itpdp/api/src/workflows/quiz.ts
2026-05-13 11:48:18 +02:00

226 lines
5.9 KiB
TypeScript

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<void> {
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<string>();
while (receivedPlayers.size < memberIds.size) {
const response = await DBOS.recv<Response>("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<void> {
console.log(partyId, quizState);
await updatePartyData(db, partyId, quizState);
}
@DBOS.step()
static async generateQuestion(
partyId: string,
quizState: QuizState,
index: number,
): Promise<Question> {
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));
}
}