attempt at fixing few issues

This commit is contained in:
Daniel Bulant 2026-05-27 21:18:35 +02:00
parent e4459833f4
commit bbf3870a85
No known key found for this signature in database
9 changed files with 170 additions and 86 deletions

View file

@ -86,8 +86,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: "What song is currently playing?", text: "What song is currently playing?",
options: currentSongOptions, options: currentSongOptions.options,
correct: 0, correct: currentSongOptions.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
hideSongTitle: true, hideSongTitle: true,
@ -96,25 +96,25 @@ export async function buildAudioMetadataQuestion(
} }
} }
const genreOptions = buildOrderedOptions( const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4);
getMostSharedGenreNames(analytics), if (genreNames) {
4, const topGenre = genreNames[0];
);
if (genreOptions) {
const topGenre = genreOptions[0];
if (topGenre) { if (topGenre) {
questions.push({ const genreOptions = buildOptionsWithCorrect(topGenre, genreNames, 4);
key: `audio:genre:${topGenre}`, if (genreOptions) {
subjectKey: `genre:${topGenre}`, questions.push({
question: { key: `audio:genre:${topGenre}`,
type: "choice", subjectKey: `genre:${topGenre}`,
text: "Which genre appears most in the party analytics?", question: {
options: genreOptions, type: "choice",
correct: 0, text: "Which genre appears most in the party analytics?",
points: 10, options: genreOptions.options,
song: topSong ?? undefined, correct: genreOptions.correct,
}, points: 10,
}); song: topSong ?? undefined,
},
});
}
} }
} }
@ -135,8 +135,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: "Which artist shows up most often in the shared audio data?", text: "Which artist shows up most often in the shared audio data?",
options: artistOptions, options: artistOptions.options,
correct: 0, correct: artistOptions.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -161,8 +161,8 @@ export async function buildAudioMetadataQuestion(
getTrackFairness(topTrack, members, history).memberCount > 1 getTrackFairness(topTrack, members, history).memberCount > 1
? "Which track looks most shared across the party?" ? "Which track looks most shared across the party?"
: "Which track stands out in the party analytics?", : "Which track stands out in the party analytics?",
options: trackOptions, options: trackOptions.options,
correct: 0, correct: trackOptions.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -184,8 +184,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: `Which artist appears on "${topTrack.albumName}"?`, text: `Which artist appears on "${topTrack.albumName}"?`,
options: artistOptions, options: artistOptions.options,
correct: 0, correct: artistOptions.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -226,8 +226,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: "Which of these tracks came out first?", text: "Which of these tracks came out first?",
options, options: options.options,
correct: 0, correct: options.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -250,8 +250,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: "Which of these tracks came out most recently?", text: "Which of these tracks came out most recently?",
options, options: options.options,
correct: 0, correct: options.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -294,8 +294,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: `What's the longest track by ${artistName}?`, text: `What's the longest track by ${artistName}?`,
options, options: options.options,
correct: 0, correct: options.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -326,8 +326,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: `Who performs "${topTrack.name}"?`, text: `Who performs "${topTrack.name}"?`,
options: artistOptions, options: artistOptions.options,
correct: 0, correct: artistOptions.correct,
points: 10, points: 10,
song: trackSong ?? topSong ?? undefined, song: trackSong ?? topSong ?? undefined,
}, },
@ -348,8 +348,9 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: `What is the name of this track by ${correctArtist}?`, text: `What is the name of this track by ${correctArtist}?`,
options: trackNameOptions, options: trackNameOptions.options,
correct: 0, correct: trackNameOptions.correct,
hideSongTitle: true,
points: 10, points: 10,
song: trackSong ?? topSong ?? undefined, song: trackSong ?? topSong ?? undefined,
}, },
@ -369,8 +370,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: "Which song is this audio clip from?", text: "Which song is this audio clip from?",
options: alternateSongOptions, options: alternateSongOptions.options,
correct: 0, correct: alternateSongOptions.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
hideSongTitle: true, hideSongTitle: true,
@ -397,8 +398,8 @@ export async function buildAudioMetadataQuestion(
question: { question: {
type: "choice", type: "choice",
text: `"${topTrack.name}" appears on which album?`, text: `"${topTrack.name}" appears on which album?`,
options: albumOptions, options: albumOptions.options,
correct: 0, correct: albumOptions.correct,
points: 10, points: 10,
song: trackSong ?? topSong ?? undefined, song: trackSong ?? topSong ?? undefined,
}, },

View file

@ -745,14 +745,28 @@ export function buildOptionsWithCorrect(
correct: string, correct: string,
candidates: string[], candidates: string[],
desiredCount: number, desiredCount: number,
): string[] | null { ): { options: string[]; correct: number } | null {
if (!isUsableText(correct)) return null; if (!isUsableText(correct)) return null;
const options = uniqueStrings([ const options = uniqueStrings([
correct, correct,
...candidates.filter((c) => isUsableText(c) && c !== correct), ...candidates.filter((c) => isUsableText(c) && c !== correct),
]); ]);
const optionCount = getAvailableOptionCount(options.length, desiredCount); 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<T>(items: T[]): T | null { export function pickRandom<T>(items: T[]): T | null {

View file

@ -3,6 +3,7 @@ import type { Question, QuizState } from "../party-types";
import { import {
buildMemberOptions, buildMemberOptions,
buildMemberPairOptions, buildMemberPairOptions,
buildOptionsWithCorrect,
buildQuestionWindow, buildQuestionWindow,
getCurrentLeader, getCurrentLeader,
getFairQuestionTracks, getFairQuestionTracks,
@ -34,15 +35,18 @@ export async function buildSocialQuestion(
if (hasMultipleMembers && hasClearLeader(quizState)) { if (hasMultipleMembers && hasClearLeader(quizState)) {
const leader = getCurrentLeader(quizState, members); const leader = getCurrentLeader(quizState, members);
const options = buildMemberOptions(leader, members); const options = buildMemberOptions(leader, members);
if (options) { const choices = options
? buildOptionsWithCorrect(leader.name, options, options.length)
: null;
if (choices) {
questions.push({ questions.push({
key: "social:leader", key: "social:leader",
subjectKey: `member:${leader.userId}`, subjectKey: `member:${leader.userId}`,
question: { question: {
type: "choice", type: "choice",
text: "Who is leading the quiz right now?", text: "Who is leading the quiz right now?",
options, options: choices.options,
correct: 0, correct: choices.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -54,15 +58,18 @@ export async function buildSocialQuestion(
const diverse = getMostDiverseMember(analytics, members); const diverse = getMostDiverseMember(analytics, members);
if (diverse) { if (diverse) {
const options = buildMemberOptions(diverse, members); const options = buildMemberOptions(diverse, members);
if (options) { const choices = options
? buildOptionsWithCorrect(diverse.name, options, options.length)
: null;
if (choices) {
questions.push({ questions.push({
key: "social:diverse", key: "social:diverse",
subjectKey: `member:${diverse.userId}`, subjectKey: `member:${diverse.userId}`,
question: { question: {
type: "choice", type: "choice",
text: "Who looks like the most diverse listener in the party?", text: "Who looks like the most diverse listener in the party?",
options, options: choices.options,
correct: 0, correct: choices.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },
@ -98,7 +105,10 @@ export async function buildSocialQuestion(
albumName: topTrack.albumName, albumName: topTrack.albumName,
}); });
const options = buildMemberOptions(topListener, members); const options = buildMemberOptions(topListener, members);
if (options) { const choices = options
? buildOptionsWithCorrect(topListener.name, options, options.length)
: null;
if (choices) {
questions.push({ questions.push({
key: `social:track-listener:${topTrack.name}`, key: `social:track-listener:${topTrack.name}`,
subjectKey: `track:${topTrack.name}`, subjectKey: `track:${topTrack.name}`,
@ -106,8 +116,8 @@ export async function buildSocialQuestion(
question: { question: {
type: "choice", type: "choice",
text: `Who listens the most to "${topTrack.name}"?`, text: `Who listens the most to "${topTrack.name}"?`,
options, options: choices.options,
correct: 0, correct: choices.correct,
points: 10, points: 10,
song: trackSong ?? topSong ?? undefined, song: trackSong ?? topSong ?? undefined,
}, },
@ -123,15 +133,18 @@ export async function buildSocialQuestion(
if (memberA && memberB) { if (memberA && memberB) {
const correctPair = `${memberA.name} & ${memberB.name}`; const correctPair = `${memberA.name} & ${memberB.name}`;
const pairOptions = buildMemberPairOptions(members, correctPair); const pairOptions = buildMemberPairOptions(members, correctPair);
if (pairOptions) { const pairChoices = pairOptions
? buildOptionsWithCorrect(correctPair, pairOptions, pairOptions.length)
: null;
if (pairChoices) {
questions.push({ questions.push({
key: `social:pair:${memberA.userId}:${memberB.userId}`, key: `social:pair:${memberA.userId}:${memberB.userId}`,
subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`, subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`,
question: { question: {
type: "choice", type: "choice",
text: "Which two players share the most musical taste?", text: "Which two players share the most musical taste?",
options: pairOptions, options: pairChoices.options,
correct: 0, correct: pairChoices.correct,
points: 10, points: 10,
song: topSong ?? undefined, song: topSong ?? undefined,
}, },

View file

@ -1,7 +1,7 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { db as Db } from "../db"; import type { db as Db } from "../db";
import { party } from "../db/schema"; 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 { publishDeviceEventForUser } from "../routes/device-socket";
import { pubsub } from "../routes/party-socket"; import { pubsub } from "../routes/party-socket";
@ -9,6 +9,7 @@ export async function updatePartyData(
db: typeof Db, db: typeof Db,
id: string, id: string,
data: QuizState, data: QuizState,
status?: PartyStatus,
) { ) {
const members = await db.query.partyMember.findMany({ const members = await db.query.partyMember.findMany({
where: { where: {
@ -24,22 +25,21 @@ export async function updatePartyData(
}, },
}); });
if (!partyObject) throw new Error("Missing party"); if (!partyObject) throw new Error("Missing party");
const updatedParty = {
...partyObject,
status: status ?? partyObject.status,
data,
};
pubsub.publishPartyData(id, { pubsub.publishPartyData(id, {
type: "party_status", type: "party_status",
party: { party: updatedParty,
...partyObject,
data,
},
members, members,
}); });
const event: PartySocketEvent = { const event: PartySocketEvent = {
type: "party_status", type: "party_status",
party: { party: updatedParty,
...partyObject,
data,
},
members, members,
}; };
for (const member of members) { for (const member of members) {
@ -48,10 +48,7 @@ export async function updatePartyData(
`user:${member.userId}`, `user:${member.userId}`,
JSON.stringify({ JSON.stringify({
type: "party_status", type: "party_status",
party: { party: updatedParty,
...partyObject,
data,
},
members, members,
}), }),
); );
@ -60,7 +57,8 @@ export async function updatePartyData(
await db await db
.update(party) .update(party)
.set({ .set({
data: data, data,
...(status ? { status } : {}),
lastUpdated: new Date(), lastUpdated: new Date(),
}) })
.where(eq(party.id, id)); .where(eq(party.id, id));

View file

@ -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) { function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) {
if (!devProxySocket) { if (!devProxySocket) {
console.log("[device-socket] no dev proxy for event", deviceId, event.type); console.log("[device-socket] no dev proxy for event", deviceId, event.type);
@ -235,6 +244,13 @@ async function forwardDevicePayload(deviceId: string, payload: unknown) {
}); });
return; return;
} }
if (!isValidAnswer(quizData, payload.QuizResponse)) {
sendDeviceEvent(deviceId, {
type: "error",
message: "Invalid answer.",
});
return;
}
await DBOS.send( await DBOS.send(
quizData.workflowId, quizData.workflowId,

View file

@ -26,6 +26,15 @@ function broadcastStatusToMembers(
const quizWf = new QuizWorkflow(); 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() export const quizRoutes = new Elysia()
.use(betterAuthElysia) .use(betterAuthElysia)
.group("/party/:partyId/quiz", (app) => .group("/party/:partyId/quiz", (app) =>
@ -51,11 +60,6 @@ export const quizRoutes = new Elysia()
return { error: "Quiz already running" }; return { error: "Quiz already running" };
} }
const handle = await DBOS.startWorkflow(quizWf.startQuiz, {
queueName: quizQueue.name,
enqueueOptions: { queuePartitionKey: params.partyId },
})(params.partyId);
await db await db
.update(party) .update(party)
.set({ .set({
@ -65,6 +69,11 @@ export const quizRoutes = new Elysia()
}) })
.where(eq(party.id, params.partyId)); .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); const status = await getPartyStatus(params.partyId);
broadcastStatusToMembers(status); broadcastStatusToMembers(status);
@ -127,6 +136,12 @@ export const quizRoutes = new Elysia()
.post( .post(
"/response", "/response",
async ({ user, body, params, set }) => { 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({ const party = await db.query.party.findFirst({
where: { where: {
id: params.partyId, id: params.partyId,
@ -148,10 +163,25 @@ export const quizRoutes = new Elysia()
set.status = 500; set.status = 500;
return { error: "Workflow ID not found" }; 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( await DBOS.send(
quizData.workflowId, quizData.workflowId,
{ playerId: user.id, selected: body.selected }, {
playerId: user.id,
selected: body.selected,
questionIndex: body.questionIndex,
},
"quiz_responses", "quiz_responses",
); );
@ -161,6 +191,7 @@ export const quizRoutes = new Elysia()
auth: true, auth: true,
body: t.Object({ body: t.Object({
selected: t.Integer(), selected: t.Integer(),
questionIndex: t.Optional(t.Integer()),
}), }),
}, },
), ),

View file

@ -24,6 +24,7 @@ export const quizQueue = new WorkflowQueue("quiz_queue", {
type Response = { type Response = {
playerId: string; playerId: string;
selected: number; selected: number;
questionIndex?: number;
}; };
export class QuizWorkflow extends ConfiguredInstance { export class QuizWorkflow extends ConfiguredInstance {
@ -53,15 +54,15 @@ export class QuizWorkflow extends ConfiguredInstance {
history: [], history: [],
}; };
// Initialize quiz state
await QuizWorkflow.updatePartyData(partyId, quizState);
// Get party members to initialize scores // Get party members to initialize scores
let members = await QuizWorkflow.getPartyMembers(partyId); let members = await QuizWorkflow.getPartyMembers(partyId);
for (const member of members) { for (const member of members) {
quizState.scores[member.userId] = 0; 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++) { for (let i = 0; i < TOTAL_QUESTIONS; i++) {
quizState.status = "running"; quizState.status = "running";
quizState.questionIndex = i; quizState.questionIndex = i;
@ -96,7 +97,7 @@ export class QuizWorkflow extends ConfiguredInstance {
if (response === null) { if (response === null) {
// Timeout - fill in missing players with no answer // Timeout - fill in missing players with no answer
const now = Date.now(); const now = await DBOS.now();
if (now < question.endTimestamp) continue; if (now < question.endTimestamp) continue;
for (const memberId of memberIds) { for (const memberId of memberIds) {
if (!receivedPlayers.has(memberId)) { if (!receivedPlayers.has(memberId)) {
@ -116,10 +117,17 @@ export class QuizWorkflow extends ConfiguredInstance {
break; break;
} }
if (!memberIds.has(response.playerId)) continue;
if (
response.questionIndex !== undefined &&
response.questionIndex !== i
) {
continue;
}
if (receivedPlayers.has(response.playerId)) continue; if (receivedPlayers.has(response.playerId)) continue;
receivedPlayers.add(response.playerId); receivedPlayers.add(response.playerId);
const answeredAt = Date.now(); const answeredAt = await DBOS.now();
const selectedValue = response.selected; const selectedValue = response.selected;
const isCorrect = selectedValue === question.correct; const isCorrect = selectedValue === question.correct;
const quizResponse: QuizResponse = { const quizResponse: QuizResponse = {
@ -148,15 +156,16 @@ export class QuizWorkflow extends ConfiguredInstance {
// Quiz complete // Quiz complete
quizState.status = "results"; quizState.status = "results";
await QuizWorkflow.updatePartyData(partyId, quizState); await QuizWorkflow.updatePartyData(partyId, quizState, "ended");
} }
@DBOS.step() @DBOS.step()
private static async updatePartyData( private static async updatePartyData(
partyId: string, partyId: string,
quizState: QuizState, quizState: QuizState,
status?: "created" | "started" | "ended",
): Promise<void> { ): Promise<void> {
await updatePartyData(db, partyId, quizState); await updatePartyData(db, partyId, quizState, status);
} }
@DBOS.step() @DBOS.step()
@ -215,10 +224,9 @@ export class QuizWorkflow extends ConfiguredInstance {
} }
if (groups.length <= 1) { if (groups.length <= 1) {
return ordered.map(({ response }) => [ const onlyDistance = groups[0]?.distance ?? Number.POSITIVE_INFINITY;
response.playerId, const gained = onlyDistance === 0 ? round.question.points : 0;
round.question.points, return ordered.map(({ response }) => [response.playerId, gained]);
]);
} }
const scoredAnswers = groups.flatMap((group, index) => { const scoredAnswers = groups.flatMap((group, index) => {

View file

@ -101,6 +101,7 @@ export function Question() {
); );
const partyId = party.id; const partyId = party.id;
const questionIndex = party.data.questionIndex;
const timeLeft = formatTimeLeft(question.endTimestamp - now); const timeLeft = formatTimeLeft(question.endTimestamp - now);
const answeredCount = Object.keys(party.data.answers).length; const answeredCount = Object.keys(party.data.answers).length;
const hasResponded = user ? party.data.answers[user.id] != null : false; const hasResponded = user ? party.data.answers[user.id] != null : false;
@ -128,6 +129,7 @@ export function Question() {
try { try {
await client.api.party({ partyId }).quiz.response.post({ await client.api.party({ partyId }).quiz.response.post({
selected: optionIndex, selected: optionIndex,
questionIndex,
}); });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@ -142,6 +144,7 @@ export function Question() {
try { try {
await client.api.party({ partyId }).quiz.response.post({ await client.api.party({ partyId }).quiz.response.post({
selected: value, selected: value,
questionIndex,
}); });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);

View file

@ -23,7 +23,7 @@ const config = defineConfig({
}), }),
], ],
server: { server: {
allowedHosts: ["aura.rpi1.danbulant.cloud"], allowedHosts: ["aura.rpi1.danbulant.cloud", "fern.rpi1.danbulant.cloud"],
proxy: { proxy: {
"/api": { "/api": {
target: "http://localhost:4000", target: "http://localhost:4000",