226 lines
5.9 KiB
TypeScript
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));
|
|
}
|
|
}
|