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: {
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,
},

View file

@ -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<T>(items: T[]): T | null {

View file

@ -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,
},

View file

@ -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));

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) {
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,

View file

@ -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()),
}),
},
),

View file

@ -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<void> {
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) => {

View file

@ -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);

View file

@ -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",