Compare commits
No commits in common. "ac506795c68f2bb89322831d854058babc9724d7" and "e4459833f42b343cca27f1b8de1eada061c03605" have entirely different histories.
ac506795c6
...
e4459833f4
11 changed files with 124 additions and 191 deletions
|
|
@ -86,8 +86,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "What song is currently playing?",
|
text: "What song is currently playing?",
|
||||||
options: currentSongOptions.options,
|
options: currentSongOptions,
|
||||||
correct: currentSongOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
hideSongTitle: true,
|
hideSongTitle: true,
|
||||||
|
|
@ -96,27 +96,27 @@ export async function buildAudioMetadataQuestion(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const genreNames = buildOrderedOptions(getMostSharedGenreNames(analytics), 4);
|
const genreOptions = buildOrderedOptions(
|
||||||
if (genreNames) {
|
getMostSharedGenreNames(analytics),
|
||||||
const topGenre = genreNames[0];
|
4,
|
||||||
if (topGenre) {
|
);
|
||||||
const genreOptions = buildOptionsWithCorrect(topGenre, genreNames, 4);
|
|
||||||
if (genreOptions) {
|
if (genreOptions) {
|
||||||
|
const topGenre = genreOptions[0];
|
||||||
|
if (topGenre) {
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `audio:genre:${topGenre}`,
|
key: `audio:genre:${topGenre}`,
|
||||||
subjectKey: `genre:${topGenre}`,
|
subjectKey: `genre:${topGenre}`,
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which genre appears most in the party analytics?",
|
text: "Which genre appears most in the party analytics?",
|
||||||
options: genreOptions.options,
|
options: genreOptions,
|
||||||
correct: genreOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
|
const topArtistEntities = getFairQuestionArtists(analytics, members, history);
|
||||||
const topArtists = topArtistEntities.map((artist) => artist.name);
|
const topArtists = topArtistEntities.map((artist) => artist.name);
|
||||||
|
|
@ -135,8 +135,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which artist shows up most often in the shared audio data?",
|
text: "Which artist shows up most often in the shared audio data?",
|
||||||
options: artistOptions.options,
|
options: artistOptions,
|
||||||
correct: artistOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -161,8 +161,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
getTrackFairness(topTrack, members, history).memberCount > 1
|
getTrackFairness(topTrack, members, history).memberCount > 1
|
||||||
? "Which track looks most shared across the party?"
|
? "Which track looks most shared across the party?"
|
||||||
: "Which track stands out in the party analytics?",
|
: "Which track stands out in the party analytics?",
|
||||||
options: trackOptions.options,
|
options: trackOptions,
|
||||||
correct: trackOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -184,8 +184,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Which artist appears on "${topTrack.albumName}"?`,
|
text: `Which artist appears on "${topTrack.albumName}"?`,
|
||||||
options: artistOptions.options,
|
options: artistOptions,
|
||||||
correct: artistOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -226,8 +226,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which of these tracks came out first?",
|
text: "Which of these tracks came out first?",
|
||||||
options: options.options,
|
options,
|
||||||
correct: options.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -250,8 +250,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which of these tracks came out most recently?",
|
text: "Which of these tracks came out most recently?",
|
||||||
options: options.options,
|
options,
|
||||||
correct: options.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -294,8 +294,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `What's the longest track by ${artistName}?`,
|
text: `What's the longest track by ${artistName}?`,
|
||||||
options: options.options,
|
options,
|
||||||
correct: options.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -326,8 +326,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Who performs "${topTrack.name}"?`,
|
text: `Who performs "${topTrack.name}"?`,
|
||||||
options: artistOptions.options,
|
options: artistOptions,
|
||||||
correct: artistOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: trackSong ?? topSong ?? undefined,
|
song: trackSong ?? topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -348,9 +348,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `What is the name of this track by ${correctArtist}?`,
|
text: `What is the name of this track by ${correctArtist}?`,
|
||||||
options: trackNameOptions.options,
|
options: trackNameOptions,
|
||||||
correct: trackNameOptions.correct,
|
correct: 0,
|
||||||
hideSongTitle: true,
|
|
||||||
points: 10,
|
points: 10,
|
||||||
song: trackSong ?? topSong ?? undefined,
|
song: trackSong ?? topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -370,8 +369,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which song is this audio clip from?",
|
text: "Which song is this audio clip from?",
|
||||||
options: alternateSongOptions.options,
|
options: alternateSongOptions,
|
||||||
correct: alternateSongOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
hideSongTitle: true,
|
hideSongTitle: true,
|
||||||
|
|
@ -398,8 +397,8 @@ export async function buildAudioMetadataQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `"${topTrack.name}" appears on which album?`,
|
text: `"${topTrack.name}" appears on which album?`,
|
||||||
options: albumOptions.options,
|
options: albumOptions,
|
||||||
correct: albumOptions.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: trackSong ?? topSong ?? undefined,
|
song: trackSong ?? topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -745,28 +745,14 @@ export function buildOptionsWithCorrect(
|
||||||
correct: string,
|
correct: string,
|
||||||
candidates: string[],
|
candidates: string[],
|
||||||
desiredCount: number,
|
desiredCount: number,
|
||||||
): { options: string[]; correct: number } | null {
|
): string[] | null {
|
||||||
if (!isUsableText(correct)) return null;
|
if (!isUsableText(correct)) return null;
|
||||||
const options = uniqueStrings([
|
const options = uniqueStrings([
|
||||||
correct,
|
correct,
|
||||||
...candidates.filter((c) => isUsableText(c) && c !== correct),
|
...candidates.filter((c) => isUsableText(c) && c !== correct),
|
||||||
]);
|
]);
|
||||||
const optionCount = getAvailableOptionCount(options.length, desiredCount);
|
const optionCount = getAvailableOptionCount(options.length, desiredCount);
|
||||||
if (!optionCount) return null;
|
return optionCount ? options.slice(0, optionCount) : null;
|
||||||
const shuffled = shuffleOptions(options.slice(0, optionCount));
|
|
||||||
return { options: shuffled, correct: shuffled.indexOf(correct) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffleOptions(options: string[]): string[] {
|
|
||||||
const shuffled = options.slice();
|
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
const value = shuffled[i];
|
|
||||||
if (value === undefined) continue;
|
|
||||||
shuffled[i] = shuffled[j] ?? value;
|
|
||||||
shuffled[j] = value;
|
|
||||||
}
|
|
||||||
return shuffled;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pickRandom<T>(items: T[]): T | null {
|
export function pickRandom<T>(items: T[]): T | null {
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ import type { Question, QuizState } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildMemberOptions,
|
buildMemberOptions,
|
||||||
buildMemberPairOptions,
|
buildMemberPairOptions,
|
||||||
buildOptionsWithCorrect,
|
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
getCurrentLeader,
|
getCurrentLeader,
|
||||||
getFairQuestionTracks,
|
getFairQuestionTracks,
|
||||||
|
|
@ -35,18 +34,15 @@ export async function buildSocialQuestion(
|
||||||
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
||||||
const leader = getCurrentLeader(quizState, members);
|
const leader = getCurrentLeader(quizState, members);
|
||||||
const options = buildMemberOptions(leader, members);
|
const options = buildMemberOptions(leader, members);
|
||||||
const choices = options
|
if (options) {
|
||||||
? buildOptionsWithCorrect(leader.name, options, options.length)
|
|
||||||
: null;
|
|
||||||
if (choices) {
|
|
||||||
questions.push({
|
questions.push({
|
||||||
key: "social:leader",
|
key: "social:leader",
|
||||||
subjectKey: `member:${leader.userId}`,
|
subjectKey: `member:${leader.userId}`,
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Who is leading the quiz right now?",
|
text: "Who is leading the quiz right now?",
|
||||||
options: choices.options,
|
options,
|
||||||
correct: choices.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -58,18 +54,15 @@ export async function buildSocialQuestion(
|
||||||
const diverse = getMostDiverseMember(analytics, members);
|
const diverse = getMostDiverseMember(analytics, members);
|
||||||
if (diverse) {
|
if (diverse) {
|
||||||
const options = buildMemberOptions(diverse, members);
|
const options = buildMemberOptions(diverse, members);
|
||||||
const choices = options
|
if (options) {
|
||||||
? buildOptionsWithCorrect(diverse.name, options, options.length)
|
|
||||||
: null;
|
|
||||||
if (choices) {
|
|
||||||
questions.push({
|
questions.push({
|
||||||
key: "social:diverse",
|
key: "social:diverse",
|
||||||
subjectKey: `member:${diverse.userId}`,
|
subjectKey: `member:${diverse.userId}`,
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Who looks like the most diverse listener in the party?",
|
text: "Who looks like the most diverse listener in the party?",
|
||||||
options: choices.options,
|
options,
|
||||||
correct: choices.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -105,10 +98,7 @@ export async function buildSocialQuestion(
|
||||||
albumName: topTrack.albumName,
|
albumName: topTrack.albumName,
|
||||||
});
|
});
|
||||||
const options = buildMemberOptions(topListener, members);
|
const options = buildMemberOptions(topListener, members);
|
||||||
const choices = options
|
if (options) {
|
||||||
? buildOptionsWithCorrect(topListener.name, options, options.length)
|
|
||||||
: null;
|
|
||||||
if (choices) {
|
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `social:track-listener:${topTrack.name}`,
|
key: `social:track-listener:${topTrack.name}`,
|
||||||
subjectKey: `track:${topTrack.name}`,
|
subjectKey: `track:${topTrack.name}`,
|
||||||
|
|
@ -116,8 +106,8 @@ export async function buildSocialQuestion(
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Who listens the most to "${topTrack.name}"?`,
|
text: `Who listens the most to "${topTrack.name}"?`,
|
||||||
options: choices.options,
|
options,
|
||||||
correct: choices.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: trackSong ?? topSong ?? undefined,
|
song: trackSong ?? topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
@ -133,18 +123,15 @@ export async function buildSocialQuestion(
|
||||||
if (memberA && memberB) {
|
if (memberA && memberB) {
|
||||||
const correctPair = `${memberA.name} & ${memberB.name}`;
|
const correctPair = `${memberA.name} & ${memberB.name}`;
|
||||||
const pairOptions = buildMemberPairOptions(members, correctPair);
|
const pairOptions = buildMemberPairOptions(members, correctPair);
|
||||||
const pairChoices = pairOptions
|
if (pairOptions) {
|
||||||
? buildOptionsWithCorrect(correctPair, pairOptions, pairOptions.length)
|
|
||||||
: null;
|
|
||||||
if (pairChoices) {
|
|
||||||
questions.push({
|
questions.push({
|
||||||
key: `social:pair:${memberA.userId}:${memberB.userId}`,
|
key: `social:pair:${memberA.userId}:${memberB.userId}`,
|
||||||
subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`,
|
subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`,
|
||||||
question: {
|
question: {
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which two players share the most musical taste?",
|
text: "Which two players share the most musical taste?",
|
||||||
options: pairChoices.options,
|
options: pairOptions,
|
||||||
correct: pairChoices.correct,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
song: topSong ?? undefined,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import type { db as Db } from "../db";
|
import type { db as Db } from "../db";
|
||||||
import { party } from "../db/schema";
|
import { party } from "../db/schema";
|
||||||
import type { PartySocketEvent, PartyStatus, QuizState } from "../party-types";
|
import type { PartySocketEvent, QuizState } from "../party-types";
|
||||||
import { publishDeviceEventForUser } from "../routes/device-socket";
|
import { publishDeviceEventForUser } from "../routes/device-socket";
|
||||||
import { pubsub } from "../routes/party-socket";
|
import { pubsub } from "../routes/party-socket";
|
||||||
|
|
||||||
|
|
@ -9,7 +9,6 @@ export async function updatePartyData(
|
||||||
db: typeof Db,
|
db: typeof Db,
|
||||||
id: string,
|
id: string,
|
||||||
data: QuizState,
|
data: QuizState,
|
||||||
status?: PartyStatus,
|
|
||||||
) {
|
) {
|
||||||
const members = await db.query.partyMember.findMany({
|
const members = await db.query.partyMember.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|
@ -25,29 +24,22 @@ export async function updatePartyData(
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!partyObject) throw new Error("Missing party");
|
if (!partyObject) throw new Error("Missing party");
|
||||||
const updatedParty = {
|
|
||||||
...partyObject,
|
|
||||||
status: status ?? partyObject.status,
|
|
||||||
data,
|
|
||||||
};
|
|
||||||
await db
|
|
||||||
.update(party)
|
|
||||||
.set({
|
|
||||||
data,
|
|
||||||
...(status ? { status } : {}),
|
|
||||||
lastUpdated: new Date(),
|
|
||||||
})
|
|
||||||
.where(eq(party.id, id));
|
|
||||||
|
|
||||||
pubsub.publishPartyData(id, {
|
pubsub.publishPartyData(id, {
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
party: updatedParty,
|
party: {
|
||||||
|
...partyObject,
|
||||||
|
data,
|
||||||
|
},
|
||||||
members,
|
members,
|
||||||
});
|
});
|
||||||
|
|
||||||
const event: PartySocketEvent = {
|
const event: PartySocketEvent = {
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
party: updatedParty,
|
party: {
|
||||||
|
...partyObject,
|
||||||
|
data,
|
||||||
|
},
|
||||||
members,
|
members,
|
||||||
};
|
};
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
|
|
@ -56,10 +48,20 @@ export async function updatePartyData(
|
||||||
`user:${member.userId}`,
|
`user:${member.userId}`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
party: updatedParty,
|
party: {
|
||||||
|
...partyObject,
|
||||||
|
data,
|
||||||
|
},
|
||||||
members,
|
members,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
void publishDeviceEventForUser(member.userId, event);
|
void publishDeviceEventForUser(member.userId, event);
|
||||||
}
|
}
|
||||||
|
await db
|
||||||
|
.update(party)
|
||||||
|
.set({
|
||||||
|
data: data,
|
||||||
|
lastUpdated: new Date(),
|
||||||
|
})
|
||||||
|
.where(eq(party.id, id));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -73,15 +73,6 @@ function isDeviceQuizResponsePayload(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isValidAnswer(quizData: QuizState, selected: number): boolean {
|
|
||||||
const question = quizData.currentQuestion;
|
|
||||||
if (!question) return false;
|
|
||||||
if (question.type === "choice") {
|
|
||||||
return selected >= 0 && selected < question.options.length;
|
|
||||||
}
|
|
||||||
return selected >= question.range.min && selected <= question.range.max;
|
|
||||||
}
|
|
||||||
|
|
||||||
function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) {
|
function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) {
|
||||||
if (!devProxySocket) {
|
if (!devProxySocket) {
|
||||||
console.log("[device-socket] no dev proxy for event", deviceId, event.type);
|
console.log("[device-socket] no dev proxy for event", deviceId, event.type);
|
||||||
|
|
@ -244,13 +235,6 @@ async function forwardDevicePayload(deviceId: string, payload: unknown) {
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isValidAnswer(quizData, payload.QuizResponse)) {
|
|
||||||
sendDeviceEvent(deviceId, {
|
|
||||||
type: "error",
|
|
||||||
message: "Invalid answer.",
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await DBOS.send(
|
await DBOS.send(
|
||||||
quizData.workflowId,
|
quizData.workflowId,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { betterAuthElysia } from "../auth";
|
||||||
|
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data";
|
import { getMemberRecord, getPartyStatus } from "../party-data";
|
||||||
import type { PartySocketEvent } from "../party-types";
|
import type { PartySocketEvent, QuizState } from "../party-types";
|
||||||
|
|
||||||
export function userTopic(userId: string) {
|
export function userTopic(userId: string) {
|
||||||
return `user:${userId}`;
|
return `user:${userId}`;
|
||||||
|
|
@ -63,6 +63,31 @@ async function subscribeWsToParty(
|
||||||
return nextPartyId;
|
return nextPartyId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function broadcastQuizState(
|
||||||
|
ws: { publish: (topic: string, message: string) => void },
|
||||||
|
partyId: string,
|
||||||
|
) {
|
||||||
|
const partyRecord = await db.query.party.findFirst({
|
||||||
|
where: { id: partyId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!partyRecord) return;
|
||||||
|
|
||||||
|
const quizData = ((partyRecord.data ?? {}) as Record<string, unknown>).quiz as
|
||||||
|
| QuizState
|
||||||
|
| undefined;
|
||||||
|
if (!quizData) return;
|
||||||
|
if (!quizData) return;
|
||||||
|
|
||||||
|
ws.publish(
|
||||||
|
partyTopic(partyId),
|
||||||
|
JSON.stringify({
|
||||||
|
type: "quiz_state",
|
||||||
|
quiz: quizData,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export const topic = {
|
export const topic = {
|
||||||
user: userTopic,
|
user: userTopic,
|
||||||
party: partyTopic,
|
party: partyTopic,
|
||||||
|
|
@ -93,6 +118,8 @@ export const partySocketApp = new Elysia()
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await broadcastQuizState(ws, subscribedPartyId);
|
||||||
},
|
},
|
||||||
message: async (ws, message) => {
|
message: async (ws, message) => {
|
||||||
const data = ws.data;
|
const data = ws.data;
|
||||||
|
|
|
||||||
|
|
@ -26,15 +26,6 @@ function broadcastStatusToMembers(
|
||||||
|
|
||||||
const quizWf = new QuizWorkflow();
|
const quizWf = new QuizWorkflow();
|
||||||
|
|
||||||
function isValidAnswer(quizData: QuizState, selected: number): boolean {
|
|
||||||
const question = quizData.currentQuestion;
|
|
||||||
if (!question) return false;
|
|
||||||
if (question.type === "choice") {
|
|
||||||
return selected >= 0 && selected < question.options.length;
|
|
||||||
}
|
|
||||||
return selected >= question.range.min && selected <= question.range.max;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const quizRoutes = new Elysia()
|
export const quizRoutes = new Elysia()
|
||||||
.use(betterAuthElysia)
|
.use(betterAuthElysia)
|
||||||
.group("/party/:partyId/quiz", (app) =>
|
.group("/party/:partyId/quiz", (app) =>
|
||||||
|
|
@ -60,6 +51,11 @@ export const quizRoutes = new Elysia()
|
||||||
return { error: "Quiz already running" };
|
return { error: "Quiz already running" };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handle = await DBOS.startWorkflow(quizWf.startQuiz, {
|
||||||
|
queueName: quizQueue.name,
|
||||||
|
enqueueOptions: { queuePartitionKey: params.partyId },
|
||||||
|
})(params.partyId);
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(party)
|
.update(party)
|
||||||
.set({
|
.set({
|
||||||
|
|
@ -69,11 +65,6 @@ export const quizRoutes = new Elysia()
|
||||||
})
|
})
|
||||||
.where(eq(party.id, params.partyId));
|
.where(eq(party.id, params.partyId));
|
||||||
|
|
||||||
const handle = await DBOS.startWorkflow(quizWf.startQuiz, {
|
|
||||||
queueName: quizQueue.name,
|
|
||||||
enqueueOptions: { queuePartitionKey: params.partyId },
|
|
||||||
})(params.partyId);
|
|
||||||
|
|
||||||
const status = await getPartyStatus(params.partyId);
|
const status = await getPartyStatus(params.partyId);
|
||||||
broadcastStatusToMembers(status);
|
broadcastStatusToMembers(status);
|
||||||
|
|
||||||
|
|
@ -136,12 +127,6 @@ export const quizRoutes = new Elysia()
|
||||||
.post(
|
.post(
|
||||||
"/response",
|
"/response",
|
||||||
async ({ user, body, params, set }) => {
|
async ({ user, body, params, set }) => {
|
||||||
const membership = await getMemberRecord(db, user.id);
|
|
||||||
if (!membership || membership.partyId !== params.partyId) {
|
|
||||||
set.status = 403;
|
|
||||||
return { error: "Not a member of this party" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const party = await db.query.party.findFirst({
|
const party = await db.query.party.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: params.partyId,
|
id: params.partyId,
|
||||||
|
|
@ -163,25 +148,10 @@ export const quizRoutes = new Elysia()
|
||||||
set.status = 500;
|
set.status = 500;
|
||||||
return { error: "Workflow ID not found" };
|
return { error: "Workflow ID not found" };
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
body.questionIndex !== undefined &&
|
|
||||||
body.questionIndex !== quizData.questionIndex
|
|
||||||
) {
|
|
||||||
set.status = 409;
|
|
||||||
return { error: "Stale question response" };
|
|
||||||
}
|
|
||||||
if (!isValidAnswer(quizData, body.selected)) {
|
|
||||||
set.status = 400;
|
|
||||||
return { error: "Invalid answer" };
|
|
||||||
}
|
|
||||||
|
|
||||||
await DBOS.send(
|
await DBOS.send(
|
||||||
quizData.workflowId,
|
quizData.workflowId,
|
||||||
{
|
{ playerId: user.id, selected: body.selected },
|
||||||
playerId: user.id,
|
|
||||||
selected: body.selected,
|
|
||||||
questionIndex: body.questionIndex,
|
|
||||||
},
|
|
||||||
"quiz_responses",
|
"quiz_responses",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -191,7 +161,6 @@ export const quizRoutes = new Elysia()
|
||||||
auth: true,
|
auth: true,
|
||||||
body: t.Object({
|
body: t.Object({
|
||||||
selected: t.Integer(),
|
selected: t.Integer(),
|
||||||
questionIndex: t.Optional(t.Integer()),
|
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ export const quizQueue = new WorkflowQueue("quiz_queue", {
|
||||||
type Response = {
|
type Response = {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
selected: number;
|
selected: number;
|
||||||
questionIndex?: number;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export class QuizWorkflow extends ConfiguredInstance {
|
export class QuizWorkflow extends ConfiguredInstance {
|
||||||
|
|
@ -54,15 +53,15 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
history: [],
|
history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Initialize quiz state
|
||||||
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
|
||||||
// Get party members to initialize scores
|
// Get party members to initialize scores
|
||||||
let members = await QuizWorkflow.getPartyMembers(partyId);
|
let members = await QuizWorkflow.getPartyMembers(partyId);
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
quizState.scores[member.userId] = 0;
|
quizState.scores[member.userId] = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize quiz state after scores are ready.
|
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
|
||||||
|
|
||||||
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
for (let i = 0; i < TOTAL_QUESTIONS; i++) {
|
||||||
quizState.status = "running";
|
quizState.status = "running";
|
||||||
quizState.questionIndex = i;
|
quizState.questionIndex = i;
|
||||||
|
|
@ -97,7 +96,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
|
|
||||||
if (response === null) {
|
if (response === null) {
|
||||||
// Timeout - fill in missing players with no answer
|
// Timeout - fill in missing players with no answer
|
||||||
const now = await DBOS.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)) {
|
||||||
|
|
@ -117,17 +116,10 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!memberIds.has(response.playerId)) continue;
|
|
||||||
if (
|
|
||||||
response.questionIndex !== undefined &&
|
|
||||||
response.questionIndex !== i
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (receivedPlayers.has(response.playerId)) continue;
|
if (receivedPlayers.has(response.playerId)) continue;
|
||||||
|
|
||||||
receivedPlayers.add(response.playerId);
|
receivedPlayers.add(response.playerId);
|
||||||
const answeredAt = await DBOS.now();
|
const answeredAt = Date.now();
|
||||||
const selectedValue = response.selected;
|
const selectedValue = response.selected;
|
||||||
const isCorrect = selectedValue === question.correct;
|
const isCorrect = selectedValue === question.correct;
|
||||||
const quizResponse: QuizResponse = {
|
const quizResponse: QuizResponse = {
|
||||||
|
|
@ -156,16 +148,15 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
|
|
||||||
// Quiz complete
|
// Quiz complete
|
||||||
quizState.status = "results";
|
quizState.status = "results";
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState, "ended");
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
private static async updatePartyData(
|
private static async updatePartyData(
|
||||||
partyId: string,
|
partyId: string,
|
||||||
quizState: QuizState,
|
quizState: QuizState,
|
||||||
status?: "created" | "started" | "ended",
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await updatePartyData(db, partyId, quizState, status);
|
await updatePartyData(db, partyId, quizState);
|
||||||
}
|
}
|
||||||
|
|
||||||
@DBOS.step()
|
@DBOS.step()
|
||||||
|
|
@ -224,9 +215,10 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (groups.length <= 1) {
|
if (groups.length <= 1) {
|
||||||
const onlyDistance = groups[0]?.distance ?? Number.POSITIVE_INFINITY;
|
return ordered.map(({ response }) => [
|
||||||
const gained = onlyDistance === 0 ? round.question.points : 0;
|
response.playerId,
|
||||||
return ordered.map(({ response }) => [response.playerId, gained]);
|
round.question.points,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoredAnswers = groups.flatMap((group, index) => {
|
const scoredAnswers = groups.flatMap((group, index) => {
|
||||||
|
|
|
||||||
|
|
@ -55,11 +55,6 @@ export function Question() {
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const question = party?.data?.currentQuestion;
|
const question = party?.data?.currentQuestion;
|
||||||
const questionStartTimestamp = question?.startTimestamp ?? null;
|
const questionStartTimestamp = question?.startTimestamp ?? null;
|
||||||
const questionResetKey = question
|
|
||||||
? `${party?.data?.questionIndex ?? 0}:${question.startTimestamp}`
|
|
||||||
: null;
|
|
||||||
const numericInitialValue =
|
|
||||||
question?.type === "numeric" ? question.range.min : null;
|
|
||||||
const questionAnnouncement = question
|
const questionAnnouncement = question
|
||||||
? getQuestionAnnouncement(question)
|
? getQuestionAnnouncement(question)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -73,10 +68,10 @@ export function Question() {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!questionResetKey) return;
|
if (!question) return;
|
||||||
setSelected(null);
|
setSelected(null);
|
||||||
setSelectedValue(numericInitialValue);
|
setSelectedValue(question.type === "numeric" ? question.range.min : null);
|
||||||
}, [questionResetKey, numericInitialValue]);
|
}, [question]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
|
|
@ -106,7 +101,6 @@ export function Question() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const partyId = party.id;
|
const partyId = party.id;
|
||||||
const questionIndex = party.data.questionIndex;
|
|
||||||
const timeLeft = formatTimeLeft(question.endTimestamp - now);
|
const timeLeft = formatTimeLeft(question.endTimestamp - now);
|
||||||
const answeredCount = Object.keys(party.data.answers).length;
|
const answeredCount = Object.keys(party.data.answers).length;
|
||||||
const hasResponded = user ? party.data.answers[user.id] != null : false;
|
const hasResponded = user ? party.data.answers[user.id] != null : false;
|
||||||
|
|
@ -134,7 +128,6 @@ export function Question() {
|
||||||
try {
|
try {
|
||||||
await client.api.party({ partyId }).quiz.response.post({
|
await client.api.party({ partyId }).quiz.response.post({
|
||||||
selected: optionIndex,
|
selected: optionIndex,
|
||||||
questionIndex,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
@ -149,7 +142,6 @@ export function Question() {
|
||||||
try {
|
try {
|
||||||
await client.api.party({ partyId }).quiz.response.post({
|
await client.api.party({ partyId }).quiz.response.post({
|
||||||
selected: value,
|
selected: value,
|
||||||
questionIndex,
|
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
|
|
|
||||||
|
|
@ -50,12 +50,7 @@ export function usePartySocket({
|
||||||
};
|
};
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
let parsed: PartySocketEvent;
|
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
||||||
try {
|
|
||||||
parsed = JSON.parse(event.data) as PartySocketEvent;
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
handlerRef.current?.(parsed);
|
handlerRef.current?.(parsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ const config = defineConfig({
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
allowedHosts: ["aura.rpi1.danbulant.cloud", "fern.rpi1.danbulant.cloud"],
|
allowedHosts: ["aura.rpi1.danbulant.cloud"],
|
||||||
proxy: {
|
proxy: {
|
||||||
"/api": {
|
"/api": {
|
||||||
target: "http://localhost:4000",
|
target: "http://localhost:4000",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue