diff --git a/api/src/party-types.ts b/api/src/party-types.ts index 4ef9fcc..033a8b6 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -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; diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index 45bfcb5..cb37ab3 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -11,14 +11,17 @@ export function buildAudioMetadataQuestion( analytics: PartyAnalytics, index: number, ): Question { - const questions: Array> = [ + type ChoiceQuestion = Extract; + const questions: Array> = [ { + 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, diff --git a/api/src/party/numeric-question-generator.ts b/api/src/party/numeric-question-generator.ts new file mode 100644 index 0000000..ec4349b --- /dev/null +++ b/api/src/party/numeric-question-generator.ts @@ -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, "startTimestamp" | "endTimestamp">; + +type BuildNumericQuestionInput = { + db: typeof db; + analytics: PartyAnalytics; + index: number; +}; + +async function getAlbumReleaseYear({ + db, + analytics, + index, +}: BuildNumericQuestionInput): Promise { + 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 { + return buildQuestionWindow(await getAlbumReleaseYear(input)); +} diff --git a/api/src/party/question-generator.ts b/api/src/party/question-generator.ts index 062fbf0..5fe3379 100644 --- a/api/src/party/question-generator.ts +++ b/api/src/party/question-generator.ts @@ -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 { 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); } diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index 5dd900d..c66c519 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -55,6 +55,29 @@ export function buildQuestionWindow( }; } +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"; } diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index 6eeeacf..b12a754 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -15,24 +15,28 @@ export function buildSocialQuestion( members: PartyQuestionMember[], index: number, ): Question { + type ChoiceQuestion = Extract; const leader = getCurrentLeader(quizState, members); const diverse = getMostDiverseMember(analytics, members); const aligned = getMostAlignedMember(analytics, members); - const questions: Array> = [ + const questions: Array> = [ { + 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, diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 611819b..f35f3dd 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -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 { 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, diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx index 822470a..81da99a 100644 --- a/web/src/components/party/question.tsx +++ b/web/src/components/party/question.tsx @@ -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(null); + const [selectedValue, setSelectedValue] = useState(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 (
Question {party.data.questionIndex + 1} @@ -102,28 +129,66 @@ export function Question() { - {question.options.map((option, index) => { - const isSelected = currentSelection === index; - return ( - - - - - {index + 1}. {option} - - {isSelected && Selected} - - - - - ); - })} + {question.type === "numeric" ? ( + + + + Choose a value + + Closest guesses get more points + + +
+ setSelectedValue(value[0] ?? null)} + /> +
+ Exact value:{" "} + + {currentNumericSelection ?? question.correct} + +
+
+
+ +
+ ) : ( + question.options?.map((option, index) => { + const isSelected = currentSelection === index; + return ( + + + + + {index + 1}. {option} + + {isSelected && Selected} + + + + + ); + }) + )}
);