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 { Question, QuizResponse, QuizRound, QuizState, } from "../party-types"; import { partyAnalysisWorkflow } from "./party-analysis"; const TOTAL_QUESTIONS = 5; export const quizQueue = new WorkflowQueue("quiz_queue", { concurrency: 1, partitionQueue: true, }); type Response = { playerId: string; selected: number; }; export class QuizWorkflow extends ConfiguredInstance { constructor() { super("QuizWorkflow"); } @DBOS.workflow() async startQuiz(partyId: string): Promise { const quizState: QuizState = { status: "running", workflowId: DBOS.workflowID ?? null, questionIndex: 0, currentQuestion: null, answers: {}, scores: {}, history: [], }; await partyAnalysisWorkflow.analyzeParty(partyId); // 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; } for (let i = 0; i < TOTAL_QUESTIONS; i++) { quizState.questionIndex = 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); members = await QuizWorkflow.getPartyMembers(partyId); // Wait for all responses with timeout const memberIds = new Set(members.map((m) => m.userId)); const receivedPlayers = new Set(); while (receivedPlayers.size < memberIds.size) { const response = await DBOS.recv("quiz_responses", { deadlineEpochMS: question.endTimestamp, }); if (response === null) { // Timeout - fill in missing players with no answer const now = Date.now(); if (now < question.endTimestamp) continue; for (const memberId of memberIds) { if (!receivedPlayers.has(memberId)) { receivedPlayers.add(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); } } break; } if (receivedPlayers.has(response.playerId)) continue; receivedPlayers.add(response.playerId); const answeredAt = Date.now(); const selectedValue = response.selected; const isCorrect = selectedValue === question.correct; const quizResponse: QuizResponse = { ...response, selectedValue, correct: isCorrect, answeredAt, pointsGained: 0, }; quizState.answers[response.playerId] = quizResponse; round.responses.push(quizResponse); await QuizWorkflow.updatePartyData(partyId, quizState); } for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) { quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained; } await QuizWorkflow.updatePartyData(partyId, quizState); } // Quiz complete quizState.status = "results"; await QuizWorkflow.updatePartyData(partyId, quizState); } @DBOS.step() private static async updatePartyData( partyId: string, quizState: QuizState, ): Promise { console.log(partyId, quizState); await updatePartyData(db, partyId, quizState); } @DBOS.step() static async generateQuestion( partyId: string, quizState: QuizState, index: number, ): Promise { const partyRecord = await db.query.party.findFirst({ where: { id: partyId, }, }); const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics; const question = await generatePartyQuestion({ db, partyId, quizState, analytics, index, }); if (!question) { throw new Error("Failed to generate quiz question"); } return question; } private static scoreRound(round: QuizRound): Array<[string, number]> { if (round.question.type !== "numeric") { return round.responses.map((response): [string, number] => [ response.playerId, response.correct ? round.question.points : 0, ]); } const ordered = round.responses .map((response) => ({ response, distance: Math.abs( (response.selectedValue ?? response.selected) - round.question.correct, ), })) .sort((a, b) => a.distance - b.distance); const groups: Array<{ distance: number; responses: QuizResponse[] }> = []; for (const item of ordered) { const group = groups.at(-1); if (!group || group.distance !== item.distance) { groups.push({ distance: item.distance, responses: [item.response] }); } else { group.responses.push(item.response); } } const _scoringGroups = groups.slice(0, Math.max(0, groups.length - 1)); if (groups.length <= 1) { return round.responses.map((response) => [ response.playerId, round.question.points, ]); } return groups.flatMap((group, index) => { const factor = (groups.length - index - 1) / (groups.length - 1); const gained = Math.round(round.question.points * factor); return group.responses.map((response): [string, number] => [ response.playerId, gained, ]); }); } @DBOS.step() private static async getPartyMembers( partyId: string, ): Promise<{ id: string; userId: string }[]> { return db .select({ id: partyMember.id, userId: partyMember.userId }) .from(partyMember) .where(eq(partyMember.partyId, partyId)); } }