Compare commits

..

No commits in common. "bfeb44a6258763fc93eaf1306fd819c7f2a621aa" and "8d2f86b3f82647a8e2a5f17542d253abf2f08208" have entirely different histories.

16 changed files with 274 additions and 836 deletions

View file

@ -16,9 +16,6 @@ export async function getPartyForUser(userId: string) {
where: { where: {
userId, userId,
}, },
orderBy: {
joinedAt: "desc",
},
with: { with: {
party: true, party: true,
}, },
@ -80,68 +77,23 @@ export async function cleanupPartyIfEmpty(dbClient: DbLike, partyId: string) {
await dbClient.delete(party).where(eq(party.id, partyId)); await dbClient.delete(party).where(eq(party.id, partyId));
} }
export type LeavePartyResult = { export async function leaveParty(dbClient: DbLike, userId: string) {
affectedPartyIds: string[]; const member = await getMemberRecord(dbClient, userId);
replacementPartyId: string | null; if (!member) return null;
}; await dbClient.delete(partyMember).where(eq(partyMember.id, member.id));
const nextHost = await dbClient.query.partyMember.findFirst({
export async function leaveParty(
dbClient: DbLike,
userId: string,
options: { createReplacementParty?: boolean } = {},
): Promise<LeavePartyResult | null> {
const memberships = await dbClient.query.partyMember.findMany({
where: { where: {
userId, partyId: member.partyId,
}, },
orderBy: { orderBy: {
joinedAt: "desc", joinedAt: "asc",
}, },
}); });
if (memberships.length === 0) { let newHostId: string | null = null;
if (!options.createReplacementParty) return null; if (nextHost) {
const created = await dbClient
.insert(party)
.values({
status: "created",
hostId: userId,
})
.returning({ id: party.id });
const replacementPartyId = created[0]?.id ?? null;
if (replacementPartyId) {
await dbClient.insert(partyMember).values({
partyId: replacementPartyId,
userId,
});
}
return {
affectedPartyIds: [],
replacementPartyId,
};
}
const affectedPartyIds = [
...new Set(memberships.map((member) => member.partyId)),
];
await dbClient.delete(partyMember).where(eq(partyMember.userId, userId));
for (const partyId of affectedPartyIds) {
const nextHost = await dbClient.query.partyMember.findFirst({
where: {
partyId,
},
orderBy: {
joinedAt: "asc",
},
});
if (!nextHost) {
await cleanupPartyIfEmpty(dbClient, partyId);
continue;
}
const currentParty = await dbClient.query.party.findFirst({ const currentParty = await dbClient.query.party.findFirst({
where: { where: {
id: partyId, id: member.partyId,
}, },
}); });
if (currentParty?.hostId === userId) { if (currentParty?.hostId === userId) {
@ -151,30 +103,13 @@ export async function leaveParty(
hostId: nextHost.userId, hostId: nextHost.userId,
lastUpdated: new Date(), lastUpdated: new Date(),
}) })
.where(eq(party.id, partyId)); .where(eq(party.id, member.partyId));
newHostId = nextHost.userId;
} }
} }
await cleanupPartyIfEmpty(dbClient, member.partyId);
let replacementPartyId: string | null = null;
if (options.createReplacementParty) {
const created = await dbClient
.insert(party)
.values({
status: "created",
hostId: userId,
})
.returning({ id: party.id });
replacementPartyId = created[0]?.id ?? null;
if (replacementPartyId) {
await dbClient.insert(partyMember).values({
partyId: replacementPartyId,
userId,
});
}
}
return { return {
affectedPartyIds, partyId: member.partyId,
replacementPartyId, newHostId,
}; };
} }

View file

@ -36,8 +36,6 @@ type BaseQuestion = {
points: number; points: number;
song?: Song; song?: Song;
hideSongTitle?: boolean; hideSongTitle?: boolean;
questionKey?: string;
subjectKey?: string;
}; };
export type Question = export type Question =

View file

@ -1,39 +0,0 @@
import { eq } from "drizzle-orm";
import { describe, expect, it } from "vitest";
import { db } from "../../db";
import { partyMember } from "../../db/schema";
import { getPartyForUser, leaveParty } from "../../party-data";
import { createParty, createUser, joinParty } from "../../test/factories";
describe("party data lifecycle", () => {
it("moves a leaving user to a fresh party and clears stale memberships", async () => {
const user = await createUser("Leave Tester");
const otherA = await createUser("Other A");
const otherB = await createUser("Other B");
const firstParty = await createParty(otherA.id);
const secondParty = await createParty(otherB.id);
await joinParty(firstParty.partyId, user.id);
await joinParty(secondParty.partyId, user.id);
const result = await leaveParty(db, user.id, {
createReplacementParty: true,
});
expect(result?.affectedPartyIds).toEqual(
expect.arrayContaining([firstParty.partyId, secondParty.partyId]),
);
expect(result?.replacementPartyId).toBeTruthy();
const memberships = await db
.select({ id: partyMember.id, partyId: partyMember.partyId })
.from(partyMember)
.where(eq(partyMember.userId, user.id));
expect(memberships).toHaveLength(1);
expect(memberships[0]?.partyId).toBe(result?.replacementPartyId);
const currentParty = await getPartyForUser(user.id);
expect(currentParty?.id).toBe(result?.replacementPartyId);
});
});

View file

@ -1,208 +0,0 @@
import { describe, expect, it, vi } from "vitest";
import type { Question, QuizRound } from "../../party-types";
import { buildNumericQuestion } from "../numeric-question-generator";
import {
buildMemberOptions,
type PartyAnalytics,
type PartyQuestionMember,
pickQuestionCandidate,
} from "../question-utils";
import { buildSocialQuestion } from "../social-question-generator";
type Db = typeof import("../../db").db;
function makeChoiceQuestion(
text: string,
key: string,
subjectKey: string,
): Question {
return {
type: "choice",
text,
correct: 0,
startTimestamp: 1,
endTimestamp: 2,
points: 10,
options: ["A", "B"],
questionKey: key,
subjectKey,
};
}
function createFakeDb(trackReleaseDate: Date | null) {
const trackRecord = {
id: "track-1",
name: "Shared Track",
album: {
name: "Shared Album",
release_date: trackReleaseDate,
},
artists: [{ id: "artist-1", name: "Shared Artist" }],
};
return {
query: {
track: {
findFirst: vi.fn(async () => trackRecord),
findMany: vi.fn(async () => []),
},
artist: {
findFirst: vi.fn(async () => ({
id: "artist-1",
name: "Shared Artist",
})),
},
},
select: vi.fn(() => ({
from: () => ({
where: async () => [],
}),
})),
} as unknown as Db;
}
describe("question generation", () => {
it("skips repeated question keys, subjects, and text", () => {
const history: QuizRound[] = [
{
questionIndex: 0,
question: {
...makeChoiceQuestion(
"Which genre appears most in the party analytics?",
"audio:genre:pop",
"genre:pop",
),
},
responses: [],
},
];
const question = pickQuestionCandidate(
[
{
key: "audio:genre:pop",
subjectKey: "genre:pop",
question: makeChoiceQuestion(
"Which genre appears most in the party analytics?",
"audio:genre:pop",
"genre:pop",
),
},
{
key: "audio:artist:abba",
subjectKey: "artist:abba",
question: makeChoiceQuestion(
"Which artist shows up most often in the shared audio data?",
"audio:artist:abba",
"artist:abba",
),
},
],
history,
0,
);
expect(question?.text).toBe(
"Which artist shows up most often in the shared audio data?",
);
});
it("returns null when member options would require fake placeholders", () => {
const members: PartyQuestionMember[] = [
{ userId: "a", name: "Sam" },
{ userId: "b", name: "Sam" },
];
const correctMember = members[0];
expect(correctMember).toBeDefined();
if (correctMember) {
expect(buildMemberOptions(correctMember, members)).toBeNull();
}
});
it("skips numeric questions with missing release dates and zero counts", async () => {
const db = createFakeDb(null);
const analytics = {
storyClusters: [
{
tracks: [
{
name: "Shared Track",
artists: [{ name: "Shared Artist" }],
albumName: "Shared Album",
},
],
artists: [{ name: "Shared Artist" }],
genres: [],
},
],
groupSummary: {
mostSharedGenres: [],
},
} as PartyAnalytics;
const members: PartyQuestionMember[] = [
{ userId: "a", name: "A" },
{ userId: "b", name: "B" },
];
const history: QuizRound[] = [
{
questionIndex: 0,
question: {
type: "numeric",
text: "What's the release year of Shared Album?",
correct: 2010,
startTimestamp: 1,
endTimestamp: 2,
points: 10,
range: { min: 2000, max: 2010 },
questionKey: "numeric:album-year:Shared Album",
subjectKey: "album:Shared Album",
},
responses: [],
},
];
const question = await buildNumericQuestion({
db,
analytics,
index: 0,
members,
history,
});
expect(question).toBeNull();
});
it("skips social fallback names for duplicate members", async () => {
const db = createFakeDb(null);
const members: PartyQuestionMember[] = [
{ userId: "a", name: "Sam" },
{ userId: "b", name: "Sam" },
];
const quizState = {
status: "running" as const,
workflowId: null,
questionIndex: 0,
currentQuestion: null,
answers: {},
scores: { a: 3, b: 1 },
history: [],
};
const question = await buildSocialQuestion(
db,
quizState,
{
groupSummary: {
mostDiverseMember: { userId: "a", genreEntropy: 1 },
mostSharedGenres: [],
mostAlignedPair: null,
},
},
members,
0,
);
expect(question).toBeNull();
});
});

View file

@ -1,5 +1,5 @@
import type { db as Db } from "../db"; import type { db as Db } from "../db";
import type { Question, QuizRound } from "../party-types"; import type { Question } from "../party-types";
import { import {
buildOptionsWithCorrect, buildOptionsWithCorrect,
buildOrderedOptions, buildOrderedOptions,
@ -9,9 +9,7 @@ import {
getTopClusterTracks, getTopClusterTracks,
isUsableText, isUsableText,
type PartyAnalytics, type PartyAnalytics,
pickQuestionCandidate,
pickRandom, pickRandom,
type QuestionCandidate,
resolveQuestionSong, resolveQuestionSong,
} from "./question-utils"; } from "./question-utils";
@ -19,11 +17,10 @@ export async function buildAudioMetadataQuestion(
dbClient: typeof Db, dbClient: typeof Db,
analytics: PartyAnalytics, analytics: PartyAnalytics,
index: number, index: number,
history: QuizRound[],
): Promise<Question | null> { ): Promise<Question | null> {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array< const questions: Array<
QuestionCandidate<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
> = []; > = [];
const topSong = await resolveQuestionSong(dbClient, analytics); const topSong = await resolveQuestionSong(dbClient, analytics);
const topSongName = topSong?.name; const topSongName = topSong?.name;
@ -37,17 +34,13 @@ export async function buildAudioMetadataQuestion(
); );
if (currentSongOptions) { if (currentSongOptions) {
questions.push({ questions.push({
key: `audio:current-song:${topSongName}`, type: "choice",
subjectKey: `track:${topSongName}`, text: "What song is currently playing?",
question: { options: currentSongOptions,
type: "choice", correct: 0,
text: "What song is currently playing?", points: 10,
options: currentSongOptions, song: topSong ?? undefined,
correct: 0, hideSongTitle: true,
points: 10,
song: topSong ?? undefined,
hideSongTitle: true,
},
}); });
} }
} }
@ -57,21 +50,14 @@ export async function buildAudioMetadataQuestion(
4, 4,
); );
if (genreOptions) { if (genreOptions) {
const topGenre = genreOptions[0]; questions.push({
if (topGenre) { type: "choice",
questions.push({ text: "Which genre appears most in the party analytics?",
key: `audio:genre:${topGenre}`, options: genreOptions,
subjectKey: `genre:${topGenre}`, correct: 0,
question: { points: 10,
type: "choice", song: topSong ?? undefined,
text: "Which genre appears most in the party analytics?", });
options: genreOptions,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
});
}
} }
const topArtists = getTopClusterArtists(analytics); const topArtists = getTopClusterArtists(analytics);
@ -80,16 +66,12 @@ export async function buildAudioMetadataQuestion(
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4); const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
if (artistOptions) { if (artistOptions) {
questions.push({ questions.push({
key: `audio:artist:${topArtist}`, type: "choice",
subjectKey: `artist:${topArtist}`, text: "Which artist shows up most often in the shared audio data?",
question: { options: artistOptions,
type: "choice", correct: 0,
text: "Which artist shows up most often in the shared audio data?", points: 10,
options: artistOptions, song: topSong ?? undefined,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
}); });
} }
} }
@ -101,16 +83,12 @@ export async function buildAudioMetadataQuestion(
: null; : null;
if (trackOptions) { if (trackOptions) {
questions.push({ questions.push({
key: `audio:track:${topTrackName}`, type: "choice",
subjectKey: `track:${topTrackName}`, text: "Which track looks most shared across the party?",
question: { options: trackOptions,
type: "choice", correct: 0,
text: "Which track looks most shared across the party?", points: 10,
options: trackOptions, song: topSong ?? undefined,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
}); });
} }
} }
@ -134,16 +112,12 @@ export async function buildAudioMetadataQuestion(
); );
if (artistOptions) { if (artistOptions) {
questions.push({ questions.push({
key: `audio:performer:${randomTopTrack.name}`, type: "choice",
subjectKey: `track:${randomTopTrack.name}`, text: `Who performs "${randomTopTrack.name}"?`,
question: { options: artistOptions,
type: "choice", correct: 0,
text: `Who performs "${randomTopTrack.name}"?`, points: 10,
options: artistOptions, song: randomTrackSong ?? topSong ?? undefined,
correct: 0,
points: 10,
song: randomTrackSong ?? topSong ?? undefined,
},
}); });
} }
@ -155,16 +129,13 @@ export async function buildAudioMetadataQuestion(
); );
if (trackNameOptions) { if (trackNameOptions) {
questions.push({ questions.push({
key: `audio:title:${randomTopTrack.name}`, type: "choice",
subjectKey: `track:${randomTopTrack.name}`, text: `What is the name of this track by ${correctArtist}?`,
question: { options: trackNameOptions,
type: "choice", correct: 0,
text: `What is the name of this track by ${correctArtist}?`, points: 10,
options: trackNameOptions, song: randomTrackSong ?? topSong ?? undefined,
correct: 0, hideSongTitle: true,
points: 10,
song: randomTrackSong ?? topSong ?? undefined,
},
}); });
} }
@ -176,17 +147,13 @@ export async function buildAudioMetadataQuestion(
); );
if (alternateSongOptions) { if (alternateSongOptions) {
questions.push({ questions.push({
key: `audio:current-song:${topSongName}`, type: "choice",
subjectKey: `track:${topSongName}`, text: "Which song is this audio clip from?",
question: { options: alternateSongOptions,
type: "choice", correct: 0,
text: "Which song is this audio clip from?", points: 10,
options: alternateSongOptions, song: topSong ?? undefined,
correct: 0, hideSongTitle: true,
points: 10,
song: topSong ?? undefined,
hideSongTitle: true,
},
}); });
} }
} }
@ -203,16 +170,12 @@ export async function buildAudioMetadataQuestion(
); );
if (albumOptions) { if (albumOptions) {
questions.push({ questions.push({
key: `audio:album:${randomTopTrack.albumName}`, type: "choice",
subjectKey: `track:${randomTopTrack.name}`, text: `"${randomTopTrack.name}" appears on which album?`,
question: { options: albumOptions,
type: "choice", correct: 0,
text: `"${randomTopTrack.name}" appears on which album?`, points: 10,
options: albumOptions, song: randomTrackSong ?? topSong ?? undefined,
correct: 0,
points: 10,
song: randomTrackSong ?? topSong ?? undefined,
},
}); });
} }
} }
@ -222,7 +185,7 @@ export async function buildAudioMetadataQuestion(
return null; return null;
} }
const question = pickQuestionCandidate(questions, history, index); const question = questions[index % questions.length];
if (!question) return null; if (!question) throw new Error("Question not found");
return buildQuestionWindow(question); return buildQuestionWindow(question);
} }

View file

@ -4,15 +4,13 @@ import {
topArtist as topArtistTable, topArtist as topArtistTable,
topTrack as topTrackTable, topTrack as topTrackTable,
} from "../db/schema"; } from "../db/schema";
import type { Question, QuizRound } from "../party-types"; import type { Question } from "../party-types";
import { import {
buildQuestionWindow, buildQuestionWindow,
getReleaseYearRange, getReleaseYearRange,
isUsableText, isUsableText,
type PartyAnalytics, type PartyAnalytics,
type PartyQuestionMember, type PartyQuestionMember,
pickQuestionCandidate,
type QuestionCandidate,
resolveQuestionSong, resolveQuestionSong,
} from "./question-utils"; } from "./question-utils";
@ -26,12 +24,12 @@ type BuildNumericQuestionInput = {
analytics: PartyAnalytics; analytics: PartyAnalytics;
index: number; index: number;
members: PartyQuestionMember[]; members: PartyQuestionMember[];
history: QuizRound[];
}; };
async function getAlbumReleaseYear({ async function getAlbumReleaseYear({
db, db,
analytics, analytics,
index,
}: BuildNumericQuestionInput): Promise<NumericQuestion | null> { }: BuildNumericQuestionInput): Promise<NumericQuestion | null> {
const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name; const trackName = analytics?.storyClusters?.[0]?.tracks?.[0]?.name;
const track = trackName const track = trackName
@ -40,15 +38,16 @@ async function getAlbumReleaseYear({
with: { album: true }, with: { album: true },
}) })
: null; : null;
const song = await resolveQuestionSong(db, analytics, {
trackName: track?.name ?? trackName ?? undefined,
});
const subject = [track?.album?.name, track?.name].find((value) => const subject = [track?.album?.name, track?.name].find((value) =>
isUsableText(value), isUsableText(value),
); );
if (!subject) return null; if (!subject) return null;
if (!track?.album?.release_date) return null; const correct =
const song = await resolveQuestionSong(db, analytics, { track?.album?.release_date?.getFullYear() ??
trackName: track?.name ?? trackName ?? undefined, new Date().getFullYear() - 1 - index;
});
const correct = track.album.release_date.getFullYear();
return { return {
type: "numeric", type: "numeric",
text: `What's the release year of ${subject}?`, text: `What's the release year of ${subject}?`,
@ -56,8 +55,6 @@ async function getAlbumReleaseYear({
range: getReleaseYearRange(correct), range: getReleaseYearRange(correct),
points: 10, points: 10,
song: song ?? undefined, song: song ?? undefined,
questionKey: `numeric:album-year:${subject}`,
subjectKey: `album:${subject}`,
}; };
} }
@ -84,7 +81,6 @@ async function countTopTrackListeners({
), ),
); );
const correct = new Set(entries.map((e) => e.userId)).size; const correct = new Set(entries.map((e) => e.userId)).size;
if (correct <= 0) return null;
return { return {
type: "numeric", type: "numeric",
text: `For how many players in the party is "${trackName}" a top track?`, text: `For how many players in the party is "${trackName}" a top track?`,
@ -92,8 +88,6 @@ async function countTopTrackListeners({
range: { min: 0, max: members.length }, range: { min: 0, max: members.length },
points: 10, points: 10,
song: song ?? undefined, song: song ?? undefined,
questionKey: `numeric:top-track-count:${trackName}`,
subjectKey: `track:${trackName}`,
}; };
} }
@ -122,7 +116,6 @@ async function countFavouriteArtistListeners({
), ),
); );
const correct = new Set(entries.map((e) => e.userId)).size; const correct = new Set(entries.map((e) => e.userId)).size;
if (correct <= 0) return null;
return { return {
type: "numeric", type: "numeric",
text: `How many players in the party have "${artistName}" as a favourite artist?`, text: `How many players in the party have "${artistName}" as a favourite artist?`,
@ -130,44 +123,24 @@ async function countFavouriteArtistListeners({
range: { min: 0, max: members.length }, range: { min: 0, max: members.length },
points: 10, points: 10,
song: song ?? undefined, song: song ?? undefined,
questionKey: `numeric:artist-count:${artistName}`,
subjectKey: `artist:${artistName}`,
}; };
} }
export async function buildNumericQuestion( export async function buildNumericQuestion(
input: BuildNumericQuestionInput, input: BuildNumericQuestionInput,
): Promise<Question | null> { ): Promise<Question | null> {
const questions: Array<QuestionCandidate<NumericQuestion>> = []; const questions: NumericQuestion[] = [];
const albumYearQ = await getAlbumReleaseYear(input); const albumYearQ = await getAlbumReleaseYear(input);
if (albumYearQ) { if (albumYearQ) questions.push(albumYearQ);
questions.push({
key: albumYearQ.questionKey ?? `numeric:album-year:${albumYearQ.text}`,
subjectKey: albumYearQ.subjectKey,
question: albumYearQ,
});
}
const topTrackQ = await countTopTrackListeners(input); const topTrackQ = await countTopTrackListeners(input);
if (topTrackQ) { if (topTrackQ) questions.push(topTrackQ);
questions.push({
key: topTrackQ.questionKey ?? `numeric:top-track-count:${topTrackQ.text}`,
subjectKey: topTrackQ.subjectKey,
question: topTrackQ,
});
}
const artistQ = await countFavouriteArtistListeners(input); const artistQ = await countFavouriteArtistListeners(input);
if (artistQ) { if (artistQ) questions.push(artistQ);
questions.push({
key: artistQ.questionKey ?? `numeric:artist-count:${artistQ.text}`,
subjectKey: artistQ.subjectKey,
question: artistQ,
});
}
const question = pickQuestionCandidate(questions, input.history, input.index); const question = questions[input.index % questions.length] ?? questions[0];
if (!question) return null; if (!question) return null;
return buildQuestionWindow(question); return buildQuestionWindow(question);
} }

View file

@ -3,7 +3,7 @@ import type { Question, QuizState } from "../party-types";
import { buildAudioMetadataQuestion } from "./audio-question-generator"; import { buildAudioMetadataQuestion } from "./audio-question-generator";
import { buildNumericQuestion } from "./numeric-question-generator"; import { buildNumericQuestion } from "./numeric-question-generator";
import type { PartyAnalytics } from "./question-utils"; import type { PartyAnalytics } from "./question-utils";
import { buildQuestionWindow, fetchPartyMembers } from "./question-utils"; import { fetchPartyMembers } from "./question-utils";
import { buildSocialQuestion } from "./social-question-generator"; import { buildSocialQuestion } from "./social-question-generator";
export type PartyQuestionType = "audio-metadata" | "social" | "numeric"; export type PartyQuestionType = "audio-metadata" | "social" | "numeric";
@ -24,58 +24,21 @@ export async function generatePartyQuestion({
index, index,
}: GenerateQuestionInput): Promise<Question | null> { }: GenerateQuestionInput): Promise<Question | null> {
const members = await fetchPartyMembers(dbClient, partyId); const members = await fetchPartyMembers(dbClient, partyId);
const preferredOrder: PartyQuestionType[] = [ const type: PartyQuestionType =
"audio-metadata", index === 2 ? "numeric" : index % 2 === 0 ? "audio-metadata" : "social";
"social",
"numeric",
];
const rotation = index % preferredOrder.length;
const typeOrder = [
...preferredOrder.slice(rotation),
...preferredOrder.slice(0, rotation),
];
for (const type of typeOrder) {
if (type === "audio-metadata") {
const q = await buildAudioMetadataQuestion(
dbClient,
analytics,
index,
quizState.history,
);
if (q) return q;
continue;
}
if (type === "social") {
const q = await buildSocialQuestion(
dbClient,
quizState,
analytics,
members,
index,
);
if (q) return q;
continue;
}
if (type === "audio-metadata") {
const q = await buildAudioMetadataQuestion(dbClient, analytics, index);
if (q) return q;
}
if (type === "numeric") {
const q = await buildNumericQuestion({ const q = await buildNumericQuestion({
db: dbClient, db: dbClient,
analytics, analytics,
index, index,
members, members,
history: quizState.history,
}); });
if (q) return q; if (q) return q;
} }
return buildSocialQuestion(dbClient, quizState, analytics, members, index);
return buildQuestionWindow({
type: "numeric" as const,
text: "How many players are in this party?",
correct: members.length,
range: { min: 0, max: members.length },
points: 5,
subjectKey: "party-size",
questionKey: `fallback:party-size:${members.length}`,
});
} }

View file

@ -1,7 +1,6 @@
import type { InferSelectModel } from "drizzle-orm"; 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"; import type { track as trackTable } from "../db/schema";
import type { QuizRound } from "../party-types";
export type PartyQuestionMember = { export type PartyQuestionMember = {
userId: string; userId: string;
@ -10,14 +9,7 @@ export type PartyQuestionMember = {
export type PartyAnalytics = { export type PartyAnalytics = {
groupSummary?: { groupSummary?: {
totalMembers?: number;
mostSharedGenres?: { name: string }[]; mostSharedGenres?: { name: string }[];
mostDiverseMember?: { userId: string; genreEntropy: number } | null;
mostAlignedPair?: {
userIdA: string;
userIdB: string;
similarity?: number;
} | null;
}; };
storyClusters?: { storyClusters?: {
tracks?: { tracks?: {
@ -39,17 +31,6 @@ export type AnalyticsTrack = {
albumName?: string; albumName?: string;
memberScores?: { userId: string; score: number }[]; 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 type QuestionSong = InferSelectModel<typeof trackTable>;
export const QUESTION_DURATION_MS = 60_000; export const QUESTION_DURATION_MS = 60_000;
@ -132,46 +113,6 @@ export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
); );
} }
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[] { export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
return (analytics?.storyClusters?.[0]?.artists ?? []).map( return (analytics?.storyClusters?.[0]?.artists ?? []).map(
(artist) => artist.name, (artist) => artist.name,
@ -279,7 +220,7 @@ export function buildOrderedOptions(
desiredCount: number, desiredCount: number,
): string[] | null { ): string[] | null {
const options = uniqueStrings( const options = uniqueStrings(
values.filter((value): value is string => isUsableText(value)), values.filter((value): value is string => Boolean(value)),
); );
return options.length >= desiredCount ? options.slice(0, desiredCount) : null; return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
} }
@ -289,10 +230,9 @@ export function buildOptionsWithCorrect(
candidates: string[], candidates: string[],
desiredCount: number, desiredCount: number,
): string[] | null { ): string[] | null {
if (!isUsableText(correct)) return null;
const options = uniqueStrings([ const options = uniqueStrings([
correct, correct,
...candidates.filter((c) => isUsableText(c) && c !== correct), ...candidates.filter((c) => c !== correct),
]); ]);
return options.length >= desiredCount ? options.slice(0, desiredCount) : null; return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
} }
@ -331,33 +271,43 @@ export function hasClearLeader(quizState: {
export function getMostDiverseMember( export function getMostDiverseMember(
analytics: PartyAnalytics, analytics: PartyAnalytics,
members: PartyQuestionMember[], members: PartyQuestionMember[],
): PartyQuestionMember | null { ): PartyQuestionMember {
const userId = analytics?.groupSummary?.mostDiverseMember?.userId; const userId = analytics?.memberProfiles?.[0]?.userId;
if (!userId) return null; return (
return members.find((member) => member.userId === userId) ?? null; members.find((member) => member.userId === userId) ??
members[1] ??
members[0] ?? { userId: "", name: "Player B" }
);
} }
export function getMostAlignedMember( export function getMostAlignedMember(
analytics: PartyAnalytics, analytics: PartyAnalytics,
members: PartyQuestionMember[], members: PartyQuestionMember[],
): PartyQuestionMember | null { ): PartyQuestionMember {
const userId = analytics?.groupSummary?.mostAlignedPair?.userIdA; const userId = analytics?.pairwise?.[0]?.userIdA;
if (!userId) return null; return (
return members.find((member) => member.userId === userId) ?? null; members.find((member) => member.userId === userId) ??
members[2] ??
members[0] ?? { userId: "", name: "Player C" }
);
} }
export function buildMemberOptions( export function buildMemberOptions(
correctMember: PartyQuestionMember, correctMember: PartyQuestionMember,
members: PartyQuestionMember[], members: PartyQuestionMember[],
): string[] | null { ): string[] {
const desiredCount = getPartySize(members.length); const desiredCount = getPartySize(members.length);
if (!isUsableText(correctMember.name)) return null;
const options = uniqueStrings([ const options = uniqueStrings([
correctMember.name, correctMember.name,
...members.map((member) => member.name).filter(isUsableText), ...members.map((member) => member.name),
]); ]);
if (options.length < desiredCount) return null; if (options.length < desiredCount) {
for (const fallback of fallbackPlayerNames(desiredCount)) {
if (options.length >= desiredCount) break;
if (!options.includes(fallback)) options.push(fallback);
}
}
const ordered = [ const ordered = [
correctMember.name, correctMember.name,
@ -378,23 +328,27 @@ function normalizeRange(
return { min: max, max: min }; return { min: max, max: min };
} }
function fallbackPlayerNames(count: number): string[] {
return Array.from(
{ length: count },
(_, index) => `Player ${String.fromCharCode(65 + index)}`,
);
}
export function buildMemberPairOptions( export function buildMemberPairOptions(
members: PartyQuestionMember[], members: PartyQuestionMember[],
correctPair: string, correctPair: string,
): string[] | null { ): string[] | null {
if (members.length < 3) return null; if (members.length < 3) return null;
if (!isUsableText(correctPair)) return null; const pairs: string[] = [correctPair];
const pairs = uniqueStrings([correctPair]);
for (let i = 0; i < members.length; i++) { for (let i = 0; i < members.length; i++) {
for (let j = i + 1; j < members.length; j++) { for (let j = i + 1; j < members.length; j++) {
const left = members[i]; const left = members[i];
const right = members[j]; const right = members[j];
if (!left || !right) continue; if (!left || !right) continue;
if (!isUsableText(left.name) || !isUsableText(right.name)) continue;
const pair = `${left.name} & ${right.name}`; const pair = `${left.name} & ${right.name}`;
if (pair !== correctPair) pairs.push(pair); if (pair !== correctPair) pairs.push(pair);
} }
} }
const deduped = uniqueStrings(pairs); return pairs.length >= 2 ? pairs.slice(0, 4) : null;
return deduped.length >= 2 ? deduped.slice(0, 4) : null;
} }

View file

@ -5,15 +5,14 @@ import {
buildMemberPairOptions, buildMemberPairOptions,
buildQuestionWindow, buildQuestionWindow,
getCurrentLeader, getCurrentLeader,
getMostAlignedMember,
getMostDiverseMember, getMostDiverseMember,
getTopClusterTracks, getTopClusterTracks,
getTopTrackListener, getTopTrackListener,
hasClearLeader, hasClearLeader,
type PartyAnalytics, type PartyAnalytics,
type PartyQuestionMember, type PartyQuestionMember,
pickQuestionCandidate,
pickRandom, pickRandom,
type QuestionCandidate,
resolveQuestionSong, resolveQuestionSong,
} from "./question-utils"; } from "./question-utils";
@ -26,49 +25,52 @@ export async function buildSocialQuestion(
): Promise<Question | null> { ): Promise<Question | null> {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array< const questions: Array<
QuestionCandidate<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
> = []; > = [];
const topSong = await resolveQuestionSong(dbClient, analytics); const topSong = await resolveQuestionSong(dbClient, analytics);
const hasMultipleMembers = members.length >= 2; const hasMultipleMembers = members.length >= 2;
if (hasMultipleMembers && hasClearLeader(quizState)) { if (hasMultipleMembers && hasClearLeader(quizState)) {
const leader = getCurrentLeader(quizState, members); const leader = getCurrentLeader(quizState, members);
const options = buildMemberOptions(leader, members); questions.push({
if (options) { type: "choice",
questions.push({ text: "Who is leading the quiz right now?",
key: "social:leader", options: buildMemberOptions(leader, members),
subjectKey: `member:${leader.userId}`, correct: 0,
question: { points: 10,
type: "choice", song: topSong ?? undefined,
text: "Who is leading the quiz right now?", });
options,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
});
}
} }
if (hasMultipleMembers) { if (hasMultipleMembers) {
const diverse = getMostDiverseMember(analytics, members); const diverse = getMostDiverseMember(analytics, members);
if (diverse) { questions.push({
const options = buildMemberOptions(diverse, members); type: "choice",
if (options) { text: "Who looks like the most diverse listener in the party?",
questions.push({ options: buildMemberOptions(diverse, members),
key: "social:diverse", correct: 0,
subjectKey: `member:${diverse.userId}`, points: 10,
question: { song: topSong ?? undefined,
type: "choice", });
text: "Who looks like the most diverse listener in the party?",
options, const aligned = getMostAlignedMember(analytics, members);
correct: 0, questions.push({
points: 10, type: "choice",
song: topSong ?? undefined, text: "Which member seems most aligned with the rest of the party?",
}, options: buildMemberOptions(aligned, members),
}); correct: 0,
} points: 10,
} song: topSong ?? undefined,
});
questions.push({
type: "choice",
text: "Who would you ask for a recommendation based on the party taste?",
options: buildMemberOptions(aligned, members),
correct: 0,
points: 10,
song: topSong ?? undefined,
});
} }
const topTracks = getTopClusterTracks(analytics); const topTracks = getTopClusterTracks(analytics);
@ -81,43 +83,32 @@ export async function buildSocialQuestion(
artistNames: randomTrack.artists?.map((artist) => artist.name), artistNames: randomTrack.artists?.map((artist) => artist.name),
albumName: randomTrack.albumName, albumName: randomTrack.albumName,
}); });
const options = buildMemberOptions(topListener, members); questions.push({
if (options) { type: "choice",
questions.push({ text: `Who is most likely to have "${randomTrack.name}" in heavy rotation?`,
key: `social:track-listener:${randomTrack.name}`, options: buildMemberOptions(topListener, members),
subjectKey: `track:${randomTrack.name}`, correct: 0,
question: { points: 10,
type: "choice", song: randomTrackSong ?? topSong ?? undefined,
text: `Who listens the most to "${randomTrack.name}"?`, });
options,
correct: 0,
points: 10,
song: randomTrackSong ?? topSong ?? undefined,
},
});
}
} }
} }
if (members.length >= 3 && analytics?.groupSummary?.mostAlignedPair) { if (members.length >= 3 && analytics?.pairwise?.length) {
const topPair = analytics.groupSummary.mostAlignedPair; const topPair = analytics.pairwise[0];
const memberA = members.find((m) => m.userId === topPair.userIdA); const memberA = members.find((m) => m.userId === topPair?.userIdA);
const memberB = members.find((m) => m.userId === topPair.userIdB); const memberB = members.find((m) => m.userId === topPair?.userIdB);
if (memberA && memberB) { if (memberA && memberB) {
const correctPair = `${memberA.name} & ${memberB.name}`; const correctPair = `${memberA.name} & ${memberB.name}`;
const pairOptions = buildMemberPairOptions(members, correctPair); const pairOptions = buildMemberPairOptions(members, correctPair);
if (pairOptions) { if (pairOptions) {
questions.push({ questions.push({
key: `social:pair:${memberA.userId}:${memberB.userId}`, type: "choice",
subjectKey: `pair:${[memberA.userId, memberB.userId].sort().join("|")}`, text: "Which two players would probably agree on the aux?",
question: { options: pairOptions,
type: "choice", correct: 0,
text: "Which two players share the most musical taste?", points: 10,
options: pairOptions, song: topSong ?? undefined,
correct: 0,
points: 10,
song: topSong ?? undefined,
},
}); });
} }
} }
@ -127,7 +118,7 @@ export async function buildSocialQuestion(
return null; return null;
} }
const question = pickQuestionCandidate(questions, quizState.history, index); const question = questions[index % questions.length];
if (!question) return null; if (!question) throw new Error("Question not found");
return buildQuestionWindow(question); return buildQuestionWindow(question);
} }

View file

@ -44,17 +44,6 @@ export async function updatePartyData(
}; };
for (const member of members) { for (const member of members) {
if (!member.userId) continue; if (!member.userId) continue;
pubsub.publish(
`user:${member.userId}`,
JSON.stringify({
type: "party_status",
party: {
...partyObject,
data,
},
members,
}),
);
void publishDeviceEventForUser(member.userId, event); void publishDeviceEventForUser(member.userId, event);
} }
await db await db

View file

@ -31,17 +31,6 @@ function broadcastToUser(userId: string, event: Record<string, unknown>) {
void publishDeviceEventForUser(userId, event as PartySocketEvent); void publishDeviceEventForUser(userId, event as PartySocketEvent);
} }
function broadcastStatusToMembers(snapshot: PartySnapshot | null) {
if (!snapshot) return;
for (const member of snapshot.members) {
broadcastToUser(member.userId, {
type: "party_status",
party: snapshot.party,
members: snapshot.members,
});
}
}
function isValidStatus( function isValidStatus(
status: string, status: string,
): status is import("../party-types").PartyStatus { ): status is import("../party-types").PartyStatus {
@ -76,80 +65,88 @@ export const partyApp = new Elysia()
return { error: "Target user not found." }; return { error: "Target user not found." };
} }
const { partyId, leaveResult } = await db.transaction(async (tx) => { const { partyId, hostChanged, leaveResult } = await db.transaction(
const leaveResult = await leaveParty(tx, user.id, { async (tx) => {
createReplacementParty: false, const leaveResult = await leaveParty(tx, user.id);
}); let partyId: string | null = null;
let partyId: string | null = null; let hostChanged = false;
const targetMembership = await getMemberRecord(tx, targetUserId); const targetMembership = await getMemberRecord(tx, targetUserId);
if (targetMembership) { if (targetMembership) {
partyId = targetMembership.partyId; partyId = targetMembership.partyId;
await tx await tx
.update(party) .update(party)
.set({ .set({
hostId: targetUserId, hostId: targetUserId,
lastUpdated: new Date(), lastUpdated: new Date(),
}) })
.where(eq(party.id, partyId)); .where(eq(party.id, partyId));
} else { hostChanged = true;
const created = await tx } else {
.insert(party) const created = await tx
.values({ .insert(party)
status: "created", .values({
hostId: targetUserId, status: "created",
}) hostId: targetUserId,
.returning({ id: party.id }); })
const createdId = created[0]?.id ?? null; .returning({ id: party.id });
if (!createdId) { const createdId = created[0]?.id ?? null;
if (!createdId) {
return {
partyId: null,
hostChanged,
leaveResult,
};
}
partyId = createdId;
await tx.insert(partyMember).values({
partyId,
userId: targetUserId,
});
}
if (!partyId) {
return { return {
partyId: null, partyId: null,
hostChanged,
leaveResult, leaveResult,
}; };
} }
partyId = createdId;
await tx.insert(partyMember).values({
partyId,
userId: targetUserId,
});
}
if (!partyId) { await tx
.insert(partyMember)
.values({ partyId, userId: user.id })
.onConflictDoNothing();
return { return {
partyId: null, partyId,
hostChanged,
leaveResult, leaveResult,
}; };
} },
);
await tx
.insert(partyMember)
.values({ partyId, userId: user.id })
.onConflictDoNothing();
return {
partyId,
leaveResult,
};
});
if (!partyId) return { party: null, members: [] }; if (!partyId) return { party: null, members: [] };
const leaveStatuses = await Promise.all(
(leaveResult?.affectedPartyIds ?? []).map(
async (affectedPartyId) => ({
partyId: affectedPartyId,
status: await getPartyStatus(affectedPartyId),
}),
),
);
for (const { partyId: affectedPartyId, status } of leaveStatuses) {
if (status) {
broadcastSnapshot(affectedPartyId, status);
broadcastStatusToMembers(status);
}
}
const status = await getPartyStatus(partyId); const status = await getPartyStatus(partyId);
if (leaveResult?.newHostId) {
broadcastSnapshot(leaveResult.partyId, status);
}
if (hostChanged) {
broadcastSnapshot(partyId, status);
}
broadcastSnapshot(partyId, status); broadcastSnapshot(partyId, status);
broadcastStatusToMembers(status); if (status) {
broadcastToUser(targetUserId, {
type: "party_status",
party: status.party,
members: status.members,
});
broadcastToUser(user.id, {
type: "party_status",
party: status.party,
members: status.members,
});
}
return status ?? { party: null, members: [] }; return status ?? { party: null, members: [] };
}, },
{ {
@ -163,30 +160,11 @@ export const partyApp = new Elysia()
"/leave", "/leave",
async ({ user }) => { async ({ user }) => {
const result = await db.transaction(async (tx) => { const result = await db.transaction(async (tx) => {
return await leaveParty(tx, user.id, { return await leaveParty(tx, user.id);
createReplacementParty: true,
});
}); });
if (!result) return { party: null, members: [] }; if (!result) return { party: null, members: [] };
const leaveStatuses = await Promise.all( const status = await getPartyStatus(result.partyId);
result.affectedPartyIds.map(async (affectedPartyId) => ({ broadcastSnapshot(result.partyId, status);
partyId: affectedPartyId,
status: await getPartyStatus(affectedPartyId),
})),
);
for (const { partyId: affectedPartyId, status } of leaveStatuses) {
if (status) {
broadcastSnapshot(affectedPartyId, status);
broadcastStatusToMembers(status);
}
}
const status = result.replacementPartyId
? await getPartyStatus(result.replacementPartyId)
: null;
if (result.replacementPartyId) {
broadcastSnapshot(result.replacementPartyId, status);
}
broadcastStatusToMembers(status);
return status ?? { party: null, members: [] }; return status ?? { party: null, members: [] };
}, },
{ auth: true }, { auth: true },

View file

@ -9,21 +9,6 @@ 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";
function broadcastStatusToMembers(
status: Awaited<ReturnType<typeof getPartyStatus>>,
) {
if (!status) return;
const payload = JSON.stringify({
type: "party_status",
party: status.party,
members: status.members,
});
pubsub.publish(`party:${status.party.id}`, payload);
for (const member of status.members) {
pubsub.publish(`user:${member.userId}`, payload);
}
}
const quizWf = new QuizWorkflow(); const quizWf = new QuizWorkflow();
export const quizRoutes = new Elysia() export const quizRoutes = new Elysia()
@ -60,13 +45,21 @@ export const quizRoutes = new Elysia()
.update(party) .update(party)
.set({ .set({
status: "started", status: "started",
data: null,
lastUpdated: new Date(), lastUpdated: new Date(),
}) })
.where(eq(party.id, params.partyId)); .where(eq(party.id, params.partyId));
const status = await getPartyStatus(params.partyId); const status = await getPartyStatus(params.partyId);
broadcastStatusToMembers(status); if (status) {
pubsub.publish(
`party:${params.partyId}`,
JSON.stringify({
type: "party_status",
party: status.party,
members: status.members,
}),
);
}
return { return {
message: "Quiz started", message: "Quiz started",

View file

@ -3,9 +3,6 @@
"module": "index.ts", "module": "index.ts",
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": {
"dev": "bun run --watch index.ts"
},
"devDependencies": { "devDependencies": {
"@types/bun": "latest" "@types/bun": "latest"
}, },

View file

@ -24,10 +24,6 @@ import { useSpotifyPlayer } from "#/hooks/use-spotify-player";
import { useUser } from "#/hooks/user"; import { useUser } from "#/hooks/user";
import { client } from "#/lib/eden"; import { client } from "#/lib/eden";
type PartyQuestion = NonNullable<
NonNullable<ReturnType<typeof useParty>["party"]>["data"]["currentQuestion"]
>;
function formatTimeLeft(milliseconds: number) { function formatTimeLeft(milliseconds: number) {
const clamped = Math.max(0, milliseconds); const clamped = Math.max(0, milliseconds);
const totalSeconds = Math.ceil(clamped / 1000); const totalSeconds = Math.ceil(clamped / 1000);
@ -38,17 +34,6 @@ function formatTimeLeft(milliseconds: number) {
return `${minutes}:${seconds}`; return `${minutes}:${seconds}`;
} }
function getQuestionAnnouncement(question: PartyQuestion) {
if (question.type === "numeric") {
return `${question.text}. Choose a number from ${question.range.min} to ${question.range.max}.`;
}
const options = question.options
.map((option, index) => `Option ${index + 1}: ${option}`)
.join(". ");
return `${question.text}. ${options}.`;
}
export function Question() { export function Question() {
const { party, members } = useParty(); const { party, members } = useParty();
const { user } = useUser(); const { user } = useUser();
@ -63,10 +48,6 @@ export function Question() {
: null; : null;
const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } = const { enabled: spotifyEnabled, setEnabled: setSpotifyEnabled } =
useSpotifyPlayer(spotifyTrackUri); useSpotifyPlayer(spotifyTrackUri);
const questionStartTimestamp = question?.startTimestamp ?? null;
const questionAnnouncement = question
? getQuestionAnnouncement(question)
: null;
useEffect(() => { useEffect(() => {
const timer = window.setInterval(() => { const timer = window.setInterval(() => {
@ -82,26 +63,6 @@ export function Question() {
setSelectedValue(question.type === "numeric" ? question.range.min : null); setSelectedValue(question.type === "numeric" ? question.range.min : null);
}, [question]); }, [question]);
useEffect(() => {
if (
!questionAnnouncement ||
questionStartTimestamp == null ||
!("speechSynthesis" in window)
) {
return;
}
const utterance = new SpeechSynthesisUtterance(questionAnnouncement);
utterance.rate = 0.95;
utterance.pitch = 1;
window.speechSynthesis.cancel();
window.speechSynthesis.speak(utterance);
return () => {
window.speechSynthesis.cancel();
};
}, [questionAnnouncement, questionStartTimestamp]);
if (!question) if (!question)
return ( return (
<Section> <Section>

View file

@ -15,14 +15,8 @@ import {
export function UserInfo() { export function UserInfo() {
const { user } = useUser(); const { user } = useUser();
const { const { party, members, isConnecting, isReconnecting, resetParty } =
party, useParty();
members,
isConnecting,
isReconnecting,
resetParty,
setPartyState,
} = useParty();
return ( return (
<Item> <Item>
<ItemMedia> <ItemMedia>
@ -47,12 +41,8 @@ export function UserInfo() {
{party && ( {party && (
<Button <Button
onClick={async () => { onClick={async () => {
const result = await client.api.party.leave.post(); await client.api.party.leave.post();
if (result?.party) { resetParty();
setPartyState(result);
} else {
resetParty();
}
}} }}
> >
Leave Leave

View file

@ -1,8 +1,8 @@
import { treaty } from "@elysiajs/eden"; import { treaty } from "@elysiajs/eden";
import type { App } from "../../../api/src/index"; import type { App } from "../../../api/src/index";
export const client = treaty<App>("aura.rpi1.danbulant.cloud", {}); // export const client = treaty<App>("aura.rpi1.danbulant.cloud", {});
// export const client = treaty<App>( export const client = treaty<App>(
// process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000", process.env.VITE_BETTER_AUTH_URL || "127.0.0.1:3000",
// {}, {},
// ); );