improved questions
This commit is contained in:
parent
14ccaee48c
commit
7b8ec190a8
7 changed files with 226 additions and 96 deletions
|
|
@ -36,16 +36,16 @@ type BaseQuestion = {
|
||||||
|
|
||||||
export type Question =
|
export type Question =
|
||||||
| (BaseQuestion & {
|
| (BaseQuestion & {
|
||||||
type: "choice";
|
type: "choice";
|
||||||
options: string[];
|
options: string[];
|
||||||
})
|
})
|
||||||
| (BaseQuestion & {
|
| (BaseQuestion & {
|
||||||
type: "numeric";
|
type: "numeric";
|
||||||
range: {
|
range: {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export type QuizResponse = {
|
export type QuizResponse = {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import type { Question } from "../party-types";
|
import type { Question } from "../party-types";
|
||||||
import {
|
import {
|
||||||
|
buildOptionsWithCorrect,
|
||||||
|
buildOrderedOptions,
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
getTopArtistName,
|
getMostSharedGenreNames,
|
||||||
getTopGenreName,
|
getTopClusterArtists,
|
||||||
getTopTrackName,
|
getTopClusterTracks,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
|
pickRandom,
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
export function buildAudioMetadataQuestion(
|
export function buildAudioMetadataQuestion(
|
||||||
|
|
@ -12,34 +15,104 @@ export function buildAudioMetadataQuestion(
|
||||||
index: number,
|
index: number,
|
||||||
): Question {
|
): Question {
|
||||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||||
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
|
const questions: Array<
|
||||||
{
|
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||||
|
> = [];
|
||||||
|
|
||||||
|
const genreOptions = buildOrderedOptions(
|
||||||
|
getMostSharedGenreNames(analytics),
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
if (genreOptions) {
|
||||||
|
questions.push({
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which genre appears most in the party analytics?",
|
text: "Which genre appears most in the party analytics?",
|
||||||
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"],
|
options: genreOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
},
|
});
|
||||||
{
|
}
|
||||||
type: "choice",
|
|
||||||
text: "Which artist shows up most often in the shared audio data?",
|
const topArtists = getTopClusterArtists(analytics);
|
||||||
options: [
|
const topArtist = topArtists[0];
|
||||||
getTopArtistName(analytics),
|
if (topArtist) {
|
||||||
"Artist B",
|
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
|
||||||
"Artist C",
|
if (artistOptions) {
|
||||||
"Artist D",
|
questions.push({
|
||||||
],
|
type: "choice",
|
||||||
correct: 0,
|
text: "Which artist shows up most often in the shared audio data?",
|
||||||
points: 10,
|
options: artistOptions,
|
||||||
},
|
correct: 0,
|
||||||
{
|
points: 10,
|
||||||
type: "choice",
|
});
|
||||||
text: "Which track looks most shared across the party?",
|
}
|
||||||
options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"],
|
}
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
const topTracks = getTopClusterTracks(analytics);
|
||||||
},
|
if (topTracks.length > 0) {
|
||||||
];
|
const trackNames = topTracks.map((track) => track.name);
|
||||||
|
const topTrackName = trackNames[0];
|
||||||
|
const trackOptions = topTrackName
|
||||||
|
? buildOptionsWithCorrect(topTrackName, trackNames, 4)
|
||||||
|
: null;
|
||||||
|
if (trackOptions) {
|
||||||
|
questions.push({
|
||||||
|
type: "choice",
|
||||||
|
text: "Which track looks most shared across the party?",
|
||||||
|
options: trackOptions,
|
||||||
|
correct: 0,
|
||||||
|
points: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomTopTrack = pickRandom(topTracks);
|
||||||
|
if (randomTopTrack) {
|
||||||
|
const trackArtists =
|
||||||
|
randomTopTrack.artists?.map((artist) => artist.name) ?? [];
|
||||||
|
const allArtists = topArtists.length > 0 ? topArtists : trackArtists;
|
||||||
|
const correctArtist = trackArtists[0] ?? allArtists[0];
|
||||||
|
if (correctArtist) {
|
||||||
|
const artistOptions = buildOptionsWithCorrect(
|
||||||
|
correctArtist,
|
||||||
|
allArtists,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
if (artistOptions) {
|
||||||
|
questions.push({
|
||||||
|
type: "choice",
|
||||||
|
text: `Who performs "${randomTopTrack.name}"?`,
|
||||||
|
options: artistOptions,
|
||||||
|
correct: 0,
|
||||||
|
points: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (randomTopTrack.albumName) {
|
||||||
|
const albumNames = topTracks
|
||||||
|
.map((track) => track.albumName)
|
||||||
|
.filter((name): name is string => Boolean(name));
|
||||||
|
const albumOptions = buildOptionsWithCorrect(
|
||||||
|
randomTopTrack.albumName,
|
||||||
|
albumNames,
|
||||||
|
4,
|
||||||
|
);
|
||||||
|
if (albumOptions) {
|
||||||
|
questions.push({
|
||||||
|
type: "choice",
|
||||||
|
text: `"${randomTopTrack.name}" appears on which album?`,
|
||||||
|
options: albumOptions,
|
||||||
|
correct: 0,
|
||||||
|
points: 10,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (questions.length === 0) {
|
||||||
|
throw new Error("Question not found");
|
||||||
|
}
|
||||||
|
|
||||||
const question = questions[index % questions.length];
|
const question = questions[index % questions.length];
|
||||||
if (!question) throw new Error("Question not found");
|
if (!question) throw new Error("Question not found");
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,10 @@ import type { Question } from "../party-types";
|
||||||
import type { PartyAnalytics } from "./question-utils";
|
import type { PartyAnalytics } from "./question-utils";
|
||||||
import { buildQuestionWindow, getQuestionRange } from "./question-utils";
|
import { buildQuestionWindow, getQuestionRange } from "./question-utils";
|
||||||
|
|
||||||
type NumericQuestion = Omit<Extract<Question, { type: "numeric" }>, "startTimestamp" | "endTimestamp">;
|
type NumericQuestion = Omit<
|
||||||
|
Extract<Question, { type: "numeric" }>,
|
||||||
|
"startTimestamp" | "endTimestamp"
|
||||||
|
>;
|
||||||
|
|
||||||
type BuildNumericQuestionInput = {
|
type BuildNumericQuestionInput = {
|
||||||
db: typeof db;
|
db: typeof db;
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,13 @@ export type PartyAnalytics = {
|
||||||
mostSharedGenres?: { name: string }[];
|
mostSharedGenres?: { name: string }[];
|
||||||
};
|
};
|
||||||
storyClusters?: {
|
storyClusters?: {
|
||||||
tracks?: { name: string }[];
|
tracks?: {
|
||||||
|
name: string;
|
||||||
|
artists?: { name: string }[];
|
||||||
|
albumName?: string;
|
||||||
|
}[];
|
||||||
artists?: { name: string }[];
|
artists?: { name: string }[];
|
||||||
|
genres?: { name: string }[];
|
||||||
}[];
|
}[];
|
||||||
memberProfiles?: { userId: string }[];
|
memberProfiles?: { userId: string }[];
|
||||||
pairwise?: { userIdA: string; userIdB: string }[];
|
pairwise?: { userIdA: string; userIdB: string }[];
|
||||||
|
|
@ -78,16 +83,50 @@ export function getQuestionRange(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopGenreName(analytics: PartyAnalytics): string {
|
export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
|
||||||
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
|
return (analytics?.groupSummary?.mostSharedGenres ?? []).map(
|
||||||
|
(genre) => genre.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopArtistName(analytics: PartyAnalytics): string {
|
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||||
return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A";
|
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
||||||
|
(artist) => artist.name,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopTrackName(analytics: PartyAnalytics): string {
|
export function getTopClusterTracks(
|
||||||
return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A";
|
analytics: PartyAnalytics,
|
||||||
|
): Array<{ name: string; artists?: { name: string }[]; albumName?: string }> {
|
||||||
|
return analytics?.storyClusters?.[0]?.tracks ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOrderedOptions(
|
||||||
|
values: Array<string | undefined>,
|
||||||
|
desiredCount: number,
|
||||||
|
): string[] | null {
|
||||||
|
const options = uniqueStrings(
|
||||||
|
values.filter((value): value is string => Boolean(value)),
|
||||||
|
);
|
||||||
|
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOptionsWithCorrect(
|
||||||
|
correct: string,
|
||||||
|
candidates: string[],
|
||||||
|
desiredCount: number,
|
||||||
|
): string[] | null {
|
||||||
|
const options = uniqueStrings([
|
||||||
|
correct,
|
||||||
|
...candidates.filter((c) => c !== correct),
|
||||||
|
]);
|
||||||
|
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pickRandom<T>(items: T[]): T | null {
|
||||||
|
if (items.length === 0) return null;
|
||||||
|
const index = Math.floor(Math.random() * items.length);
|
||||||
|
return items[index] ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCurrentLeader(
|
export function getCurrentLeader(
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,9 @@ export function buildSocialQuestion(
|
||||||
const diverse = getMostDiverseMember(analytics, members);
|
const diverse = getMostDiverseMember(analytics, members);
|
||||||
const aligned = getMostAlignedMember(analytics, members);
|
const aligned = getMostAlignedMember(analytics, members);
|
||||||
|
|
||||||
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
|
const questions: Array<
|
||||||
|
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||||
|
> = [
|
||||||
{
|
{
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Who is leading the quiz right now?",
|
text: "Who is leading the quiz right now?",
|
||||||
|
|
|
||||||
|
|
@ -1,44 +1,47 @@
|
||||||
import Elysia from "elysia"
|
import Elysia from "elysia";
|
||||||
import { broadcastQuizState, partyTopic, socketPartyId, userTopic } from "./party-socket"
|
import { db } from "../db";
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data"
|
import { getMemberRecord, getPartyStatus } from "../party-data";
|
||||||
import { db } from "../db"
|
import {
|
||||||
|
broadcastQuizState,
|
||||||
|
partyTopic,
|
||||||
|
socketPartyId,
|
||||||
|
userTopic,
|
||||||
|
} from "./party-socket";
|
||||||
|
|
||||||
export const partySocketApp = new Elysia()
|
export const partySocketApp = new Elysia().group("/dev-socket", (app) =>
|
||||||
.group("/dev-socket", (app) =>
|
app
|
||||||
app
|
.get("/test", () => ({ ok: 1 }))
|
||||||
.get("/test", () => ({ ok: 1 }))
|
.ws("/ws", {
|
||||||
.ws("/ws", {
|
async open(ws) {
|
||||||
async open(ws) {
|
const id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t";
|
||||||
let id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t"
|
ws.subscribe(userTopic(id));
|
||||||
ws.subscribe(userTopic(id))
|
const membership = await getMemberRecord(db, id);
|
||||||
const membership = await getMemberRecord(db, id);
|
if (!membership) {
|
||||||
if (!membership) {
|
ws.send(
|
||||||
ws.send(
|
JSON.stringify({
|
||||||
JSON.stringify({
|
type: "snapshot",
|
||||||
type: "snapshot",
|
party: null,
|
||||||
party: null,
|
members: [],
|
||||||
members: [],
|
}),
|
||||||
}),
|
);
|
||||||
);
|
return;
|
||||||
return;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
socketPartyId.set(ws, membership.partyId);
|
socketPartyId.set(ws, membership.partyId);
|
||||||
ws.subscribe(partyTopic(membership.partyId));
|
ws.subscribe(partyTopic(membership.partyId));
|
||||||
|
|
||||||
const snapshot = await getPartyStatus(membership.partyId);
|
const snapshot = await getPartyStatus(membership.partyId);
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "snapshot",
|
type: "snapshot",
|
||||||
party: snapshot.party,
|
party: snapshot.party,
|
||||||
members: snapshot.members,
|
members: snapshot.members,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await broadcastQuizState(ws, membership.partyId);
|
await broadcastQuizState(ws, membership.partyId);
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
}),
|
||||||
})
|
);
|
||||||
)
|
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,13 @@ import { partyMember } from "../db/schema";
|
||||||
import { generatePartyQuestion } from "../party/question-generator";
|
import { generatePartyQuestion } from "../party/question-generator";
|
||||||
import type { PartyAnalytics } from "../party/question-utils";
|
import type { PartyAnalytics } from "../party/question-utils";
|
||||||
import { updatePartyData } from "../party/state";
|
import { updatePartyData } from "../party/state";
|
||||||
import type { Question, QuizResponse, QuizRound, QuizState } from "../party-types";
|
import type {
|
||||||
import { partyAnalysisWorkflow, PartyAnalysisWorkflow } from "./party-analysis";
|
Question,
|
||||||
|
QuizResponse,
|
||||||
|
QuizRound,
|
||||||
|
QuizState,
|
||||||
|
} from "../party-types";
|
||||||
|
import { partyAnalysisWorkflow } from "./party-analysis";
|
||||||
|
|
||||||
const TOTAL_QUESTIONS = 5;
|
const TOTAL_QUESTIONS = 5;
|
||||||
|
|
||||||
|
|
@ -35,9 +40,9 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
answers: {},
|
answers: {},
|
||||||
scores: {},
|
scores: {},
|
||||||
history: [],
|
history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await partyAnalysisWorkflow.analyzeParty(partyId)
|
await partyAnalysisWorkflow.analyzeParty(partyId);
|
||||||
|
|
||||||
// Initialize quiz state
|
// Initialize quiz state
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
|
@ -76,10 +81,10 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
deadlineEpochMS: question.endTimestamp,
|
deadlineEpochMS: question.endTimestamp,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (response === null) {
|
if (response === null) {
|
||||||
// Timeout - fill in missing players with no answer
|
// Timeout - fill in missing players with no answer
|
||||||
const now = Date.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)) {
|
||||||
receivedPlayers.add(memberId);
|
receivedPlayers.add(memberId);
|
||||||
|
|
@ -116,8 +121,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
||||||
quizState.scores[playerId] =
|
quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
|
||||||
(quizState.scores[playerId] ?? 0) + gained;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
|
@ -169,7 +173,10 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
const ordered = round.responses
|
const ordered = round.responses
|
||||||
.map((response) => ({
|
.map((response) => ({
|
||||||
response,
|
response,
|
||||||
distance: Math.abs((response.selectedValue ?? response.selected) - round.question.correct),
|
distance: Math.abs(
|
||||||
|
(response.selectedValue ?? response.selected) -
|
||||||
|
round.question.correct,
|
||||||
|
),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.distance - b.distance);
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
|
@ -183,9 +190,12 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scoringGroups = groups.slice(0, Math.max(0, groups.length - 1));
|
const _scoringGroups = groups.slice(0, Math.max(0, groups.length - 1));
|
||||||
if (groups.length <= 1) {
|
if (groups.length <= 1) {
|
||||||
return round.responses.map((response) => [response.playerId, round.question.points]);
|
return round.responses.map((response) => [
|
||||||
|
response.playerId,
|
||||||
|
round.question.points,
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.flatMap((group, index) => {
|
return groups.flatMap((group, index) => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue