itpdp/api/src/party/question-utils.ts
2026-05-14 00:43:31 +02:00

354 lines
9.5 KiB
TypeScript

import type { InferSelectModel } from "drizzle-orm";
import type { db as Db } from "../db";
import type { track as trackTable } from "../db/schema";
export type PartyQuestionMember = {
userId: string;
name: string;
};
export type PartyAnalytics = {
groupSummary?: {
mostSharedGenres?: { name: string }[];
};
storyClusters?: {
tracks?: {
name: string;
artists?: { name: string }[];
albumName?: string;
memberScores?: { userId: string; score: number }[];
}[];
artists?: { name: string }[];
genres?: { name: string }[];
}[];
memberProfiles?: { userId: string }[];
pairwise?: { userIdA: string; userIdB: string }[];
} | null;
export type AnalyticsTrack = {
name: string;
artists?: { name: string }[];
albumName?: string;
memberScores?: { userId: string; score: number }[];
};
export type QuestionSong = InferSelectModel<typeof trackTable>;
export const QUESTION_DURATION_MS = 60_000;
export const MIN_PARTY_SIZE = 2;
export const MAX_PARTY_SIZE = 4;
export async function fetchPartyMembers(
db: typeof Db,
partyId: string,
): Promise<PartyQuestionMember[]> {
const members = await db.query.partyMember.findMany({
where: {
partyId,
},
with: {
user: true,
},
});
return members.map((member) => ({
userId: member.userId,
name: member.user?.name ?? member.userId,
}));
}
export function getPartySize(memberCount: number): number {
return Math.max(MIN_PARTY_SIZE, Math.min(MAX_PARTY_SIZE, memberCount));
}
export function buildQuestionWindow<T extends object>(
question: T,
timestamp = Date.now(),
): T & { startTimestamp: number; endTimestamp: number } {
return {
...question,
startTimestamp: timestamp,
endTimestamp: timestamp + QUESTION_DURATION_MS,
};
}
export function getQuestionDistanceScore(
selectedValue: number,
correctValue: number,
maxDistance: number,
points: number,
): number {
if (!Number.isFinite(selectedValue)) return 0;
if (maxDistance <= 0) return selectedValue === correctValue ? points : 0;
const distance = Math.abs(selectedValue - correctValue);
const ratio = Math.max(0, 1 - distance / maxDistance);
return Math.round(points * ratio);
}
export function getQuestionRange(
correctValue: number,
tolerance: number,
): { min: number; max: number } {
const min = Math.floor(correctValue - tolerance);
const max = Math.ceil(correctValue + tolerance);
return normalizeRange(min, max);
}
export function getReleaseYearRange(
releaseYear: number,
currentYear = new Date().getFullYear(),
tolerance = 10,
minSpan = 4,
): { min: number; max: number } {
const cappedMax = Math.min(currentYear, Math.ceil(releaseYear + tolerance));
const rawMin = Math.floor(releaseYear - tolerance);
const minimumSpan = Math.max(1, minSpan);
const expandedMin = Math.max(0, cappedMax - minimumSpan);
const min = Math.min(rawMin, expandedMin, releaseYear);
return normalizeRange(min, cappedMax);
}
export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
return (analytics?.groupSummary?.mostSharedGenres ?? []).map(
(genre) => genre.name,
);
}
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
(artist) => artist.name,
);
}
export function getTopClusterTracks(
analytics: PartyAnalytics,
): AnalyticsTrack[] {
return (analytics?.storyClusters?.[0]?.tracks ?? []).filter((track) =>
isUsableText(track.name),
);
}
export function pickRelevantTrack(
analytics: PartyAnalytics,
hints: {
trackName?: string;
artistNames?: string[];
albumName?: string;
} = {},
): AnalyticsTrack | null {
const tracks = getTopClusterTracks(analytics);
if (tracks.length === 0) return null;
const scored = tracks.map((track) => {
const trackArtistNames = track.artists?.map((artist) => artist.name) ?? [];
const matchesTrackName = hints.trackName && track.name === hints.trackName;
const matchesAlbum = hints.albumName && track.albumName === hints.albumName;
const matchesArtist =
hints.artistNames?.some((name) => trackArtistNames.includes(name)) ??
false;
return {
track,
score:
(matchesTrackName ? 4 : 0) +
(matchesAlbum ? 2 : 0) +
(matchesArtist ? 2 : 0),
};
});
const best = scored.sort((a, b) => b.score - a.score).at(0);
return best?.track ?? tracks[0] ?? null;
}
export async function resolveQuestionSong(
db: typeof Db,
analytics: PartyAnalytics,
hints: {
trackName?: string;
artistNames?: string[];
albumName?: string;
} = {},
): Promise<QuestionSong | null> {
const trackHint = pickRelevantTrack(analytics, hints);
if (!trackHint?.name) return null;
const tracks = await db.query.track.findMany({
where: { name: trackHint.name },
with: { album: true, artists: true },
});
const usableTracks = tracks.filter((track) => isUsableText(track.name));
if (usableTracks.length === 0) return null;
const scoreTrack = (track: (typeof tracks)[number]) => {
const artistNames = track.artists?.map((artist) => artist.name) ?? [];
const matchesAlbum = trackHint.albumName
? track.album?.name === trackHint.albumName
: false;
const matchesArtist =
hints.artistNames?.some((name) => artistNames.includes(name)) ?? false;
return (matchesAlbum ? 2 : 0) + (matchesArtist ? 2 : 0);
};
const chosen = usableTracks
.slice()
.sort((a, b) => scoreTrack(b) - scoreTrack(a))[0];
if (!chosen) return null;
const { album: _album, artists: _artists, ...song } = chosen;
return song;
}
export function isUsableText(
value: string | null | undefined,
): value is string {
return typeof value === "string" && value.trim().length > 0;
}
export function getTopTrackListener(
track: { memberScores?: { userId: string; score: number }[] },
members: PartyQuestionMember[],
): PartyQuestionMember | null {
const topMember = (track.memberScores ?? [])
.slice()
.sort((a, b) => b.score - a.score)
.at(0);
if (!topMember) return null;
return members.find((member) => member.userId === topMember.userId) ?? null;
}
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(
quizState: { scores: Record<string, number> },
members: PartyQuestionMember[],
): PartyQuestionMember {
const leaderId = Object.entries(quizState.scores)
.sort(([, a], [, b]) => b - a)
.at(0)?.[0];
return (
members.find((member) => member.userId === leaderId) ??
members[0] ?? { userId: "", name: "Player A" }
);
}
export function hasClearLeader(quizState: {
scores: Record<string, number>;
}): boolean {
const scores = Object.values(quizState.scores);
if (scores.length < 2) return false;
const ordered = scores.slice().sort((a, b) => b - a);
const [first, second] = ordered;
if (first === undefined || second === undefined) return false;
return first > second;
}
export function getMostDiverseMember(
analytics: PartyAnalytics,
members: PartyQuestionMember[],
): PartyQuestionMember {
const userId = analytics?.memberProfiles?.[0]?.userId;
return (
members.find((member) => member.userId === userId) ??
members[1] ??
members[0] ?? { userId: "", name: "Player B" }
);
}
export function getMostAlignedMember(
analytics: PartyAnalytics,
members: PartyQuestionMember[],
): PartyQuestionMember {
const userId = analytics?.pairwise?.[0]?.userIdA;
return (
members.find((member) => member.userId === userId) ??
members[2] ??
members[0] ?? { userId: "", name: "Player C" }
);
}
export function buildMemberOptions(
correctMember: PartyQuestionMember,
members: PartyQuestionMember[],
): string[] {
const desiredCount = getPartySize(members.length);
const options = uniqueStrings([
correctMember.name,
...members.map((member) => member.name),
]);
if (options.length < desiredCount) {
for (const fallback of fallbackPlayerNames(desiredCount)) {
if (options.length >= desiredCount) break;
if (!options.includes(fallback)) options.push(fallback);
}
}
const ordered = [
correctMember.name,
...options.filter((name) => name !== correctMember.name),
];
return ordered.slice(0, desiredCount);
}
function uniqueStrings(values: string[]): string[] {
return values.filter((value, index, list) => list.indexOf(value) === index);
}
function normalizeRange(
min: number,
max: number,
): { min: number; max: number } {
if (min <= max) return { min, max };
return { min: max, max: min };
}
function fallbackPlayerNames(count: number): string[] {
return Array.from(
{ length: count },
(_, index) => `Player ${String.fromCharCode(65 + index)}`,
);
}
export function buildMemberPairOptions(
members: PartyQuestionMember[],
correctPair: string,
): string[] | null {
if (members.length < 3) return null;
const pairs: string[] = [correctPair];
for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) {
const left = members[i];
const right = members[j];
if (!left || !right) continue;
const pair = `${left.name} & ${right.name}`;
if (pair !== correctPair) pairs.push(pair);
}
}
return pairs.length >= 2 ? pairs.slice(0, 4) : null;
}