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

View file

@ -11,14 +11,17 @@ export function buildAudioMetadataQuestion(
analytics: PartyAnalytics,
index: number,
): 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?",
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"],
correct: 0,
points: 10,
},
{
type: "choice",
text: "Which artist shows up most often in the shared audio data?",
options: [
getTopArtistName(analytics),
@ -30,6 +33,7 @@ export function buildAudioMetadataQuestion(
points: 10,
},
{
type: "choice",
text: "Which track looks most shared across the party?",
options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"],
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 { Question, QuizState } from "../party-types";
import { buildAudioMetadataQuestion } from "./audio-question-generator";
import { buildNumericQuestion } from "./numeric-question-generator";
import type { PartyAnalytics } from "./question-utils";
import { fetchPartyMembers } from "./question-utils";
import { buildSocialQuestion } from "./social-question-generator";
export type PartyQuestionType = "audio-metadata" | "social";
export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
type GenerateQuestionInput = {
db: typeof db;
@ -23,8 +24,11 @@ export async function generatePartyQuestion({
index,
}: GenerateQuestionInput): Promise<Question> {
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"
? buildAudioMetadataQuestion(analytics, index)
: buildSocialQuestion(quizState, analytics, members, index);
: type === "numeric"
? buildNumericQuestion({ db: dbClient, analytics, 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 {
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
}

View file

@ -15,24 +15,28 @@ export function buildSocialQuestion(
members: PartyQuestionMember[],
index: number,
): Question {
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const leader = getCurrentLeader(quizState, members);
const diverse = getMostDiverseMember(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?",
options: buildMemberOptions(leader, members),
correct: 0,
points: 10,
},
{
type: "choice",
text: "Who looks like the most diverse listener in the party?",
options: buildMemberOptions(diverse, members),
correct: 0,
points: 10,
},
{
type: "choice",
text: "Which member seems most aligned with the rest of the party?",
options: buildMemberOptions(aligned, members),
correct: 0,

View file

@ -5,7 +5,7 @@ import { partyMember } from "../db/schema";
import { generatePartyQuestion } from "../party/question-generator";
import type { PartyAnalytics } from "../party/question-utils";
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;
@ -95,24 +95,27 @@ export class QuizWorkflow extends ConfiguredInstance {
receivedPlayers.add(response.playerId);
const answeredAt = Date.now();
const isCorrect = response.selected === question.correct;
const pointsGained = isCorrect ? question.points : 0;
const selectedValue = response.selected;
const isCorrect = selectedValue === question.correct;
const quizResponse: QuizResponse = {
...response,
selectedValue,
correct: isCorrect,
answeredAt,
pointsGained,
pointsGained: 0,
};
quizState.answers[response.playerId] = quizResponse;
round.responses.push(quizResponse);
if (isCorrect) {
quizState.scores[response.playerId] =
(quizState.scores[response.playerId] ?? 0) + pointsGained;
}
await QuizWorkflow.updatePartyData(partyId, quizState);
}
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
quizState.scores[playerId] =
(quizState.scores[playerId] ?? 0) + gained;
}
await QuizWorkflow.updatePartyData(partyId, quizState);
}
// Quiz complete
@ -134,14 +137,7 @@ export class QuizWorkflow extends ConfiguredInstance {
partyId: string,
quizState: QuizState,
index: number,
): Promise<{
text: string;
options: string[];
correct: number;
startTimestamp: number;
endTimestamp: number;
points: number;
}> {
): Promise<Question> {
const partyRecord = await db.query.party.findFirst({
where: {
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()
private static async getPartyMembers(
partyId: string,

View file

@ -15,6 +15,7 @@ import {
ProgressValue,
} from "#/components/ui/progress";
import { Section, SectionTitle } from "#/components/ui/section";
import { Slider } from "#/components/ui/slider";
import { Spinner } from "#/components/ui/spinner";
import { useParty } from "#/hooks/use-party";
import { useUser } from "#/hooks/user";
@ -35,6 +36,7 @@ export function Question() {
const { user } = useUser();
const [now, setNow] = useState(() => Date.now());
const [selected, setSelected] = useState<number | null>(null);
const [selectedValue, setSelectedValue] = useState<number | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const question = party?.data?.currentQuestion;
@ -46,6 +48,12 @@ export function Question() {
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
if (!question) return;
setSelected(null);
setSelectedValue(question.type === "numeric" ? question.range.min : null);
}, [question]);
if (!question) return null;
const partyId = party.id;
@ -54,6 +62,11 @@ export function Question() {
const hasResponded = user ? party.data.answers[user.id] != null : false;
const currentSelection =
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(
0,
Math.min(
@ -64,7 +77,7 @@ export function Question() {
),
);
async function handleAnswer(optionIndex: number) {
async function handleChoiceAnswer(optionIndex: number) {
if (!partyId || hasResponded || isSubmitting) return;
setSelected(optionIndex);
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 (
<Section>
<SectionTitle>Question {party.data.questionIndex + 1}</SectionTitle>
@ -102,28 +129,66 @@ export function Question() {
</ItemDescription>
</ItemContent>
</Item>
{question.options.map((option, index) => {
const isSelected = currentSelection === index;
return (
<Item key={option} variant={isSelected ? "outline" : "default"}>
<ItemContent>
<ItemHeader>
<ItemTitle>
{index + 1}. {option}
</ItemTitle>
{isSelected && <ItemDescription>Selected</ItemDescription>}
</ItemHeader>
</ItemContent>
<Button
variant={isSelected ? "secondary" : "default"}
disabled={hasResponded || isSubmitting}
onClick={() => handleAnswer(index)}
>
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
</Button>
</Item>
);
})}
{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;
return (
<Item key={option} variant={isSelected ? "outline" : "default"}>
<ItemContent>
<ItemHeader>
<ItemTitle>
{index + 1}. {option}
</ItemTitle>
{isSelected && <ItemDescription>Selected</ItemDescription>}
</ItemHeader>
</ItemContent>
<Button
variant={isSelected ? "secondary" : "default"}
disabled={hasResponded || isSubmitting}
onClick={() => handleChoiceAnswer(index)}
>
{isSubmitting && isSelected ? <Spinner /> : "Answer"}
</Button>
</Item>
);
})
)}
</ItemGroup>
</Section>
);