Compare commits

..

2 commits

Author SHA1 Message Date
Daniel Bulant
55c4493459
split up generation 2026-05-03 22:38:49 +02:00
Daniel Bulant
f945f12d81
add question history 2026-05-03 21:05:08 +02:00
9 changed files with 331 additions and 82 deletions

View file

@ -1,5 +1,5 @@
{
"$schema": "https://biomejs.dev/schemas/2.4.11/schema.json",
"$schema": "https://biomejs.dev/schemas/2.4.12/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",

View file

@ -35,6 +35,20 @@ export type Question = {
points: number;
};
export type QuizResponse = {
playerId: string;
selected: number;
correct: boolean;
answeredAt: number;
pointsGained: number;
};
export type QuizRound = {
questionIndex: number;
question: Question;
responses: QuizResponse[];
};
export type QuizState = {
status: "running" | "results";
workflowId: string | null;
@ -42,9 +56,16 @@ export type QuizState = {
currentQuestion: Question | null;
answers: Record<
string,
{ playerId: string; selected: number; correct: boolean }
{
playerId: string;
selected: number;
correct: boolean;
pointsGained: number;
answeredAt: number;
}
>;
scores: Record<string, number>;
history: QuizRound[];
};
export type PartySocketEvent =

View file

@ -0,0 +1,43 @@
import type { Question } from "../party-types";
import {
buildQuestionWindow,
getTopArtistName,
getTopGenreName,
getTopTrackName,
type PartyAnalytics,
} from "./question-utils";
export function buildAudioMetadataQuestion(
analytics: PartyAnalytics,
index: number,
): Question {
const questions: Array<Omit<Question, "startTimestamp" | "endTimestamp">> = [
{
text: "Which genre appears most in the party analytics?",
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"],
correct: 0,
points: 10,
},
{
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,
},
{
text: "Which track looks most shared across the party?",
options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"],
correct: 0,
points: 10,
},
];
const question = questions[index % questions.length];
if (!question) throw new Error("Question not found");
return buildQuestionWindow(question);
}

View file

@ -0,0 +1,30 @@
import type { db } from "../db";
import type { Question, QuizState } from "../party-types";
import { buildAudioMetadataQuestion } from "./audio-question-generator";
import type { PartyAnalytics } from "./question-utils";
import { fetchPartyMembers } from "./question-utils";
import { buildSocialQuestion } from "./social-question-generator";
export type PartyQuestionType = "audio-metadata" | "social";
type GenerateQuestionInput = {
db: typeof db;
partyId: string;
quizState: QuizState;
analytics: PartyAnalytics;
index: number;
};
export async function generatePartyQuestion({
db: dbClient,
partyId,
quizState,
analytics,
index,
}: GenerateQuestionInput): Promise<Question> {
const members = await fetchPartyMembers(dbClient, partyId);
const type: PartyQuestionType = index % 2 === 0 ? "audio-metadata" : "social";
return type === "audio-metadata"
? buildAudioMetadataQuestion(analytics, index)
: buildSocialQuestion(quizState, analytics, members, index);
}

View file

@ -0,0 +1,141 @@
import type { db as Db } from "../db";
export type PartyQuestionMember = {
userId: string;
name: string;
};
export type PartyAnalytics = {
groupSummary?: {
mostSharedGenres?: { name: string }[];
};
storyClusters?: {
tracks?: { name: string }[];
artists?: { name: string }[];
}[];
memberProfiles?: { userId: string }[];
pairwise?: { userIdA: string; userIdB: string }[];
} | null;
export const QUESTION_DURATION_MS = 60_000;
export const MIN_PARTY_SIZE = 2;
export const MAX_PARTY_SIZE = 4;
export async function fetchPartyMembers(
db: typeof Db,
partyId: string,
): Promise<PartyQuestionMember[]> {
const members = await db.query.partyMember.findMany({
where: {
partyId,
},
with: {
user: true,
},
});
return members.map((member) => ({
userId: member.userId,
name: member.user?.name ?? member.userId,
}));
}
export function getPartySize(memberCount: number): number {
return Math.max(MIN_PARTY_SIZE, Math.min(MAX_PARTY_SIZE, memberCount));
}
export function buildQuestionWindow<T extends object>(
question: T,
timestamp = Date.now(),
): T & { startTimestamp: number; endTimestamp: number } {
return {
...question,
startTimestamp: timestamp,
endTimestamp: timestamp + QUESTION_DURATION_MS,
};
}
export function getTopGenreName(analytics: PartyAnalytics): string {
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
}
export function getTopArtistName(analytics: PartyAnalytics): string {
return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A";
}
export function getTopTrackName(analytics: PartyAnalytics): string {
return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A";
}
export function getCurrentLeader(
quizState: { scores: Record<string, number> },
members: PartyQuestionMember[],
): PartyQuestionMember {
const leaderId = Object.entries(quizState.scores)
.sort(([, a], [, b]) => b - a)
.at(0)?.[0];
return (
members.find((member) => member.userId === leaderId) ??
members[0] ?? { userId: "", name: "Player A" }
);
}
export function getMostDiverseMember(
analytics: PartyAnalytics,
members: PartyQuestionMember[],
): PartyQuestionMember {
const userId = analytics?.memberProfiles?.[0]?.userId;
return (
members.find((member) => member.userId === userId) ??
members[1] ??
members[0] ?? { userId: "", name: "Player B" }
);
}
export function getMostAlignedMember(
analytics: PartyAnalytics,
members: PartyQuestionMember[],
): PartyQuestionMember {
const userId = analytics?.pairwise?.[0]?.userIdA;
return (
members.find((member) => member.userId === userId) ??
members[2] ??
members[0] ?? { userId: "", name: "Player C" }
);
}
export function buildMemberOptions(
correctMember: PartyQuestionMember,
members: PartyQuestionMember[],
): string[] {
const desiredCount = getPartySize(members.length);
const options = uniqueStrings([
correctMember.name,
...members.map((member) => member.name),
]);
if (options.length < desiredCount) {
for (const fallback of fallbackPlayerNames(desiredCount)) {
if (options.length >= desiredCount) break;
if (!options.includes(fallback)) options.push(fallback);
}
}
const ordered = [
correctMember.name,
...options.filter((name) => name !== correctMember.name),
];
return ordered.slice(0, desiredCount);
}
function uniqueStrings(values: string[]): string[] {
return values.filter((value, index, list) => list.indexOf(value) === index);
}
function fallbackPlayerNames(count: number): string[] {
return Array.from(
{ length: count },
(_, index) => `Player ${String.fromCharCode(65 + index)}`,
);
}

View file

@ -0,0 +1,46 @@
import type { Question, QuizState } from "../party-types";
import {
buildMemberOptions,
buildQuestionWindow,
getCurrentLeader,
getMostAlignedMember,
getMostDiverseMember,
type PartyAnalytics,
type PartyQuestionMember,
} from "./question-utils";
export function buildSocialQuestion(
quizState: QuizState,
analytics: PartyAnalytics,
members: PartyQuestionMember[],
index: number,
): Question {
const leader = getCurrentLeader(quizState, members);
const diverse = getMostDiverseMember(analytics, members);
const aligned = getMostAlignedMember(analytics, members);
const questions: Array<Omit<Question, "startTimestamp" | "endTimestamp">> = [
{
text: "Who is leading the quiz right now?",
options: buildMemberOptions(leader, members),
correct: 0,
points: 10,
},
{
text: "Who looks like the most diverse listener in the party?",
options: buildMemberOptions(diverse, members),
correct: 0,
points: 10,
},
{
text: "Which member seems most aligned with the rest of the party?",
options: buildMemberOptions(aligned, members),
correct: 0,
points: 10,
},
];
const question = questions[index % questions.length];
if (!question) throw new Error("Question not found");
return buildQuestionWindow(question);
}

View file

@ -114,30 +114,5 @@ export const quizRoutes = new Elysia()
selected: t.Integer(),
}),
},
)
.get(
"/status",
async ({ params, set }) => {
const existingQuiz = await db
.select({ data: party.data })
.from(party)
.where(eq(party.id, params.partyId))
.limit(1)
.then((rows) => rows[0]);
if (!existingQuiz) {
set.status = 404;
return { error: "Party not found" };
}
const quizData = (
(existingQuiz.data ?? {}) as Record<string, unknown>
).quiz as QuizState | undefined;
return {
quiz: quizData,
};
},
{ auth: true },
),
);

View file

@ -358,8 +358,8 @@ export async function seedPartyWithThreeDiverseUsers(): Promise<{
// Give each user their unique track
for (let i = 0; i < 3; i++) {
await addTopTrack(userIds[i]!, tracks[i]?.id, 1);
await addTopArtist(userIds[i]!, artists[i]?.id, 1);
await addTopTrack(userIds[i]!, tracks[i]!.id, 1);
await addTopArtist(userIds[i]!, artists[i]!.id, 1);
}
return {

View file

@ -2,8 +2,10 @@ import { ConfiguredInstance, DBOS, WorkflowQueue } from "@dbos-inc/dbos-sdk";
import { eq } from "drizzle-orm";
import { db } from "../db";
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 { QuizState } from "../party-types";
import type { QuizResponse, QuizRound, QuizState } from "../party-types";
const TOTAL_QUESTIONS = 5;
@ -31,6 +33,7 @@ export class QuizWorkflow extends ConfiguredInstance {
currentQuestion: null,
answers: {},
scores: {},
history: [],
};
// Initialize quiz state
@ -45,9 +48,19 @@ export class QuizWorkflow extends ConfiguredInstance {
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
quizState.questionIndex = i;
const question = await QuizWorkflow.generateQuestion(i);
const question = await QuizWorkflow.generateQuestion(
partyId,
quizState,
i,
);
quizState.currentQuestion = question;
quizState.answers = {};
const round: QuizRound = {
questionIndex: i,
question,
responses: [],
};
quizState.history.push(round);
await QuizWorkflow.updatePartyData(partyId, quizState);
// Wait for all responses with timeout
@ -61,14 +74,19 @@ export class QuizWorkflow extends ConfiguredInstance {
if (response === null) {
// Timeout - fill in missing players with no answer
const now = Date.now();
for (const memberId of memberIds) {
if (!receivedPlayers.has(memberId)) {
receivedPlayers.add(memberId);
quizState.answers[memberId] = {
const noAnswer: QuizResponse = {
playerId: memberId,
selected: -1,
correct: false,
answeredAt: now,
pointsGained: 0,
};
quizState.answers[memberId] = noAnswer;
round.responses.push(noAnswer);
await QuizWorkflow.updatePartyData(partyId, quizState);
}
}
@ -76,15 +94,21 @@ export class QuizWorkflow extends ConfiguredInstance {
}
receivedPlayers.add(response.playerId);
const answeredAt = Date.now();
const isCorrect = response.selected === question.correct;
quizState.answers[response.playerId] = {
const pointsGained = isCorrect ? question.points : 0;
const quizResponse: QuizResponse = {
...response,
correct: isCorrect,
answeredAt,
pointsGained,
};
quizState.answers[response.playerId] = quizResponse;
round.responses.push(quizResponse);
if (isCorrect) {
quizState.scores[response.playerId] =
(quizState.scores[response.playerId] ?? 0) + question.points;
(quizState.scores[response.playerId] ?? 0) + pointsGained;
}
await QuizWorkflow.updatePartyData(partyId, quizState);
@ -106,7 +130,11 @@ export class QuizWorkflow extends ConfiguredInstance {
}
@DBOS.step()
static async generateQuestion(index: number): Promise<{
static async generateQuestion(
partyId: string,
quizState: QuizState,
index: number,
): Promise<{
text: string;
options: string[];
correct: number;
@ -114,54 +142,19 @@ export class QuizWorkflow extends ConfiguredInstance {
endTimestamp: number;
points: number;
}> {
// Placeholder - returns same question for now, question generation comes later
const questions: {
text: string;
options: string[];
correct: number;
points: number;
}[] = [
{
text: "What is the most common genre in your party's shared taste?",
options: ["Hip-Hop", "Rock", "Electronic", "Jazz"],
correct: 0,
points: 10,
const partyRecord = await db.query.party.findFirst({
where: {
id: partyId,
},
{
text: "Which artist do most party members follow?",
options: ["Artist A", "Artist B", "Artist C", "Artist D"],
correct: 1,
points: 10,
},
{
text: "What percentage of the party shares at least 1 album?",
options: ["0-25%", "25-50%", "50-75%", "75-100%"],
correct: 2,
points: 10,
},
{
text: "Who has the most diverse taste in the party?",
options: ["Player A", "Player B", "Player C", "Player D"],
correct: 0,
points: 10,
},
{
text: "Which track appears most in everyone's top 50?",
options: ["Track A", "Track B", "Track C", "Track D"],
correct: 3,
points: 10,
},
];
const question = questions[index % questions.length];
if (!question) {
throw new Error("Question not found");
}
return {
...question,
startTimestamp: Date.now(),
endTimestamp: Date.now() + 60_000,
};
});
const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics;
return generatePartyQuestion({
db,
partyId,
quizState,
analytics,
index,
});
}
@DBOS.step()