import type { InferSelectModel } from "drizzle-orm"; import type { db as Db } from "../db"; import type { track as trackTable } from "../db/schema"; import type { QuizRound } from "../party-types"; export type PartyQuestionMember = { userId: string; name: string; }; export type PartyAnalytics = { groupSummary?: { totalMembers?: number; mostSharedGenres?: { name: string }[]; mostDiverseMember?: { userId: string; genreEntropy: number } | null; mostAlignedPair?: { userIdA: string; userIdB: string; similarity?: number; } | null; }; 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 }[]; }; type QuestionLike = { text: string; questionKey?: string; subjectKey?: string; }; export type QuestionCandidate = { key: string; subjectKey?: string; question: T; }; export type QuestionSong = InferSelectModel; 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 { 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( 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 pickQuestionCandidate( candidates: QuestionCandidate[], history: QuizRound[], index: number, ): T | null { const seenKeys = new Set(); const seenSubjects = new Set(); const seenTexts = new Set(); for (const round of history) { const question = round.question; seenTexts.add(normalizeQuestionKey(question.text)); if (question.questionKey) seenKeys.add(normalizeQuestionKey(question.questionKey)); if (question.subjectKey) seenSubjects.add(normalizeQuestionKey(question.subjectKey)); } const fresh = candidates.filter((candidate) => { const key = normalizeQuestionKey(candidate.key); const subjectKey = candidate.subjectKey ? normalizeQuestionKey(candidate.subjectKey) : null; const text = normalizeQuestionKey(candidate.question.text); if (seenKeys.has(key)) return false; if (subjectKey && seenSubjects.has(subjectKey)) return false; if (seenTexts.has(text)) return false; return true; }); if (fresh.length === 0) return null; const pool = fresh; return pool[index % pool.length]?.question ?? null; } function normalizeQuestionKey(value: string): string { return value.trim().toLowerCase(); } 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 { 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, desiredCount: number, ): string[] | null { const options = uniqueStrings( values.filter((value): value is string => isUsableText(value)), ); return options.length >= desiredCount ? options.slice(0, desiredCount) : null; } export function buildOptionsWithCorrect( correct: string, candidates: string[], desiredCount: number, ): string[] | null { if (!isUsableText(correct)) return null; const options = uniqueStrings([ correct, ...candidates.filter((c) => isUsableText(c) && c !== correct), ]); return options.length >= desiredCount ? options.slice(0, desiredCount) : null; } export function pickRandom(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 }, 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; }): 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 | null { const userId = analytics?.groupSummary?.mostDiverseMember?.userId; if (!userId) return null; return members.find((member) => member.userId === userId) ?? null; } export function getMostAlignedMember( analytics: PartyAnalytics, members: PartyQuestionMember[], ): PartyQuestionMember | null { const userId = analytics?.groupSummary?.mostAlignedPair?.userIdA; if (!userId) return null; return members.find((member) => member.userId === userId) ?? null; } export function buildMemberOptions( correctMember: PartyQuestionMember, members: PartyQuestionMember[], ): string[] | null { const desiredCount = getPartySize(members.length); if (!isUsableText(correctMember.name)) return null; const options = uniqueStrings([ correctMember.name, ...members.map((member) => member.name).filter(isUsableText), ]); if (options.length < desiredCount) return null; 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 }; } export function buildMemberPairOptions( members: PartyQuestionMember[], correctPair: string, ): string[] | null { if (members.length < 3) return null; if (!isUsableText(correctPair)) return null; const pairs = uniqueStrings([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; if (!isUsableText(left.name) || !isUsableText(right.name)) continue; const pair = `${left.name} & ${right.name}`; if (pair !== correctPair) pairs.push(pair); } } const deduped = uniqueStrings(pairs); return deduped.length >= 2 ? deduped.slice(0, 4) : null; }