Compare commits
No commits in common. "c14b7a7308a00d65c3de12fca002ee318d6bb3c6" and "93861bb7ce90990a4e0e40b15760a9e763b989bb" have entirely different histories.
c14b7a7308
...
93861bb7ce
12 changed files with 69 additions and 253 deletions
|
|
@ -1,12 +1,11 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
import type { InferSelectModel } from "drizzle-orm";
|
||||||
import type { party, partyMember, track, user } from "./db/schema";
|
import type { party, partyMember, user } from "./db/schema";
|
||||||
|
|
||||||
export type Party = Omit<InferSelectModel<typeof party>, "data"> & {
|
export type Party = Omit<InferSelectModel<typeof party>, "data"> & {
|
||||||
data: QuizState;
|
data: QuizState;
|
||||||
};
|
};
|
||||||
export type PartyMember = InferSelectModel<typeof partyMember>;
|
export type PartyMember = InferSelectModel<typeof partyMember>;
|
||||||
export type User = InferSelectModel<typeof user>;
|
export type User = InferSelectModel<typeof user>;
|
||||||
export type Song = InferSelectModel<typeof track>;
|
|
||||||
|
|
||||||
export type PartyMemberWithUser = PartyMember & { user: User | null };
|
export type PartyMemberWithUser = PartyMember & { user: User | null };
|
||||||
|
|
||||||
|
|
@ -25,7 +24,6 @@ export type PartyState = {
|
||||||
|
|
||||||
export type PartySocketOutgoing =
|
export type PartySocketOutgoing =
|
||||||
| { type: "ping" }
|
| { type: "ping" }
|
||||||
| { type: "subscribe_party"; partyId: string }
|
|
||||||
| { type: "member_payload"; payload: unknown };
|
| { type: "member_payload"; payload: unknown };
|
||||||
|
|
||||||
type BaseQuestion = {
|
type BaseQuestion = {
|
||||||
|
|
@ -34,7 +32,6 @@ type BaseQuestion = {
|
||||||
startTimestamp: number;
|
startTimestamp: number;
|
||||||
endTimestamp: number;
|
endTimestamp: number;
|
||||||
points: number;
|
points: number;
|
||||||
song?: Song;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type Question =
|
export type Question =
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { db as Db } from "../db";
|
|
||||||
import type { Question } from "../party-types";
|
import type { Question } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildOptionsWithCorrect,
|
buildOptionsWithCorrect,
|
||||||
|
|
@ -9,19 +8,16 @@ import {
|
||||||
getTopClusterTracks,
|
getTopClusterTracks,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
pickRandom,
|
pickRandom,
|
||||||
resolveQuestionSong,
|
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
export async function buildAudioMetadataQuestion(
|
export function buildAudioMetadataQuestion(
|
||||||
dbClient: typeof Db,
|
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
index: number,
|
index: number,
|
||||||
): Promise<Question | null> {
|
): Question {
|
||||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||||
const questions: Array<
|
const questions: Array<
|
||||||
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||||
> = [];
|
> = [];
|
||||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
|
||||||
|
|
||||||
const genreOptions = buildOrderedOptions(
|
const genreOptions = buildOrderedOptions(
|
||||||
getMostSharedGenreNames(analytics),
|
getMostSharedGenreNames(analytics),
|
||||||
|
|
@ -34,7 +30,6 @@ export async function buildAudioMetadataQuestion(
|
||||||
options: genreOptions,
|
options: genreOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -49,7 +44,6 @@ export async function buildAudioMetadataQuestion(
|
||||||
options: artistOptions,
|
options: artistOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -68,18 +62,12 @@ export async function buildAudioMetadataQuestion(
|
||||||
options: trackOptions,
|
options: trackOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const randomTopTrack = pickRandom(topTracks);
|
const randomTopTrack = pickRandom(topTracks);
|
||||||
if (randomTopTrack) {
|
if (randomTopTrack) {
|
||||||
const randomTrackSong = await resolveQuestionSong(dbClient, analytics, {
|
|
||||||
trackName: randomTopTrack.name,
|
|
||||||
artistNames: randomTopTrack.artists?.map((artist) => artist.name),
|
|
||||||
albumName: randomTopTrack.albumName,
|
|
||||||
});
|
|
||||||
const trackArtists =
|
const trackArtists =
|
||||||
randomTopTrack.artists?.map((artist) => artist.name) ?? [];
|
randomTopTrack.artists?.map((artist) => artist.name) ?? [];
|
||||||
const allArtists = topArtists.length > 0 ? topArtists : trackArtists;
|
const allArtists = topArtists.length > 0 ? topArtists : trackArtists;
|
||||||
|
|
@ -97,7 +85,6 @@ export async function buildAudioMetadataQuestion(
|
||||||
options: artistOptions,
|
options: artistOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -114,7 +101,6 @@ export async function buildAudioMetadataQuestion(
|
||||||
options: trackNameOptions,
|
options: trackNameOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -135,14 +121,13 @@ export async function buildAudioMetadataQuestion(
|
||||||
options: albumOptions,
|
options: albumOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
return null;
|
throw new Error("Question not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = questions[index % questions.length];
|
const question = questions[index % questions.length];
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
getQuestionRange,
|
getQuestionRange,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
resolveQuestionSong,
|
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
type NumericQuestion = Omit<
|
type NumericQuestion = Omit<
|
||||||
|
|
@ -37,9 +36,6 @@ async function getAlbumReleaseYear({
|
||||||
with: { album: true },
|
with: { album: true },
|
||||||
})
|
})
|
||||||
: null;
|
: null;
|
||||||
const song = await resolveQuestionSong(db, analytics, {
|
|
||||||
trackName: track?.name ?? trackName ?? undefined,
|
|
||||||
});
|
|
||||||
const correct =
|
const correct =
|
||||||
track?.album?.release_date?.getFullYear() ??
|
track?.album?.release_date?.getFullYear() ??
|
||||||
new Date().getFullYear() - 1 - index;
|
new Date().getFullYear() - 1 - index;
|
||||||
|
|
@ -50,7 +46,6 @@ async function getAlbumReleaseYear({
|
||||||
correct,
|
correct,
|
||||||
range: getQuestionRange(correct, 10, 3),
|
range: getQuestionRange(correct, 10, 3),
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -65,7 +60,6 @@ async function countTopTrackListeners({
|
||||||
where: { name: trackName },
|
where: { name: trackName },
|
||||||
});
|
});
|
||||||
if (!dbTrack) return null;
|
if (!dbTrack) return null;
|
||||||
const song = await resolveQuestionSong(db, analytics, { trackName });
|
|
||||||
const memberIds = members.map((m) => m.userId);
|
const memberIds = members.map((m) => m.userId);
|
||||||
const entries = await db
|
const entries = await db
|
||||||
.select({ userId: topTrackTable.userId })
|
.select({ userId: topTrackTable.userId })
|
||||||
|
|
@ -83,7 +77,6 @@ async function countTopTrackListeners({
|
||||||
correct,
|
correct,
|
||||||
range: { min: 0, max: members.length },
|
range: { min: 0, max: members.length },
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -98,9 +91,6 @@ async function countFavouriteArtistListeners({
|
||||||
where: { name: artistName },
|
where: { name: artistName },
|
||||||
});
|
});
|
||||||
if (!dbArtist) return null;
|
if (!dbArtist) return null;
|
||||||
const song = await resolveQuestionSong(db, analytics, {
|
|
||||||
artistNames: [artistName],
|
|
||||||
});
|
|
||||||
const memberIds = members.map((m) => m.userId);
|
const memberIds = members.map((m) => m.userId);
|
||||||
const entries = await db
|
const entries = await db
|
||||||
.select({ userId: topArtistTable.userId })
|
.select({ userId: topArtistTable.userId })
|
||||||
|
|
@ -118,13 +108,12 @@ async function countFavouriteArtistListeners({
|
||||||
correct,
|
correct,
|
||||||
range: { min: 0, max: members.length },
|
range: { min: 0, max: members.length },
|
||||||
points: 10,
|
points: 10,
|
||||||
song: song ?? undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function buildNumericQuestion(
|
export async function buildNumericQuestion(
|
||||||
input: BuildNumericQuestionInput,
|
input: BuildNumericQuestionInput,
|
||||||
): Promise<Question | null> {
|
): Promise<Question> {
|
||||||
const questions: NumericQuestion[] = [];
|
const questions: NumericQuestion[] = [];
|
||||||
|
|
||||||
questions.push(await getAlbumReleaseYear(input));
|
questions.push(await getAlbumReleaseYear(input));
|
||||||
|
|
@ -136,6 +125,6 @@ export async function buildNumericQuestion(
|
||||||
if (artistQ) questions.push(artistQ);
|
if (artistQ) questions.push(artistQ);
|
||||||
|
|
||||||
const question = questions[input.index % questions.length] ?? questions[0];
|
const question = questions[input.index % questions.length] ?? questions[0];
|
||||||
if (!question) return null;
|
if (!question) throw new Error("Question not found");
|
||||||
return buildQuestionWindow(question);
|
return buildQuestionWindow(question);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -22,23 +22,13 @@ export async function generatePartyQuestion({
|
||||||
quizState,
|
quizState,
|
||||||
analytics,
|
analytics,
|
||||||
index,
|
index,
|
||||||
}: GenerateQuestionInput): Promise<Question | null> {
|
}: GenerateQuestionInput): Promise<Question> {
|
||||||
const members = await fetchPartyMembers(dbClient, partyId);
|
const members = await fetchPartyMembers(dbClient, partyId);
|
||||||
const type: PartyQuestionType =
|
const type: PartyQuestionType =
|
||||||
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
|
index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
|
||||||
|
return type === "audio-metadata"
|
||||||
if (type === "audio-metadata") {
|
? buildAudioMetadataQuestion(analytics, index)
|
||||||
const q = await buildAudioMetadataQuestion(dbClient, analytics, index);
|
: type === "numeric"
|
||||||
if (q) return q;
|
? buildNumericQuestion({ db: dbClient, analytics, index, members })
|
||||||
}
|
: buildSocialQuestion(quizState, analytics, members, index);
|
||||||
if (type === "numeric") {
|
|
||||||
const q = await buildNumericQuestion({
|
|
||||||
db: dbClient,
|
|
||||||
analytics,
|
|
||||||
index,
|
|
||||||
members,
|
|
||||||
});
|
|
||||||
if (q) return q;
|
|
||||||
}
|
|
||||||
return buildSocialQuestion(dbClient, quizState, analytics, members, index);
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import type { InferSelectModel } from "drizzle-orm";
|
|
||||||
import type { db as Db } from "../db";
|
import type { db as Db } from "../db";
|
||||||
import type { track as trackTable } from "../db/schema";
|
|
||||||
|
|
||||||
export type PartyQuestionMember = {
|
export type PartyQuestionMember = {
|
||||||
userId: string;
|
userId: string;
|
||||||
|
|
@ -25,14 +23,6 @@ export type PartyAnalytics = {
|
||||||
pairwise?: { userIdA: string; userIdB: string }[];
|
pairwise?: { userIdA: string; userIdB: string }[];
|
||||||
} | null;
|
} | 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 QUESTION_DURATION_MS = 60_000;
|
||||||
export const MIN_PARTY_SIZE = 2;
|
export const MIN_PARTY_SIZE = 2;
|
||||||
export const MAX_PARTY_SIZE = 4;
|
export const MAX_PARTY_SIZE = 4;
|
||||||
|
|
@ -110,80 +100,15 @@ export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterTracks(
|
export function getTopClusterTracks(analytics: PartyAnalytics): Array<{
|
||||||
analytics: PartyAnalytics,
|
name: string;
|
||||||
): AnalyticsTrack[] {
|
artists?: { name: string }[];
|
||||||
|
albumName?: string;
|
||||||
|
memberScores?: { userId: string; score: number }[];
|
||||||
|
}> {
|
||||||
return analytics?.storyClusters?.[0]?.tracks ?? [];
|
return analytics?.storyClusters?.[0]?.tracks ?? [];
|
||||||
}
|
}
|
||||||
|
|
||||||
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 },
|
|
||||||
});
|
|
||||||
if (tracks.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 = tracks
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => scoreTrack(b) - scoreTrack(a))[0];
|
|
||||||
if (!chosen) return null;
|
|
||||||
const { album: _album, artists: _artists, ...song } = chosen;
|
|
||||||
return song;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTopTrackListener(
|
export function getTopTrackListener(
|
||||||
track: { memberScores?: { userId: string; score: number }[] },
|
track: { memberScores?: { userId: string; score: number }[] },
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import type { db as Db } from "../db";
|
|
||||||
import type { Question, QuizState } from "../party-types";
|
import type { Question, QuizState } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildMemberOptions,
|
buildMemberOptions,
|
||||||
|
|
@ -13,21 +12,18 @@ import {
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickRandom,
|
pickRandom,
|
||||||
resolveQuestionSong,
|
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
export async function buildSocialQuestion(
|
export function buildSocialQuestion(
|
||||||
dbClient: typeof Db,
|
|
||||||
quizState: QuizState,
|
quizState: QuizState,
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
index: number,
|
index: number,
|
||||||
): Promise<Question | null> {
|
): Question {
|
||||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||||
const questions: Array<
|
const questions: Array<
|
||||||
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
||||||
> = [];
|
> = [];
|
||||||
const topSong = await resolveQuestionSong(dbClient, analytics);
|
|
||||||
|
|
||||||
const hasMultipleMembers = members.length >= 2;
|
const hasMultipleMembers = members.length >= 2;
|
||||||
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
||||||
|
|
@ -38,7 +34,6 @@ export async function buildSocialQuestion(
|
||||||
options: buildMemberOptions(leader, members),
|
options: buildMemberOptions(leader, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -50,7 +45,6 @@ export async function buildSocialQuestion(
|
||||||
options: buildMemberOptions(diverse, members),
|
options: buildMemberOptions(diverse, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const aligned = getMostAlignedMember(analytics, members);
|
const aligned = getMostAlignedMember(analytics, members);
|
||||||
|
|
@ -60,7 +54,6 @@ export async function buildSocialQuestion(
|
||||||
options: buildMemberOptions(aligned, members),
|
options: buildMemberOptions(aligned, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -69,18 +62,12 @@ export async function buildSocialQuestion(
|
||||||
if (randomTrack && hasMultipleMembers) {
|
if (randomTrack && hasMultipleMembers) {
|
||||||
const topListener = getTopTrackListener(randomTrack, members);
|
const topListener = getTopTrackListener(randomTrack, members);
|
||||||
if (topListener) {
|
if (topListener) {
|
||||||
const randomTrackSong = await resolveQuestionSong(dbClient, analytics, {
|
|
||||||
trackName: randomTrack.name,
|
|
||||||
artistNames: randomTrack.artists?.map((artist) => artist.name),
|
|
||||||
albumName: randomTrack.albumName,
|
|
||||||
});
|
|
||||||
questions.push({
|
questions.push({
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: `Who listens the most to "${randomTrack.name}"?`,
|
text: `Who listens the most to "${randomTrack.name}"?`,
|
||||||
options: buildMemberOptions(topListener, members),
|
options: buildMemberOptions(topListener, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
questions.push({
|
questions.push({
|
||||||
type: "choice",
|
type: "choice",
|
||||||
|
|
@ -88,7 +75,6 @@ export async function buildSocialQuestion(
|
||||||
options: buildMemberOptions(topListener, members),
|
options: buildMemberOptions(topListener, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: randomTrackSong ?? topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -107,14 +93,13 @@ export async function buildSocialQuestion(
|
||||||
options: pairOptions,
|
options: pairOptions,
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
song: topSong ?? undefined,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (questions.length === 0) {
|
if (questions.length === 0) {
|
||||||
return null;
|
throw new Error("Question not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
const question = questions[index % questions.length];
|
const question = questions[index % questions.length];
|
||||||
|
|
|
||||||
|
|
@ -29,40 +29,6 @@ export const pubsub = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
async function subscribeWsToParty(
|
|
||||||
ws: {
|
|
||||||
subscribe: (topic: string) => void;
|
|
||||||
unsubscribe: (topic: string) => void;
|
|
||||||
send: (message: string) => void;
|
|
||||||
},
|
|
||||||
userId: string,
|
|
||||||
) {
|
|
||||||
const membership = await getMemberRecord(db, userId);
|
|
||||||
if (!membership) return null;
|
|
||||||
|
|
||||||
const nextPartyId = membership.partyId;
|
|
||||||
const currentPartyId = socketPartyId.get(ws as object);
|
|
||||||
if (currentPartyId && currentPartyId !== nextPartyId) {
|
|
||||||
ws.unsubscribe(partyTopic(currentPartyId));
|
|
||||||
}
|
|
||||||
|
|
||||||
socketPartyId.set(ws as object, nextPartyId);
|
|
||||||
ws.subscribe(partyTopic(nextPartyId));
|
|
||||||
|
|
||||||
const snapshot = await getPartyStatus(nextPartyId);
|
|
||||||
if (snapshot) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "party_status",
|
|
||||||
party: snapshot.party,
|
|
||||||
members: snapshot.members,
|
|
||||||
} satisfies PartySocketEvent),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return nextPartyId;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function broadcastQuizState(
|
export async function broadcastQuizState(
|
||||||
ws: { publish: (topic: string, message: string) => void },
|
ws: { publish: (topic: string, message: string) => void },
|
||||||
partyId: string,
|
partyId: string,
|
||||||
|
|
@ -107,8 +73,8 @@ export const partySocketApp = new Elysia()
|
||||||
|
|
||||||
ws.subscribe(userTopic(user.id));
|
ws.subscribe(userTopic(user.id));
|
||||||
|
|
||||||
const subscribedPartyId = await subscribeWsToParty(ws, user.id);
|
const membership = await getMemberRecord(db, user.id);
|
||||||
if (!subscribedPartyId) {
|
if (!membership) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
|
|
@ -119,7 +85,21 @@ export const partySocketApp = new Elysia()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await broadcastQuizState(ws, subscribedPartyId);
|
socketPartyId.set(ws, membership.partyId);
|
||||||
|
ws.subscribe(partyTopic(membership.partyId));
|
||||||
|
|
||||||
|
const snapshot = await getPartyStatus(membership.partyId);
|
||||||
|
if (snapshot) {
|
||||||
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
type: "party_status",
|
||||||
|
party: snapshot.party,
|
||||||
|
members: snapshot.members,
|
||||||
|
} as PartySocketEvent),
|
||||||
|
);
|
||||||
|
|
||||||
|
await broadcastQuizState(ws, membership.partyId);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
message: async (ws, message) => {
|
message: async (ws, message) => {
|
||||||
const data = ws.data;
|
const data = ws.data;
|
||||||
|
|
@ -141,16 +121,6 @@ export const partySocketApp = new Elysia()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (parsed.type === "subscribe_party") {
|
|
||||||
const payload = parsed as {
|
|
||||||
type: "subscribe_party";
|
|
||||||
partyId: string;
|
|
||||||
};
|
|
||||||
if (typeof payload.partyId !== "string") return;
|
|
||||||
await subscribeWsToParty(ws, user.id);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.type !== "member_payload") return;
|
if (parsed.type !== "member_payload") return;
|
||||||
|
|
||||||
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
const MAX_MEMBER_PAYLOAD_SIZE = 8_000;
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ import { eq } from "drizzle-orm";
|
||||||
import Elysia, { t } from "elysia";
|
import Elysia, { t } from "elysia";
|
||||||
import { betterAuthElysia } from "../auth";
|
import { betterAuthElysia } from "../auth";
|
||||||
import { db } from "../db";
|
import { db } from "../db";
|
||||||
import { party } from "../db/schema";
|
import { party, partyMember } from "../db/schema";
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data";
|
import { getMemberRecord } from "../party-data";
|
||||||
import type { QuizState } from "../party-types";
|
import type { QuizState } from "../party-types";
|
||||||
import { QuizWorkflow, quizQueue } from "../workflows/quiz";
|
import { QuizWorkflow, quizQueue } from "../workflows/quiz";
|
||||||
import { pubsub } from "./party-socket";
|
import { pubsub } from "./party-socket";
|
||||||
|
|
@ -49,17 +49,22 @@ export const quizRoutes = new Elysia()
|
||||||
})
|
})
|
||||||
.where(eq(party.id, params.partyId));
|
.where(eq(party.id, params.partyId));
|
||||||
|
|
||||||
const status = await getPartyStatus(params.partyId);
|
const members = await db
|
||||||
if (status) {
|
.select({
|
||||||
|
id: partyMember.id,
|
||||||
|
userId: partyMember.userId,
|
||||||
|
})
|
||||||
|
.from(partyMember)
|
||||||
|
.where(eq(partyMember.partyId, params.partyId));
|
||||||
|
|
||||||
pubsub.publish(
|
pubsub.publish(
|
||||||
`party:${params.partyId}`,
|
`party:${params.partyId}`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "party_status",
|
type: "party_status",
|
||||||
party: status.party,
|
party: { status: "started" },
|
||||||
members: status.members,
|
members,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: "Quiz started",
|
message: "Quiz started",
|
||||||
|
|
|
||||||
|
|
@ -155,17 +155,13 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics;
|
const analytics = (partyRecord?.analysisData ?? null) as PartyAnalytics;
|
||||||
const question = await generatePartyQuestion({
|
return generatePartyQuestion({
|
||||||
db,
|
db,
|
||||||
partyId,
|
partyId,
|
||||||
quizState,
|
quizState,
|
||||||
analytics,
|
analytics,
|
||||||
index,
|
index,
|
||||||
});
|
});
|
||||||
if (!question) {
|
|
||||||
throw new Error("Failed to generate quiz question");
|
|
||||||
}
|
|
||||||
return question;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static scoreRound(round: QuizRound): Array<[string, number]> {
|
private static scoreRound(round: QuizRound): Array<[string, number]> {
|
||||||
|
|
|
||||||
|
|
@ -137,7 +137,7 @@ pub async fn main_loop() {
|
||||||
println!("Main loop started");
|
println!("Main loop started");
|
||||||
let mut last = (String::new(), String::new());
|
let mut last = (String::new(), String::new());
|
||||||
loop {
|
loop {
|
||||||
embassy_time::Timer::after_millis(300).await;
|
embassy_time::Timer::after_millis(100).await;
|
||||||
let mut state = STATE.lock().await;
|
let mut state = STATE.lock().await;
|
||||||
state.tick();
|
state.tick();
|
||||||
let lines = state.render_lines();
|
let lines = state.render_lines();
|
||||||
|
|
@ -198,7 +198,7 @@ async fn main(spawner: Spawner) -> ! {
|
||||||
p.GPIO26.degrade(),
|
p.GPIO26.degrade(),
|
||||||
GpioInputConfig::default().with_pull(Pull::Up),
|
GpioInputConfig::default().with_pull(Pull::Up),
|
||||||
),
|
),
|
||||||
0,
|
1,
|
||||||
)
|
)
|
||||||
.expect("spawn btn1"),
|
.expect("spawn btn1"),
|
||||||
);
|
);
|
||||||
|
|
@ -208,7 +208,7 @@ async fn main(spawner: Spawner) -> ! {
|
||||||
p.GPIO25.degrade(),
|
p.GPIO25.degrade(),
|
||||||
GpioInputConfig::default().with_pull(Pull::Up),
|
GpioInputConfig::default().with_pull(Pull::Up),
|
||||||
),
|
),
|
||||||
1,
|
2,
|
||||||
)
|
)
|
||||||
.expect("spawn btn2"),
|
.expect("spawn btn2"),
|
||||||
);
|
);
|
||||||
|
|
@ -218,7 +218,7 @@ async fn main(spawner: Spawner) -> ! {
|
||||||
p.GPIO33.degrade(),
|
p.GPIO33.degrade(),
|
||||||
GpioInputConfig::default().with_pull(Pull::Up),
|
GpioInputConfig::default().with_pull(Pull::Up),
|
||||||
),
|
),
|
||||||
2,
|
3,
|
||||||
)
|
)
|
||||||
.expect("spawn btn3"),
|
.expect("spawn btn3"),
|
||||||
);
|
);
|
||||||
|
|
@ -228,7 +228,7 @@ async fn main(spawner: Spawner) -> ! {
|
||||||
p.GPIO32.degrade(),
|
p.GPIO32.degrade(),
|
||||||
GpioInputConfig::default().with_pull(Pull::Up),
|
GpioInputConfig::default().with_pull(Pull::Up),
|
||||||
),
|
),
|
||||||
3,
|
4,
|
||||||
)
|
)
|
||||||
.expect("spawn btn4"),
|
.expect("spawn btn4"),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,5 @@
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import type {
|
import type { PartySocketEvent } from "../../../api/src/party-types";
|
||||||
PartySocketEvent,
|
|
||||||
PartySocketOutgoing,
|
|
||||||
} from "../../../api/src/party-types";
|
|
||||||
|
|
||||||
type Handler = (event: PartySocketEvent) => void;
|
type Handler = (event: PartySocketEvent) => void;
|
||||||
|
|
||||||
|
|
@ -27,12 +24,6 @@ export function usePartySocket({
|
||||||
const reconnectAttemptRef = useRef(0);
|
const reconnectAttemptRef = useRef(0);
|
||||||
const handlerRef = useRef(onMessage);
|
const handlerRef = useRef(onMessage);
|
||||||
|
|
||||||
const send = useCallback((message: PartySocketOutgoing) => {
|
|
||||||
const ws = wsRef.current;
|
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
|
||||||
ws.send(JSON.stringify(message));
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
handlerRef.current = onMessage;
|
handlerRef.current = onMessage;
|
||||||
}, [onMessage]);
|
}, [onMessage]);
|
||||||
|
|
@ -51,6 +42,7 @@ export function usePartySocket({
|
||||||
|
|
||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
const parsed = JSON.parse(event.data) as PartySocketEvent;
|
||||||
|
console.log(parsed);
|
||||||
handlerRef.current?.(parsed);
|
handlerRef.current?.(parsed);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -128,9 +120,8 @@ export function usePartySocket({
|
||||||
isConnected: connectionState === "connected",
|
isConnected: connectionState === "connected",
|
||||||
isConnecting: connectionState === "connecting",
|
isConnecting: connectionState === "connecting",
|
||||||
isReconnecting: connectionState === "reconnecting",
|
isReconnecting: connectionState === "reconnecting",
|
||||||
send,
|
|
||||||
}),
|
}),
|
||||||
[connectionState, send],
|
[connectionState],
|
||||||
);
|
);
|
||||||
|
|
||||||
return state;
|
return state;
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
|
||||||
useState,
|
useState,
|
||||||
} from "react";
|
} from "react";
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -76,11 +75,6 @@ export function PartyProvider({ children }: { children: ReactNode }) {
|
||||||
if (!url) return null;
|
if (!url) return null;
|
||||||
return url;
|
return url;
|
||||||
}, []);
|
}, []);
|
||||||
const subscribedPartyIdRef = useRef<string | null>(null);
|
|
||||||
const wsState = usePartySocket({
|
|
||||||
apiUrl: userId ? apiUrl : null,
|
|
||||||
onMessage: userId ? handleMessage : null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const resetParty = useCallback(() => {
|
const resetParty = useCallback(() => {
|
||||||
setState(emptyPartyState);
|
setState(emptyPartyState);
|
||||||
|
|
@ -90,21 +84,10 @@ export function PartyProvider({ children }: { children: ReactNode }) {
|
||||||
if (!userId) resetParty();
|
if (!userId) resetParty();
|
||||||
}, [resetParty, userId]);
|
}, [resetParty, userId]);
|
||||||
|
|
||||||
useEffect(() => {
|
const wsState = usePartySocket({
|
||||||
if (wsState.connectionState !== "connected") {
|
apiUrl: userId ? apiUrl : null,
|
||||||
subscribedPartyIdRef.current = null;
|
onMessage: userId ? handleMessage : null,
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!userId || !state.party?.id) return;
|
|
||||||
if (subscribedPartyIdRef.current === state.party.id) return;
|
|
||||||
|
|
||||||
wsState.send({
|
|
||||||
type: "subscribe_party",
|
|
||||||
partyId: state.party.id,
|
|
||||||
});
|
});
|
||||||
subscribedPartyIdRef.current = state.party.id;
|
|
||||||
}, [state.party?.id, userId, wsState.connectionState, wsState.send]);
|
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue