numeric options

This commit is contained in:
Daniel Bulant 2026-05-03 23:14:56 +02:00
parent 55c4493459
commit 3be0ed058b
No known key found for this signature in database
8 changed files with 244 additions and 47 deletions

View file

@ -26,18 +26,31 @@ export type PartySocketOutgoing =
| { type: "ping" } | { type: "ping" }
| { type: "member_payload"; payload: unknown }; | { type: "member_payload"; payload: unknown };
export type Question = { type BaseQuestion = {
text: string; text: string;
options: string[];
correct: number; correct: number;
startTimestamp: number; startTimestamp: number;
endTimestamp: number; endTimestamp: number;
points: number; points: number;
}; };
export type Question =
| (BaseQuestion & {
type: "choice";
options: string[];
})
| (BaseQuestion & {
type: "numeric";
range: {
min: number;
max: number;
};
});
export type QuizResponse = { export type QuizResponse = {
playerId: string; playerId: string;
selected: number; selected: number;
selectedValue?: number;
correct: boolean; correct: boolean;
answeredAt: number; answeredAt: number;
pointsGained: number; pointsGained: number;
@ -59,6 +72,7 @@ export type QuizState = {
{ {
playerId: string; playerId: string;
selected: number; selected: number;
selectedValue?: number;
correct: boolean; correct: boolean;
pointsGained: number; pointsGained: number;
answeredAt: number; answeredAt: number;

View file

@ -11,14 +11,17 @@ export function buildAudioMetadataQuestion(
analytics: PartyAnalytics, analytics: PartyAnalytics,
index: number, index: number,
): Question { ): Question {
const questions: Array<Omit<Question, "startTimestamp" | "endTimestamp">> = [ type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
{ {
type: "choice",
text: "Which genre appears most in the party analytics?", text: "Which genre appears most in the party analytics?",
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"], options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"],
correct: 0, correct: 0,
points: 10, points: 10,
}, },
{ {
type: "choice",
text: "Which artist shows up most often in the shared audio data?", text: "Which artist shows up most often in the shared audio data?",
options: [ options: [
getTopArtistName(analytics), getTopArtistName(analytics),
@ -30,6 +33,7 @@ export function buildAudioMetadataQuestion(
points: 10, points: 10,
}, },
{ {
type: "choice",
text: "Which track looks most shared across the party?", text: "Which track looks most shared across the party?",
options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"], options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"],
correct: 0, correct: 0,

View file

@ -0,0 +1,47 @@
import type { db } from "../db";
import type { Question } from "../party-types";
import type { PartyAnalytics } from "./question-utils";
import { buildQuestionWindow, getQuestionRange } from "./question-utils";
type NumericQuestion = Omit<Extract<Question, { type: "numeric" }>, "startTimestamp" | "endTimestamp">;
type BuildNumericQuestionInput = {
db: typeof db;
analytics: PartyAnalytics;
index: number;
};
async function getAlbumReleaseYear({
db,
analytics,
index,
}: BuildNumericQuestionInput): Promise<NumericQuestion> {
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
const track = trackName
? await db.query.track.findFirst({
where: {
name: trackName,
},
with: {
album: true,
},
})
: null;
const correct =
track?.album?.release_date?.getFullYear() ??
new Date().getFullYear() - 1 - index;
const subject = track?.album?.name ?? track?.name ?? "the album";
return {
type: "numeric",
text: `What number best matches ${subject}?`,
correct,
range: getQuestionRange(correct, 5),
points: 10,
};
}
export async function buildNumericQuestion(
input: BuildNumericQuestionInput,
): Promise<Question> {
return buildQuestionWindow(await getAlbumReleaseYear(input));
}

View file

@ -1,11 +1,12 @@
import type { db } from "../db"; import type { db } from "../db";
import type { Question, QuizState } from "../party-types"; import type { Question, QuizState } from "../party-types";
import { buildAudioMetadataQuestion } from "./audio-question-generator"; import { buildAudioMetadataQuestion } from "./audio-question-generator";
import { buildNumericQuestion } from "./numeric-question-generator";
import type { PartyAnalytics } from "./question-utils"; import type { PartyAnalytics } from "./question-utils";
import { fetchPartyMembers } from "./question-utils"; import { fetchPartyMembers } from "./question-utils";
import { buildSocialQuestion } from "./social-question-generator"; import { buildSocialQuestion } from "./social-question-generator";
export type PartyQuestionType = "audio-metadata" | "social"; export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
type GenerateQuestionInput = { type GenerateQuestionInput = {
db: typeof db; db: typeof db;
@ -23,8 +24,11 @@ export async function generatePartyQuestion({
index, index,
}: GenerateQuestionInput): Promise<Question> { }: GenerateQuestionInput): Promise<Question> {
const members = await fetchPartyMembers(dbClient, partyId); const members = await fetchPartyMembers(dbClient, partyId);
const type: PartyQuestionType = index % 2 === 0 ? "audio-metadata" : "social"; const type: PartyQuestionType =
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
return type === "audio-metadata" return type === "audio-metadata"
? buildAudioMetadataQuestion(analytics, index) ? buildAudioMetadataQuestion(analytics, index)
: type === "numeric"
? buildNumericQuestion({ db: dbClient, analytics, index })
: buildSocialQuestion(quizState, analytics, members, index); : buildSocialQuestion(quizState, analytics, members, index);
} }

View file

@ -55,6 +55,29 @@ export function buildQuestionWindow<T extends object>(
}; };
} }
export function getQuestionDistanceScore(
selectedValue: number,
correctValue: number,
maxDistance: number,
points: number,
): number {
if (!Number.isFinite(selectedValue)) return 0;
if (maxDistance <= 0) return selectedValue === correctValue ? points : 0;
const distance = Math.abs(selectedValue - correctValue);
const ratio = Math.max(0, 1 - distance / maxDistance);
return Math.round(points * ratio);
}
export function getQuestionRange(
correctValue: number,
tolerance: number,
): { min: number; max: number } {
return {
min: Math.floor(correctValue - tolerance),
max: Math.ceil(correctValue + tolerance),
};
}
export function getTopGenreName(analytics: PartyAnalytics): string { export function getTopGenreName(analytics: PartyAnalytics): string {
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop"; return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
} }

View file

@ -15,24 +15,28 @@ export function buildSocialQuestion(
members: PartyQuestionMember[], members: PartyQuestionMember[],
index: number, index: number,
): Question { ): Question {
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const leader = getCurrentLeader(quizState, members); const leader = getCurrentLeader(quizState, members);
const diverse = getMostDiverseMember(analytics, members); const diverse = getMostDiverseMember(analytics, members);
const aligned = getMostAlignedMember(analytics, members); const aligned = getMostAlignedMember(analytics, members);
const questions: Array<Omit<Question, "startTimestamp" | "endTimestamp">> = [ const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
{ {
type: "choice",
text: "Who is leading the quiz right now?", text: "Who is leading the quiz right now?",
options: buildMemberOptions(leader, members), options: buildMemberOptions(leader, members),
correct: 0, correct: 0,
points: 10, points: 10,
}, },
{ {
type: "choice",
text: "Who looks like the most diverse listener in the party?", text: "Who looks like the most diverse listener in the party?",
options: buildMemberOptions(diverse, members), options: buildMemberOptions(diverse, members),
correct: 0, correct: 0,
points: 10, points: 10,
}, },
{ {
type: "choice",
text: "Which member seems most aligned with the rest of the party?", text: "Which member seems most aligned with the rest of the party?",
options: buildMemberOptions(aligned, members), options: buildMemberOptions(aligned, members),
correct: 0, correct: 0,

View file

@ -5,7 +5,7 @@ import { partyMember } from "../db/schema";
import { generatePartyQuestion } from "../party/question-generator"; import { generatePartyQuestion } from "../party/question-generator";
import type { PartyAnalytics } from "../party/question-utils"; import type { PartyAnalytics } from "../party/question-utils";
import { updatePartyData } from "../party/state"; import { updatePartyData } from "../party/state";
import type { QuizResponse, QuizRound, QuizState } from "../party-types"; import type { Question, QuizResponse, QuizRound, QuizState } from "../party-types";
const TOTAL_QUESTIONS = 5; const TOTAL_QUESTIONS = 5;
@ -95,25 +95,28 @@ export class QuizWorkflow extends ConfiguredInstance {
receivedPlayers.add(response.playerId); receivedPlayers.add(response.playerId);
const answeredAt = Date.now(); const answeredAt = Date.now();
const isCorrect = response.selected === question.correct; const selectedValue = response.selected;
const pointsGained = isCorrect ? question.points : 0; const isCorrect = selectedValue === question.correct;
const quizResponse: QuizResponse = { const quizResponse: QuizResponse = {
...response, ...response,
selectedValue,
correct: isCorrect, correct: isCorrect,
answeredAt, answeredAt,
pointsGained, pointsGained: 0,
}; };
quizState.answers[response.playerId] = quizResponse; quizState.answers[response.playerId] = quizResponse;
round.responses.push(quizResponse); round.responses.push(quizResponse);
if (isCorrect) { await QuizWorkflow.updatePartyData(partyId, quizState);
quizState.scores[response.playerId] = }
(quizState.scores[response.playerId] ?? 0) + pointsGained;
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
quizState.scores[playerId] =
(quizState.scores[playerId] ?? 0) + gained;
} }
await QuizWorkflow.updatePartyData(partyId, quizState); await QuizWorkflow.updatePartyData(partyId, quizState);
} }
}
// Quiz complete // Quiz complete
quizState.status = "results"; quizState.status = "results";
@ -134,14 +137,7 @@ export class QuizWorkflow extends ConfiguredInstance {
partyId: string, partyId: string,
quizState: QuizState, quizState: QuizState,
index: number, index: number,
): Promise<{ ): Promise<Question> {
text: string;
options: string[];
correct: number;
startTimestamp: number;
endTimestamp: number;
points: number;
}> {
const partyRecord = await db.query.party.findFirst({ const partyRecord = await db.query.party.findFirst({
where: { where: {
id: partyId, id: partyId,
@ -157,6 +153,46 @@ export class QuizWorkflow extends ConfiguredInstance {
}); });
} }
private static scoreRound(round: QuizRound): Array<[string, number]> {
if (round.question.type !== "numeric") {
return round.responses.map((response): [string, number] => [
response.playerId,
response.correct ? round.question.points : 0,
]);
}
const ordered = round.responses
.map((response) => ({
response,
distance: Math.abs((response.selectedValue ?? response.selected) - round.question.correct),
}))
.sort((a, b) => a.distance - b.distance);
const groups: Array<{ distance: number; responses: QuizResponse[] }> = [];
for (const item of ordered) {
const group = groups.at(-1);
if (!group || group.distance !== item.distance) {
groups.push({ distance: item.distance, responses: [item.response] });
} else {
group.responses.push(item.response);
}
}
const scoringGroups = groups.slice(0, Math.max(0, groups.length - 1));
if (groups.length <= 1) {
return round.responses.map((response) => [response.playerId, round.question.points]);
}
return groups.flatMap((group, index) => {
const factor = (groups.length - index - 1) / (groups.length - 1);
const gained = Math.round(round.question.points * factor);
return group.responses.map((response): [string, number] => [
response.playerId,
gained,
]);
});
}
@DBOS.step() @DBOS.step()
private static async getPartyMembers( private static async getPartyMembers(
partyId: string, partyId: string,

View file

@ -15,6 +15,7 @@ import {
ProgressValue, ProgressValue,
} from "#/components/ui/progress"; } from "#/components/ui/progress";
import { Section, SectionTitle } from "#/components/ui/section"; import { Section, SectionTitle } from "#/components/ui/section";
import { Slider } from "#/components/ui/slider";
import { Spinner } from "#/components/ui/spinner"; import { Spinner } from "#/components/ui/spinner";
import { useParty } from "#/hooks/use-party"; import { useParty } from "#/hooks/use-party";
import { useUser } from "#/hooks/user"; import { useUser } from "#/hooks/user";
@ -35,6 +36,7 @@ export function Question() {
const { user } = useUser(); const { user } = useUser();
const [now, setNow] = useState(() => Date.now()); const [now, setNow] = useState(() => Date.now());
const [selected, setSelected] = useState<number | null>(null); const [selected, setSelected] = useState<number | null>(null);
const [selectedValue, setSelectedValue] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const question = party?.data?.currentQuestion; const question = party?.data?.currentQuestion;
@ -46,6 +48,12 @@ export function Question() {
return () => window.clearInterval(timer); return () => window.clearInterval(timer);
}, []); }, []);
useEffect(() => {
if (!question) return;
setSelected(null);
setSelectedValue(question.type === "numeric" ? question.range.min : null);
}, [question]);
if (!question) return null; if (!question) return null;
const partyId = party.id; const partyId = party.id;
@ -54,6 +62,11 @@ export function Question() {
const hasResponded = user ? party.data.answers[user.id] != null : false; const hasResponded = user ? party.data.answers[user.id] != null : false;
const currentSelection = const currentSelection =
selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null); selected ?? (user ? (party.data.answers[user.id]?.selected ?? null) : null);
const currentNumericSelection =
selectedValue ??
(user ? (party.data.answers[user.id]?.selectedValue ?? null) : null);
const numericMin = question.type === "numeric" ? question.range.min : 0;
const numericMax = question.type === "numeric" ? question.range.max : 0;
const progressValue = Math.max( const progressValue = Math.max(
0, 0,
Math.min( Math.min(
@ -64,7 +77,7 @@ export function Question() {
), ),
); );
async function handleAnswer(optionIndex: number) { async function handleChoiceAnswer(optionIndex: number) {
if (!partyId || hasResponded || isSubmitting) return; if (!partyId || hasResponded || isSubmitting) return;
setSelected(optionIndex); setSelected(optionIndex);
setIsSubmitting(true); setIsSubmitting(true);
@ -77,6 +90,20 @@ export function Question() {
} }
} }
async function handleNumericAnswer() {
if (!partyId || hasResponded || isSubmitting) return;
const value = selectedValue;
if (value == null) return;
setIsSubmitting(true);
try {
await client.api.party({ partyId }).quiz.response.post({
selected: value,
});
} finally {
setIsSubmitting(false);
}
}
return ( return (
<Section> <Section>
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle> <SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
@ -102,7 +129,44 @@ export function Question() {
</ItemDescription> </ItemDescription>
</ItemContent> </ItemContent>
</Item> </Item>
{question.options.map((option, index) => { {question.type === "numeric" ? (
<Item variant="muted">
<ItemContent>
<ItemHeader>
<ItemTitle>Choose a value</ItemTitle>
<ItemDescription>
Closest guesses get more points
</ItemDescription>
</ItemHeader>
<div className="space-y-3">
<Slider
min={numericMin}
max={numericMax}
step={1}
value={
currentNumericSelection != null
? [currentNumericSelection]
: undefined
}
onValueChange={(value) => setSelectedValue(value[0] ?? null)}
/>
<div className="text-sm text-muted-foreground">
Exact value:{" "}
<span className="font-medium text-foreground">
{currentNumericSelection ?? question.correct}
</span>
</div>
</div>
</ItemContent>
<Button
disabled={hasResponded || isSubmitting || selectedValue == null}
onClick={() => void handleNumericAnswer()}
>
{isSubmitting ? <Spinner /> : "Answer"}
</Button>
</Item>
) : (
question.options?.map((option, index) => {
const isSelected = currentSelection === index; const isSelected = currentSelection === index;
return ( return (
<Item key={option} variant={isSelected ? "outline" : "default"}> <Item key={option} variant={isSelected ? "outline" : "default"}>
@ -117,13 +181,14 @@ export function Question() {
<Button <Button
variant={isSelected ? "secondary" : "default"} variant={isSelected ? "secondary" : "default"}
disabled={hasResponded || isSubmitting} disabled={hasResponded || isSubmitting}
onClick={() => handleAnswer(index)} onClick={() => handleChoiceAnswer(index)}
> >
{isSubmitting && isSelected ? <Spinner /> : "Answer"} {isSubmitting && isSelected ? <Spinner /> : "Answer"}
</Button> </Button>
</Item> </Item>
); );
})} })
)}
</ItemGroup> </ItemGroup>
</Section> </Section>
); );