diff --git a/api/src/party/audio-question-generator.ts b/api/src/party/audio-question-generator.ts index 8f36dab..cbddef4 100644 --- a/api/src/party/audio-question-generator.ts +++ b/api/src/party/audio-question-generator.ts @@ -86,8 +86,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: "What song is currently playing?", - options: currentSongOptions, - correct: 0, + options: currentSongOptions.options, + correct: currentSongOptions.correct, points: 10, song: topSong ?? undefined, hideSongTitle: true, @@ -96,25 +96,25 @@ export async function buildAudioMetadataQuestion( } } - const genreOptions = buildOrderedOptions( - getMostSharedGenreNames(analytics), - 4, - ); - if (genreOptions) { - const topGenre = genreOptions[0]; + const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4); + if (genreNames) { + const topGenre = genreNames[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 genreOptions = buildOptionsWithCorrect(topGenre, genreNames, 4); + if (genreOptions) { + questions.push({ + key: `audio:genre:${topGenre}`, + subjectKey: `genre:${topGenre}`, + question: { + type: "choice", + text: "Which genre appears most in the party analytics?", + options: genreOptions.options, + correct: genreOptions.correct, + points: 10, + song: topSong ?? undefined, + }, + }); + } } } @@ -135,8 +135,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: "Which artist shows up most often in the shared audio data?", - options: artistOptions, - correct: 0, + options: artistOptions.options, + correct: artistOptions.correct, points: 10, song: topSong ?? undefined, }, @@ -161,8 +161,8 @@ export async function buildAudioMetadataQuestion( getTrackFairness(topTrack, members, history).memberCount > 1 ? "Which track looks most shared across the party?" : "Which track stands out in the party analytics?", - options: trackOptions, - correct: 0, + options: trackOptions.options, + correct: trackOptions.correct, points: 10, song: topSong ?? undefined, }, @@ -184,8 +184,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: `Which artist appears on "${topTrack.albumName}"?`, - options: artistOptions, - correct: 0, + options: artistOptions.options, + correct: artistOptions.correct, points: 10, song: topSong ?? undefined, }, @@ -226,8 +226,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: "Which of these tracks came out first?", - options, - correct: 0, + options: options.options, + correct: options.correct, points: 10, song: topSong ?? undefined, }, @@ -250,8 +250,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: "Which of these tracks came out most recently?", - options, - correct: 0, + options: options.options, + correct: options.correct, points: 10, song: topSong ?? undefined, }, @@ -294,8 +294,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: `What's the longest track by ${artistName}?`, - options, - correct: 0, + options: options.options, + correct: options.correct, points: 10, song: topSong ?? undefined, }, @@ -326,8 +326,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: `Who performs "${topTrack.name}"?`, - options: artistOptions, - correct: 0, + options: artistOptions.options, + correct: artistOptions.correct, points: 10, song: trackSong ?? topSong ?? undefined, }, @@ -348,8 +348,9 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: `What is the name of this track by ${correctArtist}?`, - options: trackNameOptions, - correct: 0, + options: trackNameOptions.options, + correct: trackNameOptions.correct, + hideSongTitle: true, points: 10, song: trackSong ?? topSong ?? undefined, }, @@ -369,8 +370,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: "Which song is this audio clip from?", - options: alternateSongOptions, - correct: 0, + options: alternateSongOptions.options, + correct: alternateSongOptions.correct, points: 10, song: topSong ?? undefined, hideSongTitle: true, @@ -397,8 +398,8 @@ export async function buildAudioMetadataQuestion( question: { type: "choice", text: `"${topTrack.name}" appears on which album?`, - options: albumOptions, - correct: 0, + options: albumOptions.options, + correct: albumOptions.correct, points: 10, song: trackSong ?? topSong ?? undefined, }, diff --git a/api/src/party/question-utils.ts b/api/src/party/question-utils.ts index e8e354b..78f8fee 100644 --- a/api/src/party/question-utils.ts +++ b/api/src/party/question-utils.ts @@ -745,14 +745,28 @@ export function buildOptionsWithCorrect( correct: string, candidates: string[], desiredCount: number, -): string[] | null { +): { options: string[]; correct: number } | null { if (!isUsableText(correct)) return null; const options = uniqueStrings([ correct, ...candidates.filter((c) => isUsableText(c) && c !== correct), ]); const optionCount = getAvailableOptionCount(options.length, desiredCount); - return optionCount ? options.slice(0, optionCount) : null; + if (!optionCount) return null; + const shuffled = shuffleOptions(options.slice(0, optionCount)); + return { options: shuffled, correct: shuffled.indexOf(correct) }; +} + +function shuffleOptions(options: string[]): string[] { + const shuffled = options.slice(); + for (let i = shuffled.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + const value = shuffled[i]; + if (value === undefined) continue; + shuffled[i] = shuffled[j] ?? value; + shuffled[j] = value; + } + return shuffled; } export function pickRandom(items: T[]): T | null { diff --git a/api/src/party/social-question-generator.ts b/api/src/party/social-question-generator.ts index 016b635..22ccfcc 100644 --- a/api/src/party/social-question-generator.ts +++ b/api/src/party/social-question-generator.ts @@ -3,6 +3,7 @@ import type { Question, QuizState } from "../party-types"; import { buildMemberOptions, buildMemberPairOptions, + buildOptionsWithCorrect, buildQuestionWindow, getCurrentLeader, getFairQuestionTracks, @@ -34,15 +35,18 @@ export async function buildSocialQuestion( if (hasMultipleMembers && hasClearLeader(quizState)) { const leader = getCurrentLeader(quizState, members); const options = buildMemberOptions(leader, members); - if (options) { + const choices = options + ? buildOptionsWithCorrect(leader.name, options, options.length) + : null; + if (choices) { questions.push({ key: "social:leader", subjectKey: `member:${leader.userId}`, question: { type: "choice", text: "Who is leading the quiz right now?", - options, - correct: 0, + options: choices.options, + correct: choices.correct, points: 10, song: topSong ?? undefined, }, @@ -54,15 +58,18 @@ export async function buildSocialQuestion( const diverse = getMostDiverseMember(analytics, members); if (diverse) { const options = buildMemberOptions(diverse, members); - if (options) { + const choices = options + ? buildOptionsWithCorrect(diverse.name, options, options.length) + : null; + if (choices) { 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, + options: choices.options, + correct: choices.correct, points: 10, song: topSong ?? undefined, }, @@ -98,7 +105,10 @@ export async function buildSocialQuestion( albumName: topTrack.albumName, }); const options = buildMemberOptions(topListener, members); - if (options) { + const choices = options + ? buildOptionsWithCorrect(topListener.name, options, options.length) + : null; + if (choices) { questions.push({ key: `social:track-listener:${topTrack.name}`, subjectKey: `track:${topTrack.name}`, @@ -106,8 +116,8 @@ export async function buildSocialQuestion( question: { type: "choice", text: `Who listens the most to "${topTrack.name}"?`, - options, - correct: 0, + options: choices.options, + correct: choices.correct, points: 10, song: trackSong ?? topSong ?? undefined, }, @@ -123,15 +133,18 @@ export async function buildSocialQuestion( if (memberA && memberB) { const correctPair = `${memberA.name} & ${memberB.name}`; const pairOptions = buildMemberPairOptions(members, correctPair); - if (pairOptions) { + const pairChoices = pairOptions + ? buildOptionsWithCorrect(correctPair, pairOptions, pairOptions.length) + : null; + if (pairChoices) { questions.push({ 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, + options: pairChoices.options, + correct: pairChoices.correct, points: 10, song: topSong ?? undefined, }, diff --git a/api/src/party/state.ts b/api/src/party/state.ts index 261ccf9..5572295 100644 --- a/api/src/party/state.ts +++ b/api/src/party/state.ts @@ -1,7 +1,7 @@ import { eq } from "drizzle-orm"; import type { db as Db } from "../db"; import { party } from "../db/schema"; -import type { PartySocketEvent, QuizState } from "../party-types"; +import type { PartySocketEvent, PartyStatus, QuizState } from "../party-types"; import { publishDeviceEventForUser } from "../routes/device-socket"; import { pubsub } from "../routes/party-socket"; @@ -9,6 +9,7 @@ export async function updatePartyData( db: typeof Db, id: string, data: QuizState, + status?: PartyStatus, ) { const members = await db.query.partyMember.findMany({ where: { @@ -24,22 +25,21 @@ export async function updatePartyData( }, }); if (!partyObject) throw new Error("Missing party"); + const updatedParty = { + ...partyObject, + status: status ?? partyObject.status, + data, + }; pubsub.publishPartyData(id, { type: "party_status", - party: { - ...partyObject, - data, - }, + party: updatedParty, members, }); const event: PartySocketEvent = { type: "party_status", - party: { - ...partyObject, - data, - }, + party: updatedParty, members, }; for (const member of members) { @@ -48,10 +48,7 @@ export async function updatePartyData( `user:${member.userId}`, JSON.stringify({ type: "party_status", - party: { - ...partyObject, - data, - }, + party: updatedParty, members, }), ); @@ -60,7 +57,8 @@ export async function updatePartyData( await db .update(party) .set({ - data: data, + data, + ...(status ? { status } : {}), lastUpdated: new Date(), }) .where(eq(party.id, id)); diff --git a/api/src/routes/device-socket.ts b/api/src/routes/device-socket.ts index 1d2cbe9..e3b91b0 100644 --- a/api/src/routes/device-socket.ts +++ b/api/src/routes/device-socket.ts @@ -73,6 +73,15 @@ function isDeviceQuizResponsePayload( ); } +function isValidAnswer(quizData: QuizState, selected: number): boolean { + const question = quizData.currentQuestion; + if (!question) return false; + if (question.type === "choice") { + return selected >= 0 && selected < question.options.length; + } + return selected >= question.range.min && selected <= question.range.max; +} + function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) { if (!devProxySocket) { console.log("[device-socket] no dev proxy for event", deviceId, event.type); @@ -235,6 +244,13 @@ async function forwardDevicePayload(deviceId: string, payload: unknown) { }); return; } + if (!isValidAnswer(quizData, payload.QuizResponse)) { + sendDeviceEvent(deviceId, { + type: "error", + message: "Invalid answer.", + }); + return; + } await DBOS.send( quizData.workflowId, diff --git a/api/src/routes/quiz.ts b/api/src/routes/quiz.ts index 1c27270..833491b 100644 --- a/api/src/routes/quiz.ts +++ b/api/src/routes/quiz.ts @@ -26,6 +26,15 @@ function broadcastStatusToMembers( const quizWf = new QuizWorkflow(); +function isValidAnswer(quizData: QuizState, selected: number): boolean { + const question = quizData.currentQuestion; + if (!question) return false; + if (question.type === "choice") { + return selected >= 0 && selected < question.options.length; + } + return selected >= question.range.min && selected <= question.range.max; +} + export const quizRoutes = new Elysia() .use(betterAuthElysia) .group("/party/:partyId/quiz", (app) => @@ -51,11 +60,6 @@ export const quizRoutes = new Elysia() return { error: "Quiz already running" }; } - const handle = await DBOS.startWorkflow(quizWf.startQuiz, { - queueName: quizQueue.name, - enqueueOptions: { queuePartitionKey: params.partyId }, - })(params.partyId); - await db .update(party) .set({ @@ -65,6 +69,11 @@ export const quizRoutes = new Elysia() }) .where(eq(party.id, params.partyId)); + const handle = await DBOS.startWorkflow(quizWf.startQuiz, { + queueName: quizQueue.name, + enqueueOptions: { queuePartitionKey: params.partyId }, + })(params.partyId); + const status = await getPartyStatus(params.partyId); broadcastStatusToMembers(status); @@ -127,6 +136,12 @@ export const quizRoutes = new Elysia() .post( "/response", async ({ user, body, params, set }) => { + const membership = await getMemberRecord(db, user.id); + if (!membership || membership.partyId !== params.partyId) { + set.status = 403; + return { error: "Not a member of this party" }; + } + const party = await db.query.party.findFirst({ where: { id: params.partyId, @@ -148,10 +163,25 @@ export const quizRoutes = new Elysia() set.status = 500; return { error: "Workflow ID not found" }; } + if ( + body.questionIndex !== undefined && + body.questionIndex !== quizData.questionIndex + ) { + set.status = 409; + return { error: "Stale question response" }; + } + if (!isValidAnswer(quizData, body.selected)) { + set.status = 400; + return { error: "Invalid answer" }; + } await DBOS.send( quizData.workflowId, - { playerId: user.id, selected: body.selected }, + { + playerId: user.id, + selected: body.selected, + questionIndex: body.questionIndex, + }, "quiz_responses", ); @@ -161,6 +191,7 @@ export const quizRoutes = new Elysia() auth: true, body: t.Object({ selected: t.Integer(), + questionIndex: t.Optional(t.Integer()), }), }, ), diff --git a/api/src/workflows/quiz.ts b/api/src/workflows/quiz.ts index 489d16e..479a3ab 100644 --- a/api/src/workflows/quiz.ts +++ b/api/src/workflows/quiz.ts @@ -24,6 +24,7 @@ export const quizQueue = new WorkflowQueue("quiz_queue", { type Response = { playerId: string; selected: number; + questionIndex?: number; }; export class QuizWorkflow extends ConfiguredInstance { @@ -53,15 +54,15 @@ export class QuizWorkflow extends ConfiguredInstance { history: [], }; - // Initialize quiz state - await QuizWorkflow.updatePartyData(partyId, quizState); - // Get party members to initialize scores let members = await QuizWorkflow.getPartyMembers(partyId); for (const member of members) { quizState.scores[member.userId] = 0; } + // Initialize quiz state after scores are ready. + await QuizWorkflow.updatePartyData(partyId, quizState); + for (let i = 0; i < TOTAL_QUESTIONS; i++) { quizState.status = "running"; quizState.questionIndex = i; @@ -96,7 +97,7 @@ export class QuizWorkflow extends ConfiguredInstance { if (response === null) { // Timeout - fill in missing players with no answer - const now = Date.now(); + const now = await DBOS.now(); if (now < question.endTimestamp) continue; for (const memberId of memberIds) { if (!receivedPlayers.has(memberId)) { @@ -116,10 +117,17 @@ export class QuizWorkflow extends ConfiguredInstance { break; } + if (!memberIds.has(response.playerId)) continue; + if ( + response.questionIndex !== undefined && + response.questionIndex !== i + ) { + continue; + } if (receivedPlayers.has(response.playerId)) continue; receivedPlayers.add(response.playerId); - const answeredAt = Date.now(); + const answeredAt = await DBOS.now(); const selectedValue = response.selected; const isCorrect = selectedValue === question.correct; const quizResponse: QuizResponse = { @@ -148,15 +156,16 @@ export class QuizWorkflow extends ConfiguredInstance { // Quiz complete quizState.status = "results"; - await QuizWorkflow.updatePartyData(partyId, quizState); + await QuizWorkflow.updatePartyData(partyId, quizState, "ended"); } @DBOS.step() private static async updatePartyData( partyId: string, quizState: QuizState, + status?: "created" | "started" | "ended", ): Promise { - await updatePartyData(db, partyId, quizState); + await updatePartyData(db, partyId, quizState, status); } @DBOS.step() @@ -215,10 +224,9 @@ export class QuizWorkflow extends ConfiguredInstance { } if (groups.length <= 1) { - return ordered.map(({ response }) => [ - response.playerId, - round.question.points, - ]); + const onlyDistance = groups[0]?.distance ?? Number.POSITIVE_INFINITY; + const gained = onlyDistance === 0 ? round.question.points : 0; + return ordered.map(({ response }) => [response.playerId, gained]); } const scoredAnswers = groups.flatMap((group, index) => { diff --git a/web/src/components/party/question.tsx b/web/src/components/party/question.tsx index 551fcdb..dc56f26 100644 --- a/web/src/components/party/question.tsx +++ b/web/src/components/party/question.tsx @@ -101,6 +101,7 @@ export function Question() { ); const partyId = party.id; + const questionIndex = party.data.questionIndex; const timeLeft = formatTimeLeft(question.endTimestamp - now); const answeredCount = Object.keys(party.data.answers).length; const hasResponded = user ? party.data.answers[user.id] != null : false; @@ -128,6 +129,7 @@ export function Question() { try { await client.api.party({ partyId }).quiz.response.post({ selected: optionIndex, + questionIndex, }); } finally { setIsSubmitting(false); @@ -142,6 +144,7 @@ export function Question() { try { await client.api.party({ partyId }).quiz.response.post({ selected: value, + questionIndex, }); } finally { setIsSubmitting(false); diff --git a/web/vite.config.ts b/web/vite.config.ts index 139961d..f0fa08e 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -23,7 +23,7 @@ const config = defineConfig({ }), ], server: { - allowedHosts: ["aura.rpi1.danbulant.cloud"], + allowedHosts: ["aura.rpi1.danbulant.cloud", "fern.rpi1.danbulant.cloud"], proxy: { "/api": { target: "http://localhost:4000",