diff --git a/api/src/party-types.ts b/api/src/party-types.ts index 033a8b6..5e2cc3c 100644 --- a/api/src/party-types.ts +++ b/api/src/party-types.ts @@ -36,16 +36,16 @@ type BaseQuestion = { export type Question = | (BaseQuestion & { - type: "choice"; - options: string[]; - }) + type: "choice"; + options: string[]; + }) | (BaseQuestion & { - type: "numeric"; - range: { - min: number; - max: number; - }; - }); + type: "numeric"; + range: { + min: number; + max: number; + }; + }); export type QuizResponse = { playerId: string; diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index cb37ab3..e05dc74 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -1,10 +1,13 @@ import type { Question } from "../party-types"; import { + buildOptionsWithCorrect, + buildOrderedOptions, buildQuestionWindow, - getTopArtistName, - getTopGenreName, - getTopTrackName, + getMostSharedGenreNames, + getTopClusterArtists, + getTopClusterTracks, type PartyAnalytics, + pickRandom, } from "./question-utils"; export function buildAudioMetadataQuestion( @@ -12,34 +15,104 @@ export function buildAudioMetadataQuestion( index: number, ): Question { type ChoiceQuestion = Extract; - const questions: Array> = [ - { + const questions: Array< + Omit + > = []; + + const genreOptions = buildOrderedOptions( + getMostSharedGenreNames(analytics), + 4, + ); + if (genreOptions) { + questions.push({ type: "choice", text: "Which genre appears most in the party analytics?", - options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"], + options: genreOptions, correct: 0, points: 10, - }, - { - type: "choice", - text: "Which artist shows up most often in the shared audio data?", - options: [ - getTopArtistName(analytics), - "Artist B", - "Artist C", - "Artist D", - ], - correct: 0, - points: 10, - }, - { - type: "choice", - text: "Which track looks most shared across the party?", - options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"], - correct: 0, - points: 10, - }, - ]; + }); + } + + const topArtists = getTopClusterArtists(analytics); + const topArtist = topArtists[0]; + if (topArtist) { + 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, + }); + } + } + + const topTracks = getTopClusterTracks(analytics); + if (topTracks.length > 0) { + const trackNames = topTracks.map((track) => track.name); + const topTrackName = trackNames[0]; + const trackOptions = topTrackName + ? buildOptionsWithCorrect(topTrackName, trackNames, 4) + : null; + if (trackOptions) { + questions.push({ + type: "choice", + text: "Which track looks most shared across the party?", + options: trackOptions, + correct: 0, + points: 10, + }); + } + } + + const randomTopTrack = pickRandom(topTracks); + if (randomTopTrack) { + const trackArtists = + randomTopTrack.artists?.map((artist) => artist.name) ?? []; + const allArtists = topArtists.length > 0 ? topArtists : trackArtists; + const correctArtist = trackArtists[0] ?? allArtists[0]; + if (correctArtist) { + const artistOptions = buildOptionsWithCorrect( + correctArtist, + allArtists, + 4, + ); + if (artistOptions) { + questions.push({ + type: "choice", + text: `Who performs "${randomTopTrack.name}"?`, + options: artistOptions, + correct: 0, + points: 10, + }); + } + } + + if (randomTopTrack.albumName) { + const albumNames = topTracks + .map((track) => track.albumName) + .filter((name): name is string => Boolean(name)); + const albumOptions = buildOptionsWithCorrect( + randomTopTrack.albumName, + albumNames, + 4, + ); + if (albumOptions) { + questions.push({ + type: "choice", + text: `"${randomTopTrack.name}" appears on which album?`, + options: albumOptions, + correct: 0, + points: 10, + }); + } + } + } + + if (questions.length === 0) { + throw new Error("Question not found"); + } const question = questions[index % questions.length]; if (!question) throw new Error("Question not found"); diff --git a/api/src/party/numeric-question-generator.ts b/api/src/party/numeric-question-generator.ts index d26bd3c..87c4dda 100644 --- a/api/src/party/numeric-question-generator.ts +++ b/api/src/party/numeric-question-generator.ts @@ -3,7 +3,10 @@ import type { Question } from "../party-types"; import type { PartyAnalytics } from "./question-utils"; import { buildQuestionWindow, getQuestionRange } from "./question-utils"; -type NumericQuestion = Omit, "startTimestamp" | "endTimestamp">; +type NumericQuestion = Omit< + Extract, + "startTimestamp" | "endTimestamp" +>; type BuildNumericQuestionInput = { db: typeof db; diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index c66c519..3cd231a 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -10,8 +10,13 @@ export type PartyAnalytics = { mostSharedGenres?: { name: string }[]; }; storyClusters?: { - tracks?: { name: string }[]; + tracks?: { + name: string; + artists?: { name: string }[]; + albumName?: string; + }[]; artists?: { name: string }[]; + genres?: { name: string }[]; }[]; memberProfiles?: { userId: string }[]; pairwise?: { userIdA: string; userIdB: string }[]; @@ -78,16 +83,50 @@ export function getQuestionRange( }; } -export function getTopGenreName(analytics: PartyAnalytics): string { - return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop"; +export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] { + return (analytics?.groupSummary?.mostSharedGenres ?? []).map( + (genre) => genre.name, + ); } -export function getTopArtistName(analytics: PartyAnalytics): string { - return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A"; +export function getTopClusterArtists(analytics: PartyAnalytics): string[] { + return (analytics?.storyClusters?.[0]?.artists ?? []).map( + (artist) => artist.name, + ); } -export function getTopTrackName(analytics: PartyAnalytics): string { - return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A"; +export function getTopClusterTracks( + analytics: PartyAnalytics, +): Array<{ name: string; artists?: { name: string }[]; albumName?: string }> { + return analytics?.storyClusters?.[0]?.tracks ?? []; +} + +export function buildOrderedOptions( + values: Array, + desiredCount: number, +): string[] | null { + const options = uniqueStrings( + values.filter((value): value is string => Boolean(value)), + ); + return options.length >= desiredCount ? options.slice(0, desiredCount) : null; +} + +export function buildOptionsWithCorrect( + correct: string, + candidates: string[], + desiredCount: number, +): string[] | null { + const options = uniqueStrings([ + correct, + ...candidates.filter((c) => c !== correct), + ]); + return options.length >= desiredCount ? options.slice(0, desiredCount) : null; +} + +export function pickRandom(items: T[]): T | null { + if (items.length === 0) return null; + const index = Math.floor(Math.random() * items.length); + return items[index] ?? null; } export function getCurrentLeader( diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index b12a754..b80c18f 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -20,7 +20,9 @@ export function buildSocialQuestion( const diverse = getMostDiverseMember(analytics, members); const aligned = getMostAlignedMember(analytics, members); - const questions: Array> = [ + const questions: Array< + Omit + > = [ { type: "choice", text: "Who is leading the quiz right now?", diff --git a/api/src/routes/device-socket.ts b/api/src/routes/device-socket.ts index bd4e765..8bfe28c 100644 --- a/api/src/routes/device-socket.ts +++ b/api/src/routes/device-socket.ts @@ -1,44 +1,47 @@ -import Elysia from "elysia" -import { broadcastQuizState, partyTopic, socketPartyId, userTopic } from "./party-socket" -import { getMemberRecord, getPartyStatus } from "../party-data" -import { db } from "../db" +import Elysia from "elysia"; +import { db } from "../db"; +import { getMemberRecord, getPartyStatus } from "../party-data"; +import { + broadcastQuizState, + partyTopic, + socketPartyId, + userTopic, +} from "./party-socket"; -export const partySocketApp = new Elysia() - .group("/dev-socket", (app) => - app - .get("/test", () => ({ ok: 1 })) - .ws("/ws", { - async open(ws) { - let id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t" - ws.subscribe(userTopic(id)) - const membership = await getMemberRecord(db, id); - if (!membership) { - ws.send( - JSON.stringify({ - type: "snapshot", - party: null, - members: [], - }), - ); - return; - } +export const partySocketApp = new Elysia().group("/dev-socket", (app) => + app + .get("/test", () => ({ ok: 1 })) + .ws("/ws", { + async open(ws) { + const id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t"; + ws.subscribe(userTopic(id)); + const membership = await getMemberRecord(db, id); + if (!membership) { + ws.send( + JSON.stringify({ + type: "snapshot", + party: null, + members: [], + }), + ); + return; + } - socketPartyId.set(ws, membership.partyId); - ws.subscribe(partyTopic(membership.partyId)); + socketPartyId.set(ws, membership.partyId); + ws.subscribe(partyTopic(membership.partyId)); - const snapshot = await getPartyStatus(membership.partyId); - if (snapshot) { - ws.send( - JSON.stringify({ - type: "snapshot", - party: snapshot.party, - members: snapshot.members, - }), - ); + const snapshot = await getPartyStatus(membership.partyId); + if (snapshot) { + ws.send( + JSON.stringify({ + type: "snapshot", + party: snapshot.party, + members: snapshot.members, + }), + ); - await broadcastQuizState(ws, membership.partyId); - } - } - - }) - ) + await broadcastQuizState(ws, membership.partyId); + } + }, + }), +); diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 680a35a..5230542 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -5,8 +5,13 @@ 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 { Question, QuizResponse, QuizRound, QuizState } from "../party-types"; -import { partyAnalysisWorkflow, PartyAnalysisWorkflow } from "./party-analysis"; +import type { + Question, + QuizResponse, + QuizRound, + QuizState, +} from "../party-types"; +import { partyAnalysisWorkflow } from "./party-analysis"; const TOTAL_QUESTIONS = 5; @@ -35,9 +40,9 @@ export class QuizWorkflow extends ConfiguredInstance { answers: {}, scores: {}, history: [], - }; + }; - await partyAnalysisWorkflow.analyzeParty(partyId) + await partyAnalysisWorkflow.analyzeParty(partyId); // Initialize quiz state await QuizWorkflow.updatePartyData(partyId, quizState); @@ -76,10 +81,10 @@ export class QuizWorkflow extends ConfiguredInstance { deadlineEpochMS: question.endTimestamp, }); - if (response === null) { + if (response === null) { // Timeout - fill in missing players with no answer const now = Date.now(); - if (now < question.endTimestamp) continue; + if (now < question.endTimestamp) continue; for (const memberId of memberIds) { if (!receivedPlayers.has(memberId)) { receivedPlayers.add(memberId); @@ -116,8 +121,7 @@ export class QuizWorkflow extends ConfiguredInstance { } for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) { - quizState.scores[playerId] = - (quizState.scores[playerId] ?? 0) + gained; + quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained; } await QuizWorkflow.updatePartyData(partyId, quizState); @@ -169,7 +173,10 @@ export class QuizWorkflow extends ConfiguredInstance { const ordered = round.responses .map((response) => ({ response, - distance: Math.abs((response.selectedValue ?? response.selected) - round.question.correct), + distance: Math.abs( + (response.selectedValue ?? response.selected) - + round.question.correct, + ), })) .sort((a, b) => a.distance - b.distance); @@ -183,9 +190,12 @@ export class QuizWorkflow extends ConfiguredInstance { } } - const scoringGroups = groups.slice(0, Math.max(0, groups.length - 1)); + 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 round.responses.map((response) => [ + response.playerId, + round.question.points, + ]); } return groups.flatMap((group, index) => {