164 lines
4.2 KiB
TypeScript
164 lines
4.2 KiB
TypeScript
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 }[];
|
|
}[];
|
|
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<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 } {
|
|
return {
|
|
min: Math.floor(correctValue - tolerance),
|
|
max: Math.ceil(correctValue + tolerance),
|
|
};
|
|
}
|
|
|
|
export function getTopGenreName(analytics: PartyAnalytics): string {
|
|
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
|
|
}
|
|
|
|
export function getTopArtistName(analytics: PartyAnalytics): string {
|
|
return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A";
|
|
}
|
|
|
|
export function getTopTrackName(analytics: PartyAnalytics): string {
|
|
return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A";
|
|
}
|
|
|
|
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 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)}`,
|
|
);
|
|
}
|