improved questions

This commit is contained in:
Daniel Bulant 2026-05-04 11:30:31 +02:00
parent 14ccaee48c
commit 7b8ec190a8
No known key found for this signature in database
7 changed files with 226 additions and 96 deletions

View file

@ -36,16 +36,16 @@ type BaseQuestion = {
export type Question = export type Question =
| (BaseQuestion & { | (BaseQuestion & {
type: "choice"; type: "choice";
options: string[]; options: string[];
}) })
| (BaseQuestion & { | (BaseQuestion & {
type: "numeric"; type: "numeric";
range: { range: {
min: number; min: number;
max: number; max: number;
}; };
}); });
export type QuizResponse = { export type QuizResponse = {
playerId: string; playerId: string;

View file

@ -1,10 +1,13 @@
import type { Question } from "../party-types"; import type { Question } from "../party-types";
import { import {
buildOptionsWithCorrect,
buildOrderedOptions,
buildQuestionWindow, buildQuestionWindow,
getTopArtistName, getMostSharedGenreNames,
getTopGenreName, getTopClusterArtists,
getTopTrackName, getTopClusterTracks,
type PartyAnalytics, type PartyAnalytics,
pickRandom,
} from "./question-utils"; } from "./question-utils";
export function buildAudioMetadataQuestion( export function buildAudioMetadataQuestion(
@ -12,34 +15,104 @@ export function buildAudioMetadataQuestion(
index: number, index: number,
): Question { ): Question {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [ const questions: Array<
{ Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
> = [];
const genreOptions = buildOrderedOptions(
getMostSharedGenreNames(analytics),
4,
);
if (genreOptions) {
questions.push({
type: "choice", type: "choice",
text: "Which genre appears most in the party analytics?", text: "Which genre appears most in the party analytics?",
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"], options: genreOptions,
correct: 0, correct: 0,
points: 10, points: 10,
}, });
{ }
type: "choice",
text: "Which artist shows up most often in the shared audio data?", const topArtists = getTopClusterArtists(analytics);
options: [ const topArtist = topArtists[0];
getTopArtistName(analytics), if (topArtist) {
"Artist B", const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
"Artist C", if (artistOptions) {
"Artist D", questions.push({
], type: "choice",
correct: 0, text: "Which artist shows up most often in the shared audio data?",
points: 10, options: artistOptions,
}, 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 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]; const question = questions[index % questions.length];
if (!question) throw new Error("Question not found"); if (!question) throw new Error("Question not found");

View file

@ -3,7 +3,10 @@ import type { Question } from "../party-types";
import type { PartyAnalytics } from "./question-utils"; import type { PartyAnalytics } from "./question-utils";
import { buildQuestionWindow, getQuestionRange } from "./question-utils"; import { buildQuestionWindow, getQuestionRange } from "./question-utils";
type NumericQuestion = Omit<Extract<Question, { type: "numeric" }>, "startTimestamp" | "endTimestamp">; type NumericQuestion = Omit<
Extract<Question, { type: "numeric" }>,
"startTimestamp" | "endTimestamp"
>;
type BuildNumericQuestionInput = { type BuildNumericQuestionInput = {
db: typeof db; db: typeof db;

View file

@ -10,8 +10,13 @@ export type PartyAnalytics = {
mostSharedGenres?: { name: string }[]; mostSharedGenres?: { name: string }[];
}; };
storyClusters?: { storyClusters?: {
tracks?: { name: string }[]; tracks?: {
name: string;
artists?: { name: string }[];
albumName?: string;
}[];
artists?: { name: string }[]; artists?: { name: string }[];
genres?: { name: string }[];
}[]; }[];
memberProfiles?: { userId: string }[]; memberProfiles?: { userId: string }[];
pairwise?: { userIdA: string; userIdB: string }[]; pairwise?: { userIdA: string; userIdB: string }[];
@ -78,16 +83,50 @@ export function getQuestionRange(
}; };
} }
export function getTopGenreName(analytics: PartyAnalytics): string { export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop"; return (analytics?.groupSummary?.mostSharedGenres ?? []).map(
(genre) => genre.name,
);
} }
export function getTopArtistName(analytics: PartyAnalytics): string { export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A"; return (analytics?.storyClusters?.[0]?.artists ?? []).map(
(artist) => artist.name,
);
} }
export function getTopTrackName(analytics: PartyAnalytics): string { export function getTopClusterTracks(
return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A"; analytics: PartyAnalytics,
): Array<{ name: string; artists?: { name: string }[]; albumName?: string }> {
return analytics?.storyClusters?.[0]?.tracks ?? [];
}
export function buildOrderedOptions(
values: Array<string | undefined>,
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<T>(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( export function getCurrentLeader(

View file

@ -20,7 +20,9 @@ export function buildSocialQuestion(
const diverse = getMostDiverseMember(analytics, members); const diverse = getMostDiverseMember(analytics, members);
const aligned = getMostAlignedMember(analytics, members); const aligned = getMostAlignedMember(analytics, members);
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [ const questions: Array<
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
> = [
{ {
type: "choice", type: "choice",
text: "Who is leading the quiz right now?", text: "Who is leading the quiz right now?",

View file

@ -1,44 +1,47 @@
import Elysia from "elysia" import Elysia from "elysia";
import { broadcastQuizState, partyTopic, socketPartyId, userTopic } from "./party-socket" import { db } from "../db";
import { getMemberRecord, getPartyStatus } from "../party-data" import { getMemberRecord, getPartyStatus } from "../party-data";
import { db } from "../db" import {
broadcastQuizState,
partyTopic,
socketPartyId,
userTopic,
} from "./party-socket";
export const partySocketApp = new Elysia() export const partySocketApp = new Elysia().group("/dev-socket", (app) =>
.group("/dev-socket", (app) => app
app .get("/test", () => ({ ok: 1 }))
.get("/test", () => ({ ok: 1 })) .ws("/ws", {
.ws("/ws", { async open(ws) {
async open(ws) { const id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t";
let id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t" ws.subscribe(userTopic(id));
ws.subscribe(userTopic(id)) const membership = await getMemberRecord(db, id);
const membership = await getMemberRecord(db, id); if (!membership) {
if (!membership) { ws.send(
ws.send( JSON.stringify({
JSON.stringify({ type: "snapshot",
type: "snapshot", party: null,
party: null, members: [],
members: [], }),
}), );
); return;
return; }
}
socketPartyId.set(ws, membership.partyId); socketPartyId.set(ws, membership.partyId);
ws.subscribe(partyTopic(membership.partyId)); ws.subscribe(partyTopic(membership.partyId));
const snapshot = await getPartyStatus(membership.partyId); const snapshot = await getPartyStatus(membership.partyId);
if (snapshot) { if (snapshot) {
ws.send( ws.send(
JSON.stringify({ JSON.stringify({
type: "snapshot", type: "snapshot",
party: snapshot.party, party: snapshot.party,
members: snapshot.members, members: snapshot.members,
}), }),
); );
await broadcastQuizState(ws, membership.partyId); await broadcastQuizState(ws, membership.partyId);
} }
} },
}),
}) );
)

View file

@ -5,8 +5,13 @@ import { partyMember } from "../db/schema";
import { generatePartyQuestion } from "../party/question-generator"; import { generatePartyQuestion } from "../party/question-generator";
import type { PartyAnalytics } from "../party/question-utils"; import type { PartyAnalytics } from "../party/question-utils";
import { updatePartyData } from "../party/state"; import { updatePartyData } from "../party/state";
import type { Question, QuizResponse, QuizRound, QuizState } from "../party-types"; import type {
import { partyAnalysisWorkflow, PartyAnalysisWorkflow } from "./party-analysis"; Question,
QuizResponse,
QuizRound,
QuizState,
} from "../party-types";
import { partyAnalysisWorkflow } from "./party-analysis";
const TOTAL_QUESTIONS = 5; const TOTAL_QUESTIONS = 5;
@ -35,9 +40,9 @@ export class QuizWorkflow extends ConfiguredInstance {
answers: {}, answers: {},
scores: {}, scores: {},
history: [], history: [],
}; };
await partyAnalysisWorkflow.analyzeParty(partyId) await partyAnalysisWorkflow.analyzeParty(partyId);
// Initialize quiz state // Initialize quiz state
await QuizWorkflow.updatePartyData(partyId, quizState); await QuizWorkflow.updatePartyData(partyId, quizState);
@ -76,10 +81,10 @@ export class QuizWorkflow extends ConfiguredInstance {
deadlineEpochMS: question.endTimestamp, deadlineEpochMS: question.endTimestamp,
}); });
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 = Date.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)) {
receivedPlayers.add(memberId); receivedPlayers.add(memberId);
@ -116,8 +121,7 @@ export class QuizWorkflow extends ConfiguredInstance {
} }
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) { for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
quizState.scores[playerId] = quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
(quizState.scores[playerId] ?? 0) + gained;
} }
await QuizWorkflow.updatePartyData(partyId, quizState); await QuizWorkflow.updatePartyData(partyId, quizState);
@ -169,7 +173,10 @@ export class QuizWorkflow extends ConfiguredInstance {
const ordered = round.responses const ordered = round.responses
.map((response) => ({ .map((response) => ({
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); .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) { 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) => { return groups.flatMap((group, index) => {