itpdp/api/src/party/numeric-question-generator.ts
2026-05-26 19:53:28 +02:00

356 lines
10 KiB
TypeScript

import { and, eq, inArray } from "drizzle-orm";
import type { db } from "../db";
import {
topArtist as topArtistTable,
topTrack as topTrackTable,
} from "../db/schema";
import type { Question, QuizRound } from "../party-types";
import {
buildQuestionWindow,
getArtistFairness,
getFairQuestionArtists,
getFairQuestionTracks,
getReleaseYearRange,
getTrackFairness,
isUsableText,
type PartyAnalytics,
type PartyQuestionMember,
pickQuestionCandidate,
type QuestionCandidate,
resolveQuestionSong,
} from "./question-utils";
type NumericQuestion = Omit<
Extract<Question, { type: "numeric" }>,
"startTimestamp" | "endTimestamp"
>;
type TrackDetails = {
id: string;
name: string | null;
platform_id: string | null;
album?: { name: string | null; release_date: Date | null } | null;
artists?: { name: string }[] | null;
};
type BuildNumericQuestionInput = {
db: typeof db;
analytics: PartyAnalytics;
index: number;
members: PartyQuestionMember[];
history: QuizRound[];
};
async function getDetailedTopTracks({
db,
analytics,
members,
history,
}: BuildNumericQuestionInput): Promise<TrackDetails[]> {
const tracks: TrackDetails[] = [];
const seen = new Set<string>();
for (const topTrack of getFairQuestionTracks(analytics, members, history)) {
if (!isUsableText(topTrack.name)) continue;
const dbTracks = (await db.query.track.findMany({
where: { name: topTrack.name },
with: { album: true, artists: true },
})) as TrackDetails[];
const topArtists = topTrack.artists?.map((artist) => artist.name) ?? [];
const track = dbTracks.slice().sort((a, b) => {
const score = (candidate: typeof a) => {
const artistNames =
candidate.artists?.map((artist) => artist.name) ?? [];
return (
(candidate.album?.name === topTrack.albumName ? 2 : 0) +
(topArtists.some((name) => artistNames.includes(name)) ? 2 : 0)
);
};
return score(b) - score(a);
})[0];
if (!track || !isUsableText(track.name)) continue;
const key = track.platform_id ?? track.id;
if (seen.has(key)) continue;
seen.add(key);
tracks.push(track);
}
return tracks;
}
async function getAlbumReleaseYear({
db,
analytics,
members,
history,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
const trackName = topTrack?.name;
const track = trackName
? await db.query.track.findFirst({
where: { name: trackName },
with: { album: true },
})
: null;
const subject = [track?.album?.name, track?.name].find((value) =>
isUsableText(value),
);
if (!subject) return null;
if (!track?.album?.release_date) return null;
const song = await resolveQuestionSong(db, analytics, {
trackName: track?.name ?? trackName ?? undefined,
});
const correct = track.album.release_date.getFullYear();
return {
type: "numeric",
text: `What's the release year of ${subject}?`,
correct,
range: getReleaseYearRange(correct),
points: 10,
song: song ?? undefined,
questionKey: `numeric:album-year:${subject}`,
subjectKey: track.name ? `track:${track.name}` : `album:${subject}`,
};
}
async function getTrackReleaseYear(
input: BuildNumericQuestionInput,
): Promise<NumericQuestion | null> {
const tracks = await getDetailedTopTracks(input);
const track = tracks.find((track) => track.album?.release_date && track.name);
if (!track?.name || !track.album?.release_date) return null;
const song = await resolveQuestionSong(input.db, input.analytics, {
trackName: track.name,
artistNames: track.artists?.map((artist) => artist.name),
albumName: track.album?.name ?? undefined,
});
const correct = track.album.release_date.getFullYear();
return {
type: "numeric",
text: `What year did "${track.name}" come out?`,
correct,
range: getReleaseYearRange(correct),
points: 10,
song: song ?? undefined,
questionKey: `numeric:track-year:${track.name}`,
subjectKey: `track:${track.name}`,
};
}
async function getArtistFirstTrackReleaseYear(
input: BuildNumericQuestionInput,
): Promise<NumericQuestion | null> {
const tracks = await getDetailedTopTracks(input);
const tracksByArtist = new Map<string, TrackDetails[]>();
for (const track of tracks) {
if (!track.album?.release_date) continue;
for (const artist of track.artists ?? []) {
if (!isUsableText(artist.name)) continue;
const artistTracks = tracksByArtist.get(artist.name) ?? [];
artistTracks.push(track);
tracksByArtist.set(artist.name, artistTracks);
}
}
const artistEntry = Array.from(tracksByArtist.entries()).find(
([, artistTracks]) => artistTracks.length >= 2,
);
if (!artistEntry) return null;
const [artistName, artistTracks] = artistEntry;
const firstTrack = artistTracks
.slice()
.sort(
(a, b) =>
Number(a.album?.release_date ?? 0) - Number(b.album?.release_date ?? 0),
)[0];
if (!firstTrack?.album?.release_date) return null;
const song = await resolveQuestionSong(input.db, input.analytics, {
trackName: firstTrack.name ?? undefined,
artistNames: [artistName],
albumName: firstTrack.album?.name ?? undefined,
});
const correct = firstTrack.album.release_date.getFullYear();
return {
type: "numeric",
text: `What year did ${artistName}'s first party track come out?`,
correct,
range: getReleaseYearRange(correct),
points: 10,
song: song ?? undefined,
questionKey: `numeric:artist-first-track-year:${artistName}`,
subjectKey: `artist:${artistName}`,
};
}
async function countTopTrackListeners({
db,
analytics,
members,
history,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const topTrack = getFairQuestionTracks(analytics, members, history)[0];
const trackName = topTrack?.name;
if (!trackName || members.length === 0) return null;
const dbTrack = await db.query.track.findFirst({
where: { name: trackName },
});
if (!dbTrack) return null;
const song = await resolveQuestionSong(db, analytics, { trackName });
const memberIds = members.map((m) => m.userId);
const entries = await db
.select({ userId: topTrackTable.userId })
.from(topTrackTable)
.where(
and(
eq(topTrackTable.trackId, dbTrack.id),
inArray(topTrackTable.userId, memberIds),
),
);
const correct = new Set(entries.map((e) => e.userId)).size;
if (correct <= 0) return null;
return {
type: "numeric",
text: `For how many players in the party is "${trackName}" a top track?`,
correct,
range: { min: 0, max: members.length },
points: 10,
song: song ?? undefined,
questionKey: `numeric:top-track-count:${trackName}`,
subjectKey: `track:${trackName}`,
};
}
async function countFavouriteArtistListeners({
db,
analytics,
members,
history,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const topArtist = getFairQuestionArtists(analytics, members, history)[0];
const artistName = topArtist?.name;
if (!artistName || members.length === 0) return null;
const dbArtist = await db.query.artist.findFirst({
where: { name: artistName },
});
if (!dbArtist) return null;
const song = await resolveQuestionSong(db, analytics, {
artistNames: [artistName],
});
const memberIds = members.map((m) => m.userId);
const entries = await db
.select({ userId: topArtistTable.userId })
.from(topArtistTable)
.where(
and(
eq(topArtistTable.artistId, dbArtist.id),
inArray(topArtistTable.userId, memberIds),
),
);
const correct = new Set(entries.map((e) => e.userId)).size;
if (correct <= 0) return null;
return {
type: "numeric",
text: `How many players in the party have "${artistName}" as a favourite artist?`,
correct,
range: { min: 0, max: members.length },
points: 10,
song: song ?? undefined,
questionKey: `numeric:artist-count:${artistName}`,
subjectKey: `artist:${artistName}`,
};
}
export async function buildNumericQuestion(
input: BuildNumericQuestionInput,
): Promise<Question | null> {
const questions: Array<QuestionCandidate<NumericQuestion>> = [];
const albumYearQ = await getAlbumReleaseYear(input);
if (albumYearQ) {
questions.push({
key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`,
subjectKey: albumYearQ.subjectKey,
fairness: getNumericQuestionFairness(input, albumYearQ),
question: albumYearQ,
});
}
const trackYearQ = await getTrackReleaseYear(input);
if (trackYearQ) {
questions.push({
key: trackYearQ.questionKey ?? `numeric:track-year:${trackYearQ.text}`,
subjectKey: trackYearQ.subjectKey,
fairness: getNumericQuestionFairness(input, trackYearQ),
question: trackYearQ,
});
}
const artistFirstTrackYearQ = await getArtistFirstTrackReleaseYear(input);
if (artistFirstTrackYearQ) {
questions.push({
key:
artistFirstTrackYearQ.questionKey ??
`numeric:artist-first-track-year:${artistFirstTrackYearQ.text}`,
subjectKey: artistFirstTrackYearQ.subjectKey,
fairness: getNumericQuestionFairness(input, artistFirstTrackYearQ),
question: artistFirstTrackYearQ,
});
}
const topTrackQ = await countTopTrackListeners(input);
if (topTrackQ) {
questions.push({
key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`,
subjectKey: topTrackQ.subjectKey,
fairness: getNumericQuestionFairness(input, topTrackQ),
question: topTrackQ,
});
}
const artistQ = await countFavouriteArtistListeners(input);
if (artistQ) {
questions.push({
key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`,
subjectKey: artistQ.subjectKey,
fairness: getNumericQuestionFairness(input, artistQ),
question: artistQ,
});
}
const question = pickQuestionCandidate(questions, input.history, input.index);
if (!question) return null;
return buildQuestionWindow(question);
}
function getNumericQuestionFairness(
input: BuildNumericQuestionInput,
question: NumericQuestion,
) {
const subjectKey = question.subjectKey ?? "";
if (subjectKey.startsWith("track:")) {
const trackName = subjectKey.slice("track:".length);
const track = getFairQuestionTracks(
input.analytics,
input.members,
input.history,
).find((track) => track.name === trackName);
return track
? getTrackFairness(track, input.members, input.history)
: undefined;
}
if (subjectKey.startsWith("artist:")) {
const artistName = subjectKey.slice("artist:".length);
const artist = getFairQuestionArtists(
input.analytics,
input.members,
input.history,
).find((artist) => artist.name === artistName);
return artist
? getArtistFairness(artist, input.members, input.history)
: undefined;
}
return undefined;
}