diff --git a/api/src/party-types.ts b/api/src/party-types.ts index 9117a1c..427092e 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -36,6 +36,8 @@ type BaseQuestion = { points: number; song?: Song; hideSongTitle?: boolean; + questionKey?: string; + subjectKey?: string; }; export type Question = diff --git a/api/src/party/__tests__/question-generation.test.ts b/api/src/party/__tests__/question-generation.test.ts new file mode 100644 index 0000000..8ba70d5 --- /dev/null +++ b/api/src/party/__tests__/question-generation.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from "vitest"; +import type { Question, QuizRound } from "../../party-types"; +import { buildNumericQuestion } from "../numeric-question-generator"; +import { + buildMemberOptions, + type PartyAnalytics, + type PartyQuestionMember, + pickQuestionCandidate, +} from "../question-utils"; +import { buildSocialQuestion } from "../social-question-generator"; + +type Db = typeof import("../../db").db; + +function makeChoiceQuestion( + text: string, + key: string, + subjectKey: string, +): Question { + return { + type: "choice", + text, + correct: 0, + startTimestamp: 1, + endTimestamp: 2, + points: 10, + options: ["A", "B"], + questionKey: key, + subjectKey, + }; +} + +function createFakeDb(trackReleaseDate: Date | null) { + const trackRecord = { + id: "track-1", + name: "Shared Track", + album: { + name: "Shared Album", + release_date: trackReleaseDate, + }, + artists: [{ id: "artist-1", name: "Shared Artist" }], + }; + + return { + query: { + track: { + findFirst: vi.fn(async () => trackRecord), + findMany: vi.fn(async () => []), + }, + artist: { + findFirst: vi.fn(async () => ({ + id: "artist-1", + name: "Shared Artist", + })), + }, + }, + select: vi.fn(() => ({ + from: () => ({ + where: async () => [], + }), + })), + } as unknown as Db; +} + +describe("question generation", () => { + it("skips repeated question keys, subjects, and text", () => { + const history: QuizRound[] = [ + { + questionIndex: 0, + question: { + ...makeChoiceQuestion( + "Which genre appears most in the party analytics?", + "audio:genre:pop", + "genre:pop", + ), + }, + responses: [], + }, + ]; + + const question = pickQuestionCandidate( + [ + { + key: "audio:genre:pop", + subjectKey: "genre:pop", + question: makeChoiceQuestion( + "Which genre appears most in the party analytics?", + "audio:genre:pop", + "genre:pop", + ), + }, + { + key: "audio:artist:abba", + subjectKey: "artist:abba", + question: makeChoiceQuestion( + "Which artist shows up most often in the shared audio data?", + "audio:artist:abba", + "artist:abba", + ), + }, + ], + history, + 0, + ); + + expect(question?.text).toBe( + "Which artist shows up most often in the shared audio data?", + ); + }); + + it("returns null when member options would require fake placeholders", () => { + const members: PartyQuestionMember[] = [ + { userId: "a", name: "Sam" }, + { userId: "b", name: "Sam" }, + ]; + + const correctMember = members[0]; + expect(correctMember).toBeDefined(); + if (correctMember) { + expect(buildMemberOptions(correctMember, members)).toBeNull(); + } + }); + + it("skips numeric questions with missing release dates and zero counts", async () => { + const db = createFakeDb(null); + const analytics = { + storyClusters: [ + { + tracks: [ + { + name: "Shared Track", + artists: [{ name: "Shared Artist" }], + albumName: "Shared Album", + }, + ], + artists: [{ name: "Shared Artist" }], + genres: [], + }, + ], + groupSummary: { + mostSharedGenres: [], + }, + } as PartyAnalytics; + const members: PartyQuestionMember[] = [ + { userId: "a", name: "A" }, + { userId: "b", name: "B" }, + ]; + const history: QuizRound[] = [ + { + questionIndex: 0, + question: { + type: "numeric", + text: "What's the release year of Shared Album?", + correct: 2010, + startTimestamp: 1, + endTimestamp: 2, + points: 10, + range: { min: 2000, max: 2010 }, + questionKey: "numeric:album-year:Shared Album", + subjectKey: "album:Shared Album", + }, + responses: [], + }, + ]; + + const question = await buildNumericQuestion({ + db, + analytics, + index: 0, + members, + history, + }); + + expect(question).toBeNull(); + }); + + it("skips social fallback names for duplicate members", async () => { + const db = createFakeDb(null); + const members: PartyQuestionMember[] = [ + { userId: "a", name: "Sam" }, + { userId: "b", name: "Sam" }, + ]; + const quizState = { + status: "running" as const, + workflowId: null, + questionIndex: 0, + currentQuestion: null, + answers: {}, + scores: { a: 3, b: 1 }, + history: [], + }; + + const question = await buildSocialQuestion( + db, + quizState, + { + groupSummary: { + mostDiverseMember: { userId: "a", genreEntropy: 1 }, + mostSharedGenres: [], + mostAlignedPair: null, + }, + }, + members, + 0, + ); + + expect(question).toBeNull(); + }); +}); diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index 3bcb4c5..3425130 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -1,5 +1,5 @@ import type { db as Db } from "../db"; -import type { Question } from "../party-types"; +import type { Question, QuizRound } from "../party-types"; import { buildOptionsWithCorrect, buildOrderedOptions, @@ -9,7 +9,9 @@ import { getTopClusterTracks, isUsableText, type PartyAnalytics, + pickQuestionCandidate, pickRandom, + type QuestionCandidate, resolveQuestionSong, } from "./question-utils"; @@ -17,10 +19,11 @@ export async function buildAudioMetadataQuestion( dbClient: typeof Db, analytics: PartyAnalytics, index: number, + history: QuizRound[], ): Promise { type ChoiceQuestion = Extract; const questions: Array< - Omit + QuestionCandidate> > = []; const topSong = await resolveQuestionSong(dbClient, analytics); const topSongName = topSong?.name; @@ -34,13 +37,17 @@ export async function buildAudioMetadataQuestion( ); if (currentSongOptions) { questions.push({ - type: "choice", - text: "What song is currently playing?", - options: currentSongOptions, - correct: 0, - points: 10, - song: topSong ?? undefined, - hideSongTitle: true, + key: `audio:current-song:${topSongName}`, + subjectKey: `track:${topSongName}`, + question: { + type: "choice", + text: "What song is currently playing?", + options: currentSongOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + hideSongTitle: true, + }, }); } } @@ -50,14 +57,21 @@ export async function buildAudioMetadataQuestion( 4, ); if (genreOptions) { - questions.push({ - type: "choice", - text: "Which genre appears most in the party analytics?", - options: genreOptions, - correct: 0, - points: 10, - song: topSong ?? undefined, - }); + const topGenre = genreOptions[0]; + if (topGenre) { + questions.push({ + key: `audio:genre:${topGenre}`, + subjectKey: `genre:${topGenre}`, + question: { + type: "choice", + text: "Which genre appears most in the party analytics?", + options: genreOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } } const topArtists = getTopClusterArtists(analytics); @@ -66,12 +80,16 @@ export async function buildAudioMetadataQuestion( const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4); if (artistOptions) { questions.push({ - type: "choice", - text: "Which artist shows up most often in the shared audio data?", - options: artistOptions, - correct: 0, - points: 10, - song: topSong ?? undefined, + key: `audio:artist:${topArtist}`, + subjectKey: `artist:${topArtist}`, + question: { + type: "choice", + text: "Which artist shows up most often in the shared audio data?", + options: artistOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, }); } } @@ -83,12 +101,16 @@ export async function buildAudioMetadataQuestion( : null; if (trackOptions) { questions.push({ - type: "choice", - text: "Which track looks most shared across the party?", - options: trackOptions, - correct: 0, - points: 10, - song: topSong ?? undefined, + key: `audio:track:${topTrackName}`, + subjectKey: `track:${topTrackName}`, + question: { + type: "choice", + text: "Which track looks most shared across the party?", + options: trackOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, }); } } @@ -112,12 +134,16 @@ export async function buildAudioMetadataQuestion( ); if (artistOptions) { questions.push({ - type: "choice", - text: `Who performs "${randomTopTrack.name}"?`, - options: artistOptions, - correct: 0, - points: 10, - song: randomTrackSong ?? topSong ?? undefined, + key: `audio:performer:${randomTopTrack.name}`, + subjectKey: `track:${randomTopTrack.name}`, + question: { + type: "choice", + text: `Who performs "${randomTopTrack.name}"?`, + options: artistOptions, + correct: 0, + points: 10, + song: randomTrackSong ?? topSong ?? undefined, + }, }); } @@ -129,13 +155,16 @@ export async function buildAudioMetadataQuestion( ); if (trackNameOptions) { questions.push({ - type: "choice", - text: `What is the name of this track by ${correctArtist}?`, - options: trackNameOptions, - correct: 0, - points: 10, - song: randomTrackSong ?? topSong ?? undefined, - hideSongTitle: true, + key: `audio:title:${randomTopTrack.name}`, + subjectKey: `track:${randomTopTrack.name}`, + question: { + type: "choice", + text: `What is the name of this track by ${correctArtist}?`, + options: trackNameOptions, + correct: 0, + points: 10, + song: randomTrackSong ?? topSong ?? undefined, + }, }); } @@ -147,13 +176,17 @@ export async function buildAudioMetadataQuestion( ); if (alternateSongOptions) { questions.push({ - type: "choice", - text: "Which song is this audio clip from?", - options: alternateSongOptions, - correct: 0, - points: 10, - song: topSong ?? undefined, - hideSongTitle: true, + key: `audio:current-song:${topSongName}`, + subjectKey: `track:${topSongName}`, + question: { + type: "choice", + text: "Which song is this audio clip from?", + options: alternateSongOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + hideSongTitle: true, + }, }); } } @@ -170,12 +203,16 @@ export async function buildAudioMetadataQuestion( ); if (albumOptions) { questions.push({ - type: "choice", - text: `"${randomTopTrack.name}" appears on which album?`, - options: albumOptions, - correct: 0, - points: 10, - song: randomTrackSong ?? topSong ?? undefined, + key: `audio:album:${randomTopTrack.albumName}`, + subjectKey: `track:${randomTopTrack.name}`, + question: { + type: "choice", + text: `"${randomTopTrack.name}" appears on which album?`, + options: albumOptions, + correct: 0, + points: 10, + song: randomTrackSong ?? topSong ?? undefined, + }, }); } } @@ -185,7 +222,7 @@ export async function buildAudioMetadataQuestion( return null; } - const question = questions[index % questions.length]; - if (!question) throw new Error("Question not found"); + const question = pickQuestionCandidate(questions, history, index); + if (!question) return null; return buildQuestionWindow(question); } diff --git a/api/src/party/numeric-question-generator.ts b/api/src/party/numeric-question-generator.ts index 4f8723a..11c63f9 100644 --- a/api/src/party/numeric-question-generator.ts +++ b/api/src/party/numeric-question-generator.ts @@ -4,13 +4,15 @@ import { topArtist as topArtistTable, topTrack as topTrackTable, } from "../db/schema"; -import type { Question } from "../party-types"; +import type { Question, QuizRound } from "../party-types"; import { buildQuestionWindow, getReleaseYearRange, isUsableText, type PartyAnalytics, type PartyQuestionMember, + pickQuestionCandidate, + type QuestionCandidate, resolveQuestionSong, } from "./question-utils"; @@ -24,12 +26,12 @@ type BuildNumericQuestionInput = { analytics: PartyAnalytics; index: number; members: PartyQuestionMember[]; + history: QuizRound[]; }; async function getAlbumReleaseYear({ db, analytics, - index, }: BuildNumericQuestionInput): Promise { const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name; const track = trackName @@ -38,16 +40,15 @@ async function getAlbumReleaseYear({ with: { album: true }, }) : null; - const song = await resolveQuestionSong(db, analytics, { - trackName: track?.name ?? trackName ?? undefined, - }); const subject = [track?.album?.name, track?.name].find((value) => isUsableText(value), ); if (!subject) return null; - const correct = - track?.album?.release_date?.getFullYear() ?? - new Date().getFullYear() - 1 - index; + if (!track?.album?.release_date) return null; + const song = await resolveQuestionSong(db, analytics, { + trackName: track?.name ?? trackName ?? undefined, + }); + const correct = track.album.release_date.getFullYear(); return { type: "numeric", text: `What's the release year of ${subject}?`, @@ -55,6 +56,8 @@ async function getAlbumReleaseYear({ range: getReleaseYearRange(correct), points: 10, song: song ?? undefined, + questionKey: `numeric:album-year:${subject}`, + subjectKey: `album:${subject}`, }; } @@ -81,6 +84,7 @@ async function countTopTrackListeners({ ), ); const correct = new Set(entries.map((e) => e.userId)).size; + if (correct <= 0) return null; return { type: "numeric", text: `For how many players in the party is "${trackName}" a top track?`, @@ -88,6 +92,8 @@ async function countTopTrackListeners({ range: { min: 0, max: members.length }, points: 10, song: song ?? undefined, + questionKey: `numeric:top-track-count:${trackName}`, + subjectKey: `track:${trackName}`, }; } @@ -116,6 +122,7 @@ async function countFavouriteArtistListeners({ ), ); const correct = new Set(entries.map((e) => e.userId)).size; + if (correct <= 0) return null; return { type: "numeric", text: `How many players in the party have "${artistName}" as a favourite artist?`, @@ -123,24 +130,44 @@ async function countFavouriteArtistListeners({ range: { min: 0, max: members.length }, points: 10, song: song ?? undefined, + questionKey: `numeric:artist-count:${artistName}`, + subjectKey: `artist:${artistName}`, }; } export async function buildNumericQuestion( input: BuildNumericQuestionInput, ): Promise { - const questions: NumericQuestion[] = []; + const questions: Array> = []; const albumYearQ = await getAlbumReleaseYear(input); - if (albumYearQ) questions.push(albumYearQ); + if (albumYearQ) { + questions.push({ + key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`, + subjectKey: albumYearQ.subjectKey, + question: albumYearQ, + }); + } const topTrackQ = await countTopTrackListeners(input); - if (topTrackQ) questions.push(topTrackQ); + if (topTrackQ) { + questions.push({ + key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`, + subjectKey: topTrackQ.subjectKey, + question: topTrackQ, + }); + } const artistQ = await countFavouriteArtistListeners(input); - if (artistQ) questions.push(artistQ); + if (artistQ) { + questions.push({ + key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`, + subjectKey: artistQ.subjectKey, + question: artistQ, + }); + } - const question = questions[input.index % questions.length] ?? questions[0]; + const question = pickQuestionCandidate(questions, input.history, input.index); if (!question) return null; return buildQuestionWindow(question); } diff --git a/api/src/party/question-generator.ts b/api/src/party/question-generator.ts index 08dd9fc..0a65f27 100644 --- a/api/src/party/question-generator.ts +++ b/api/src/party/question-generator.ts @@ -3,7 +3,7 @@ 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 { buildQuestionWindow, fetchPartyMembers } from "./question-utils"; import { buildSocialQuestion } from "./social-question-generator"; export type PartyQuestionType = "audio-metadata" | "social" | "numeric"; @@ -24,21 +24,58 @@ export async function generatePartyQuestion({ index, }: GenerateQuestionInput): Promise { const members = await fetchPartyMembers(dbClient, partyId); - const type: PartyQuestionType = - index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social"; + const preferredOrder: PartyQuestionType[] = [ + "audio-metadata", + "social", + "numeric", + ]; + const rotation = index % preferredOrder.length; + const typeOrder = [ + ...preferredOrder.slice(rotation), + ...preferredOrder.slice(0, rotation), + ]; + + for (const type of typeOrder) { + if (type === "audio-metadata") { + const q = await buildAudioMetadataQuestion( + dbClient, + analytics, + index, + quizState.history, + ); + if (q) return q; + continue; + } + + if (type === "social") { + const q = await buildSocialQuestion( + dbClient, + quizState, + analytics, + members, + index, + ); + if (q) return q; + continue; + } - if (type === "audio-metadata") { - const q = await buildAudioMetadataQuestion(dbClient, analytics, index); - if (q) return q; - } - if (type === "numeric") { const q = await buildNumericQuestion({ db: dbClient, analytics, index, members, + history: quizState.history, }); if (q) return q; } - return buildSocialQuestion(dbClient, quizState, analytics, members, index); + + return buildQuestionWindow({ + type: "numeric" as const, + text: "How many players are in this party?", + correct: members.length, + range: { min: 0, max: members.length }, + points: 5, + subjectKey: "party-size", + questionKey: `fallback:party-size:${members.length}`, + }); } diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index 14fe0d8..b65873e 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -1,6 +1,7 @@ import type { InferSelectModel } from "drizzle-orm"; import type { db as Db } from "../db"; import type { track as trackTable } from "../db/schema"; +import type { QuizRound } from "../party-types"; export type PartyQuestionMember = { userId: string; @@ -9,7 +10,14 @@ export type PartyQuestionMember = { export type PartyAnalytics = { groupSummary?: { + totalMembers?: number; mostSharedGenres?: { name: string }[]; + mostDiverseMember?: { userId: string; genreEntropy: number } | null; + mostAlignedPair?: { + userIdA: string; + userIdB: string; + similarity?: number; + } | null; }; storyClusters?: { tracks?: { @@ -31,6 +39,17 @@ export type AnalyticsTrack = { albumName?: string; memberScores?: { userId: string; score: number }[]; }; +type QuestionLike = { + text: string; + questionKey?: string; + subjectKey?: string; +}; + +export type QuestionCandidate = { + key: string; + subjectKey?: string; + question: T; +}; export type QuestionSong = InferSelectModel; export const QUESTION_DURATION_MS = 60_000; @@ -113,6 +132,46 @@ export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] { ); } +export function pickQuestionCandidate( + candidates: QuestionCandidate[], + history: QuizRound[], + index: number, +): T | null { + const seenKeys = new Set(); + const seenSubjects = new Set(); + const seenTexts = new Set(); + + for (const round of history) { + const question = round.question; + seenTexts.add(normalizeQuestionKey(question.text)); + if (question.questionKey) + seenKeys.add(normalizeQuestionKey(question.questionKey)); + if (question.subjectKey) + seenSubjects.add(normalizeQuestionKey(question.subjectKey)); + } + + const fresh = candidates.filter((candidate) => { + const key = normalizeQuestionKey(candidate.key); + const subjectKey = candidate.subjectKey + ? normalizeQuestionKey(candidate.subjectKey) + : null; + const text = normalizeQuestionKey(candidate.question.text); + + if (seenKeys.has(key)) return false; + if (subjectKey && seenSubjects.has(subjectKey)) return false; + if (seenTexts.has(text)) return false; + return true; + }); + + if (fresh.length === 0) return null; + const pool = fresh; + return pool[index % pool.length]?.question ?? null; +} + +function normalizeQuestionKey(value: string): string { + return value.trim().toLowerCase(); +} + export function getTopClusterArtists(analytics: PartyAnalytics): string[] { return (analytics?.storyClusters?.[0]?.artists ?? []).map( (artist) => artist.name, @@ -220,7 +279,7 @@ export function buildOrderedOptions( desiredCount: number, ): string[] | null { const options = uniqueStrings( - values.filter((value): value is string => Boolean(value)), + values.filter((value): value is string => isUsableText(value)), ); return options.length >= desiredCount ? options.slice(0, desiredCount) : null; } @@ -230,9 +289,10 @@ export function buildOptionsWithCorrect( candidates: string[], desiredCount: number, ): string[] | null { + if (!isUsableText(correct)) return null; const options = uniqueStrings([ correct, - ...candidates.filter((c) => c !== correct), + ...candidates.filter((c) => isUsableText(c) && c !== correct), ]); return options.length >= desiredCount ? options.slice(0, desiredCount) : null; } @@ -271,43 +331,33 @@ export function hasClearLeader(quizState: { export function getMostDiverseMember( analytics: PartyAnalytics, members: PartyQuestionMember[], -): PartyQuestionMember { - const userId = analytics?.memberProfiles?.[0]?.userId; - return ( - members.find((member) => member.userId === userId) ?? - members[1] ?? - members[0] ?? { userId: "", name: "Player B" } - ); +): PartyQuestionMember | null { + const userId = analytics?.groupSummary?.mostDiverseMember?.userId; + if (!userId) return null; + return members.find((member) => member.userId === userId) ?? null; } export function getMostAlignedMember( analytics: PartyAnalytics, members: PartyQuestionMember[], -): PartyQuestionMember { - const userId = analytics?.pairwise?.[0]?.userIdA; - return ( - members.find((member) => member.userId === userId) ?? - members[2] ?? - members[0] ?? { userId: "", name: "Player C" } - ); +): PartyQuestionMember | null { + const userId = analytics?.groupSummary?.mostAlignedPair?.userIdA; + if (!userId) return null; + return members.find((member) => member.userId === userId) ?? null; } export function buildMemberOptions( correctMember: PartyQuestionMember, members: PartyQuestionMember[], -): string[] { +): string[] | null { const desiredCount = getPartySize(members.length); + if (!isUsableText(correctMember.name)) return null; const options = uniqueStrings([ correctMember.name, - ...members.map((member) => member.name), + ...members.map((member) => member.name).filter(isUsableText), ]); - if (options.length < desiredCount) { - for (const fallback of fallbackPlayerNames(desiredCount)) { - if (options.length >= desiredCount) break; - if (!options.includes(fallback)) options.push(fallback); - } - } + if (options.length < desiredCount) return null; const ordered = [ correctMember.name, @@ -328,27 +378,23 @@ function normalizeRange( return { min: max, max: min }; } -function fallbackPlayerNames(count: number): string[] { - return Array.from( - { length: count }, - (_, index) => `Player ${String.fromCharCode(65 + index)}`, - ); -} - export function buildMemberPairOptions( members: PartyQuestionMember[], correctPair: string, ): string[] | null { if (members.length < 3) return null; - const pairs: string[] = [correctPair]; + if (!isUsableText(correctPair)) return null; + const pairs = uniqueStrings([correctPair]); for (let i = 0; i < members.length; i++) { for (let j = i + 1; j < members.length; j++) { const left = members[i]; const right = members[j]; if (!left || !right) continue; + if (!isUsableText(left.name) || !isUsableText(right.name)) continue; const pair = `${left.name} & ${right.name}`; if (pair !== correctPair) pairs.push(pair); } } - return pairs.length >= 2 ? pairs.slice(0, 4) : null; + const deduped = uniqueStrings(pairs); + return deduped.length >= 2 ? deduped.slice(0, 4) : null; } diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index 14e27ba..b2ebafa 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -5,14 +5,15 @@ import { buildMemberPairOptions, buildQuestionWindow, getCurrentLeader, - getMostAlignedMember, getMostDiverseMember, getTopClusterTracks, getTopTrackListener, hasClearLeader, type PartyAnalytics, type PartyQuestionMember, + pickQuestionCandidate, pickRandom, + type QuestionCandidate, resolveQuestionSong, } from "./question-utils"; @@ -25,52 +26,49 @@ export async function buildSocialQuestion( ): Promise { type ChoiceQuestion = Extract; const questions: Array< - Omit + QuestionCandidate> > = []; const topSong = await resolveQuestionSong(dbClient, analytics); const hasMultipleMembers = members.length >= 2; if (hasMultipleMembers && hasClearLeader(quizState)) { const leader = getCurrentLeader(quizState, members); - questions.push({ - type: "choice", - text: "Who is leading the quiz right now?", - options: buildMemberOptions(leader, members), - correct: 0, - points: 10, - song: topSong ?? undefined, - }); + const options = buildMemberOptions(leader, members); + if (options) { + questions.push({ + key: "social:leader", + subjectKey: `member:${leader.userId}`, + question: { + type: "choice", + text: "Who is leading the quiz right now?", + options, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } } if (hasMultipleMembers) { const diverse = getMostDiverseMember(analytics, members); - questions.push({ - type: "choice", - text: "Who looks like the most diverse listener in the party?", - options: buildMemberOptions(diverse, members), - correct: 0, - points: 10, - song: topSong ?? undefined, - }); - - const aligned = getMostAlignedMember(analytics, members); - questions.push({ - type: "choice", - text: "Which member seems most aligned with the rest of the party?", - options: buildMemberOptions(aligned, members), - correct: 0, - points: 10, - song: topSong ?? undefined, - }); - - questions.push({ - type: "choice", - text: "Who would you ask for a recommendation based on the party taste?", - options: buildMemberOptions(aligned, members), - correct: 0, - points: 10, - song: topSong ?? undefined, - }); + if (diverse) { + const options = buildMemberOptions(diverse, members); + if (options) { + questions.push({ + key: "social:diverse", + subjectKey: `member:${diverse.userId}`, + question: { + type: "choice", + text: "Who looks like the most diverse listener in the party?", + options, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, + }); + } + } } const topTracks = getTopClusterTracks(analytics); @@ -83,32 +81,43 @@ export async function buildSocialQuestion( artistNames: randomTrack.artists?.map((artist) => artist.name), albumName: randomTrack.albumName, }); - questions.push({ - type: "choice", - text: `Who is most likely to have "${randomTrack.name}" in heavy rotation?`, - options: buildMemberOptions(topListener, members), - correct: 0, - points: 10, - song: randomTrackSong ?? topSong ?? undefined, - }); + const options = buildMemberOptions(topListener, members); + if (options) { + questions.push({ + key: `social:track-listener:${randomTrack.name}`, + subjectKey: `track:${randomTrack.name}`, + question: { + type: "choice", + text: `Who listens the most to "${randomTrack.name}"?`, + options, + correct: 0, + points: 10, + song: randomTrackSong ?? topSong ?? undefined, + }, + }); + } } } - if (members.length >= 3 && analytics?.pairwise?.length) { - const topPair = analytics.pairwise[0]; - const memberA = members.find((m) => m.userId === topPair?.userIdA); - const memberB = members.find((m) => m.userId === topPair?.userIdB); + if (members.length >= 3 && analytics?.groupSummary?.mostAlignedPair) { + const topPair = analytics.groupSummary.mostAlignedPair; + const memberA = members.find((m) => m.userId === topPair.userIdA); + const memberB = members.find((m) => m.userId === topPair.userIdB); if (memberA && memberB) { const correctPair = `${memberA.name} & ${memberB.name}`; const pairOptions = buildMemberPairOptions(members, correctPair); if (pairOptions) { questions.push({ - type: "choice", - text: "Which two players would probably agree on the aux?", - options: pairOptions, - correct: 0, - points: 10, - song: topSong ?? undefined, + key: `social:pair:${memberA.userId}:${memberB.userId}`, + subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`, + question: { + type: "choice", + text: "Which two players share the most musical taste?", + options: pairOptions, + correct: 0, + points: 10, + song: topSong ?? undefined, + }, }); } } @@ -118,7 +127,7 @@ export async function buildSocialQuestion( return null; } - const question = questions[index % questions.length]; - if (!question) throw new Error("Question not found"); + const question = pickQuestionCandidate(questions, quizState.history, index); + if (!question) return null; return buildQuestionWindow(question); } diff --git a/dev-proxy/package.json b/dev-proxy/package.json index cb16183..fc8dd79 100644 --- a/dev-proxy/package.json +++ b/dev-proxy/package.json @@ -3,6 +3,9 @@ "module": "index.ts", "type": "module", "private": true, + "scripts": { + "dev": "bun run --watch index.ts" + }, "devDependencies": { "@types/bun": "latest" }, diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx index 446f6a9..d61e7f8 100644 --- a/web/src/components/party/question.tsx +++ b/web/src/components/party/question.tsx @@ -24,6 +24,10 @@ import { useSpotifyPlayer } from "#/hooks/use-spotify-player"; import { useUser } from "#/hooks/user"; import { client } from "#/lib/eden"; +type PartyQuestion = NonNullable< + NonNullable["party"]>["data"]["currentQuestion"] +>; + function formatTimeLeft(milliseconds: number) { const clamped = Math.max(0, milliseconds); const totalSeconds = Math.ceil(clamped / 1000); @@ -34,6 +38,17 @@ function formatTimeLeft(milliseconds: number) { return `${minutes}:${seconds}`; } +function getQuestionAnnouncement(question: PartyQuestion) { + if (question.type === "numeric") { + return `${question.text}. Choose a number from ${question.range.min} to ${question.range.max}.`; + } + + const options = question.options + .map((option, index) => `Option ${index + 1}: ${option}`) + .join(". "); + return `${question.text}. ${options}.`; +} + export function Question() { const { party, members } = useParty(); const { user } = useUser(); @@ -48,6 +63,10 @@ export function Question() { : null; const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } = useSpotifyPlayer(spotifyTrackUri); + const questionStartTimestamp = question?.startTimestamp ?? null; + const questionAnnouncement = question + ? getQuestionAnnouncement(question) + : null; useEffect(() => { const timer = window.setInterval(() => { @@ -63,6 +82,26 @@ export function Question() { setSelectedValue(question.type === "numeric" ? question.range.min : null); }, [question]); + useEffect(() => { + if ( + !questionAnnouncement || + questionStartTimestamp == null || + !("speechSynthesis" in window) + ) { + return; + } + + const utterance = new SpeechSynthesisUtterance(questionAnnouncement); + utterance.rate = 0.95; + utterance.pitch = 1; + + window.speechSynthesis.cancel(); + window.speechSynthesis.speak(utterance); + + return () => { + window.speechSynthesis.cancel(); + }; + }, [questionAnnouncement, questionStartTimestamp]); if (!question) return (