import type { db as Db } from "../db"; 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 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, randomness: number, ): { min: number; max: number } { const min = Math.floor(correctValue - tolerance); const max = Math.ceil(correctValue + tolerance); const range = max - min; return { min: min + Math.floor(Math.random() * range * randomness), max: max - Math.floor(Math.random() * range * randomness), }; } 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): Array<{ name: string; artists?: { name: string }[]; albumName?: string; memberScores?: { userId: string; score: number }[]; }> { return analytics?.storyClusters?.[0]?.tracks ?? []; } 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 => 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(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 { 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 fallbackPlayerNames(count: number): string[] { return Array.from( { length: count }, (_, index) => `Player ${String.fromCharCode(65 + index)}`, ); }