400 lines
11 KiB
TypeScript
400 lines
11 KiB
TypeScript
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<T extends QuestionLike = QuestionLike> = {
|
|
key: string;
|
|
subjectKey?: string;
|
|
question: T;
|
|
};
|
|
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 pickQuestionCandidate<T extends QuestionLike>(
|
|
candidates: QuestionCandidate<T>[],
|
|
history: QuizRound[],
|
|
index: number,
|
|
): T | null {
|
|
const seenKeys = new Set<string>();
|
|
const seenSubjects = new Set<string>();
|
|
const seenTexts = new Set<string>();
|
|
|
|
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<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 => 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<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 | 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;
|
|
}
|