Compare commits
No commits in common. "e4e392de5133b2076e78d8a831f8c91ce2ef6d57" and "3be0ed058b24fbe4a473631d05dfbc162b906bd9" have entirely different histories.
e4e392de51
...
3be0ed058b
16 changed files with 87 additions and 518 deletions
|
|
@ -36,16 +36,16 @@ type BaseQuestion = {
|
||||||
|
|
||||||
export type Question =
|
export type Question =
|
||||||
| (BaseQuestion & {
|
| (BaseQuestion & {
|
||||||
type: "choice";
|
type: "choice";
|
||||||
options: string[];
|
options: string[];
|
||||||
})
|
})
|
||||||
| (BaseQuestion & {
|
| (BaseQuestion & {
|
||||||
type: "numeric";
|
type: "numeric";
|
||||||
range: {
|
range: {
|
||||||
min: number;
|
min: number;
|
||||||
max: number;
|
max: number;
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
export type QuizResponse = {
|
export type QuizResponse = {
|
||||||
playerId: string;
|
playerId: string;
|
||||||
|
|
@ -83,11 +83,8 @@ export type QuizState = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export type PartySocketEvent =
|
export type PartySocketEvent =
|
||||||
| {
|
| { type: "snapshot"; party: Party | null; members: PartyMemberWithUser[] }
|
||||||
type: "party_status";
|
| { type: "party_status"; party: Party; members: PartyMemberWithUser[] }
|
||||||
party: Party | null;
|
|
||||||
members: PartyMemberWithUser[];
|
|
||||||
}
|
|
||||||
| { type: "member_payload"; fromUserId: string; payload: unknown }
|
| { type: "member_payload"; fromUserId: string; payload: unknown }
|
||||||
| { type: "error"; message: string }
|
| { type: "error"; message: string }
|
||||||
| { type: "pong" };
|
| { type: "pong" };
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,10 @@
|
||||||
import type { Question } from "../party-types";
|
import type { Question } from "../party-types";
|
||||||
import {
|
import {
|
||||||
buildOptionsWithCorrect,
|
|
||||||
buildOrderedOptions,
|
|
||||||
buildQuestionWindow,
|
buildQuestionWindow,
|
||||||
getMostSharedGenreNames,
|
getTopArtistName,
|
||||||
getTopClusterArtists,
|
getTopGenreName,
|
||||||
getTopClusterTracks,
|
getTopTrackName,
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
pickRandom,
|
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
export function buildAudioMetadataQuestion(
|
export function buildAudioMetadataQuestion(
|
||||||
|
|
@ -15,104 +12,34 @@ export function buildAudioMetadataQuestion(
|
||||||
index: number,
|
index: number,
|
||||||
): Question {
|
): 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 genreOptions = buildOrderedOptions(
|
|
||||||
getMostSharedGenreNames(analytics),
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
if (genreOptions) {
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which genre appears most in the party analytics?",
|
text: "Which genre appears most in the party analytics?",
|
||||||
options: genreOptions,
|
options: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"],
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
});
|
},
|
||||||
}
|
{
|
||||||
|
type: "choice",
|
||||||
const topArtists = getTopClusterArtists(analytics);
|
text: "Which artist shows up most often in the shared audio data?",
|
||||||
const topArtist = topArtists[0];
|
options: [
|
||||||
if (topArtist) {
|
getTopArtistName(analytics),
|
||||||
const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
|
"Artist B",
|
||||||
if (artistOptions) {
|
"Artist C",
|
||||||
questions.push({
|
"Artist D",
|
||||||
type: "choice",
|
],
|
||||||
text: "Which artist shows up most often in the shared audio data?",
|
correct: 0,
|
||||||
options: artistOptions,
|
points: 10,
|
||||||
correct: 0,
|
},
|
||||||
points: 10,
|
{
|
||||||
});
|
type: "choice",
|
||||||
}
|
text: "Which track looks most shared across the party?",
|
||||||
}
|
options: [getTopTrackName(analytics), "Track B", "Track C", "Track D"],
|
||||||
|
correct: 0,
|
||||||
const topTracks = getTopClusterTracks(analytics);
|
points: 10,
|
||||||
if (topTracks.length > 0) {
|
},
|
||||||
const trackNames = topTracks.map((track) => track.name);
|
];
|
||||||
const topTrackName = trackNames[0];
|
|
||||||
const trackOptions = topTrackName
|
|
||||||
? buildOptionsWithCorrect(topTrackName, trackNames, 4)
|
|
||||||
: null;
|
|
||||||
if (trackOptions) {
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
|
||||||
text: "Which track looks most shared across the party?",
|
|
||||||
options: trackOptions,
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomTopTrack = pickRandom(topTracks);
|
|
||||||
if (randomTopTrack) {
|
|
||||||
const trackArtists =
|
|
||||||
randomTopTrack.artists?.map((artist) => artist.name) ?? [];
|
|
||||||
const allArtists = topArtists.length > 0 ? topArtists : trackArtists;
|
|
||||||
const correctArtist = trackArtists[0] ?? allArtists[0];
|
|
||||||
if (correctArtist) {
|
|
||||||
const artistOptions = buildOptionsWithCorrect(
|
|
||||||
correctArtist,
|
|
||||||
allArtists,
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
if (artistOptions) {
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
|
||||||
text: `Who performs "${randomTopTrack.name}"?`,
|
|
||||||
options: artistOptions,
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (randomTopTrack.albumName) {
|
|
||||||
const albumNames = topTracks
|
|
||||||
.map((track) => track.albumName)
|
|
||||||
.filter((name): name is string => Boolean(name));
|
|
||||||
const albumOptions = buildOptionsWithCorrect(
|
|
||||||
randomTopTrack.albumName,
|
|
||||||
albumNames,
|
|
||||||
4,
|
|
||||||
);
|
|
||||||
if (albumOptions) {
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
|
||||||
text: `"${randomTopTrack.name}" appears on which album?`,
|
|
||||||
options: albumOptions,
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questions.length === 0) {
|
|
||||||
throw new Error("Question not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const question = questions[index % questions.length];
|
const question = questions[index % questions.length];
|
||||||
if (!question) throw new Error("Question not found");
|
if (!question) throw new Error("Question not found");
|
||||||
|
|
|
||||||
|
|
@ -3,10 +3,7 @@ import type { Question } from "../party-types";
|
||||||
import type { PartyAnalytics } from "./question-utils";
|
import type { PartyAnalytics } from "./question-utils";
|
||||||
import { buildQuestionWindow, getQuestionRange } from "./question-utils";
|
import { buildQuestionWindow, getQuestionRange } from "./question-utils";
|
||||||
|
|
||||||
type NumericQuestion = Omit<
|
type NumericQuestion = Omit<Extract<Question, { type: "numeric" }>, "startTimestamp" | "endTimestamp">;
|
||||||
Extract<Question, { type: "numeric" }>,
|
|
||||||
"startTimestamp" | "endTimestamp"
|
|
||||||
>;
|
|
||||||
|
|
||||||
type BuildNumericQuestionInput = {
|
type BuildNumericQuestionInput = {
|
||||||
db: typeof db;
|
db: typeof db;
|
||||||
|
|
@ -33,10 +30,10 @@ async function getAlbumReleaseYear({
|
||||||
const correct =
|
const correct =
|
||||||
track?.album?.release_date?.getFullYear() ??
|
track?.album?.release_date?.getFullYear() ??
|
||||||
new Date().getFullYear() - 1 - index;
|
new Date().getFullYear() - 1 - index;
|
||||||
const subject = track?.album?.name ?? track?.name ?? "unknown album";
|
const subject = track?.album?.name ?? track?.name ?? "the album";
|
||||||
return {
|
return {
|
||||||
type: "numeric",
|
type: "numeric",
|
||||||
text: `What's the release year of ${subject}?`,
|
text: `What number best matches ${subject}?`,
|
||||||
correct,
|
correct,
|
||||||
range: getQuestionRange(correct, 5),
|
range: getQuestionRange(correct, 5),
|
||||||
points: 10,
|
points: 10,
|
||||||
|
|
|
||||||
|
|
@ -10,14 +10,8 @@ export type PartyAnalytics = {
|
||||||
mostSharedGenres?: { name: string }[];
|
mostSharedGenres?: { name: string }[];
|
||||||
};
|
};
|
||||||
storyClusters?: {
|
storyClusters?: {
|
||||||
tracks?: {
|
tracks?: { name: string }[];
|
||||||
name: string;
|
|
||||||
artists?: { name: string }[];
|
|
||||||
albumName?: string;
|
|
||||||
memberScores?: { userId: string; score: number }[];
|
|
||||||
}[];
|
|
||||||
artists?: { name: string }[];
|
artists?: { name: string }[];
|
||||||
genres?: { name: string }[];
|
|
||||||
}[];
|
}[];
|
||||||
memberProfiles?: { userId: string }[];
|
memberProfiles?: { userId: string }[];
|
||||||
pairwise?: { userIdA: string; userIdB: string }[];
|
pairwise?: { userIdA: string; userIdB: string }[];
|
||||||
|
|
@ -84,66 +78,16 @@ export function getQuestionRange(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
|
export function getTopGenreName(analytics: PartyAnalytics): string {
|
||||||
return (analytics?.groupSummary?.mostSharedGenres ?? []).map(
|
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop";
|
||||||
(genre) => genre.name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
|
export function getTopArtistName(analytics: PartyAnalytics): string {
|
||||||
return (analytics?.storyClusters?.[0]?.artists ?? []).map(
|
return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A";
|
||||||
(artist) => artist.name,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopClusterTracks(analytics: PartyAnalytics): Array<{
|
export function getTopTrackName(analytics: PartyAnalytics): string {
|
||||||
name: string;
|
return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A";
|
||||||
artists?: { name: string }[];
|
|
||||||
albumName?: string;
|
|
||||||
memberScores?: { userId: string; score: number }[];
|
|
||||||
}> {
|
|
||||||
return analytics?.storyClusters?.[0]?.tracks ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getTopTrackListener(
|
|
||||||
track: { memberScores?: { userId: string; score: number }[] },
|
|
||||||
members: PartyQuestionMember[],
|
|
||||||
): PartyQuestionMember | null {
|
|
||||||
const topMember = (track.memberScores ?? [])
|
|
||||||
.slice()
|
|
||||||
.sort((a, b) => b.score - a.score)
|
|
||||||
.at(0);
|
|
||||||
|
|
||||||
if (!topMember) return null;
|
|
||||||
return members.find((member) => member.userId === topMember.userId) ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOrderedOptions(
|
|
||||||
values: Array<string | undefined>,
|
|
||||||
desiredCount: number,
|
|
||||||
): string[] | null {
|
|
||||||
const options = uniqueStrings(
|
|
||||||
values.filter((value): value is string => Boolean(value)),
|
|
||||||
);
|
|
||||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOptionsWithCorrect(
|
|
||||||
correct: string,
|
|
||||||
candidates: string[],
|
|
||||||
desiredCount: number,
|
|
||||||
): string[] | null {
|
|
||||||
const options = uniqueStrings([
|
|
||||||
correct,
|
|
||||||
...candidates.filter((c) => c !== correct),
|
|
||||||
]);
|
|
||||||
return options.length >= desiredCount ? options.slice(0, desiredCount) : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function pickRandom<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(
|
export function getCurrentLeader(
|
||||||
|
|
@ -160,17 +104,6 @@ export function getCurrentLeader(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
export function getMostDiverseMember(
|
||||||
analytics: PartyAnalytics,
|
analytics: PartyAnalytics,
|
||||||
members: PartyQuestionMember[],
|
members: PartyQuestionMember[],
|
||||||
|
|
|
||||||
|
|
@ -5,12 +5,8 @@ import {
|
||||||
getCurrentLeader,
|
getCurrentLeader,
|
||||||
getMostAlignedMember,
|
getMostAlignedMember,
|
||||||
getMostDiverseMember,
|
getMostDiverseMember,
|
||||||
getTopClusterTracks,
|
|
||||||
getTopTrackListener,
|
|
||||||
hasClearLeader,
|
|
||||||
type PartyAnalytics,
|
type PartyAnalytics,
|
||||||
type PartyQuestionMember,
|
type PartyQuestionMember,
|
||||||
pickRandom,
|
|
||||||
} from "./question-utils";
|
} from "./question-utils";
|
||||||
|
|
||||||
export function buildSocialQuestion(
|
export function buildSocialQuestion(
|
||||||
|
|
@ -20,60 +16,33 @@ export function buildSocialQuestion(
|
||||||
index: number,
|
index: number,
|
||||||
): Question {
|
): Question {
|
||||||
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
type ChoiceQuestion = Extract<Question, { type: "choice" }>;
|
||||||
const questions: Array<
|
const leader = getCurrentLeader(quizState, members);
|
||||||
Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
|
const diverse = getMostDiverseMember(analytics, members);
|
||||||
> = [];
|
const aligned = getMostAlignedMember(analytics, members);
|
||||||
|
|
||||||
const hasMultipleMembers = members.length >= 2;
|
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [
|
||||||
if (hasMultipleMembers && hasClearLeader(quizState)) {
|
{
|
||||||
const leader = getCurrentLeader(quizState, members);
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Who is leading the quiz right now?",
|
text: "Who is leading the quiz right now?",
|
||||||
options: buildMemberOptions(leader, members),
|
options: buildMemberOptions(leader, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
});
|
},
|
||||||
}
|
{
|
||||||
|
|
||||||
if (hasMultipleMembers) {
|
|
||||||
const diverse = getMostDiverseMember(analytics, members);
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Who looks like the most diverse listener in the party?",
|
text: "Who looks like the most diverse listener in the party?",
|
||||||
options: buildMemberOptions(diverse, members),
|
options: buildMemberOptions(diverse, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
});
|
},
|
||||||
|
{
|
||||||
const aligned = getMostAlignedMember(analytics, members);
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
type: "choice",
|
||||||
text: "Which member seems most aligned with the rest of the party?",
|
text: "Which member seems most aligned with the rest of the party?",
|
||||||
options: buildMemberOptions(aligned, members),
|
options: buildMemberOptions(aligned, members),
|
||||||
correct: 0,
|
correct: 0,
|
||||||
points: 10,
|
points: 10,
|
||||||
});
|
},
|
||||||
}
|
];
|
||||||
|
|
||||||
const topTracks = getTopClusterTracks(analytics);
|
|
||||||
const randomTrack = pickRandom(topTracks);
|
|
||||||
if (randomTrack && hasMultipleMembers) {
|
|
||||||
const topListener = getTopTrackListener(randomTrack, members);
|
|
||||||
if (topListener) {
|
|
||||||
questions.push({
|
|
||||||
type: "choice",
|
|
||||||
text: `Who listens the most to "${randomTrack.name}"?`,
|
|
||||||
options: buildMemberOptions(topListener, members),
|
|
||||||
correct: 0,
|
|
||||||
points: 10,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (questions.length === 0) {
|
|
||||||
throw new Error("Question not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
const question = questions[index % questions.length];
|
const question = questions[index % questions.length];
|
||||||
if (!question) throw new Error("Question not found");
|
if (!question) throw new Error("Question not found");
|
||||||
|
|
|
||||||
|
|
@ -1,47 +0,0 @@
|
||||||
import Elysia from "elysia";
|
|
||||||
import { db } from "../db";
|
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data";
|
|
||||||
import {
|
|
||||||
broadcastQuizState,
|
|
||||||
partyTopic,
|
|
||||||
socketPartyId,
|
|
||||||
userTopic,
|
|
||||||
} from "./party-socket";
|
|
||||||
|
|
||||||
export const partySocketApp = new Elysia().group("/dev-socket", (app) =>
|
|
||||||
app
|
|
||||||
.get("/test", () => ({ ok: 1 }))
|
|
||||||
.ws("/ws", {
|
|
||||||
async open(ws) {
|
|
||||||
const id = "zzxWcTUntIWTHkX8atEOv7Neiu7XEz9t";
|
|
||||||
ws.subscribe(userTopic(id));
|
|
||||||
const membership = await getMemberRecord(db, id);
|
|
||||||
if (!membership) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "snapshot",
|
|
||||||
party: null,
|
|
||||||
members: [],
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
socketPartyId.set(ws, membership.partyId);
|
|
||||||
ws.subscribe(partyTopic(membership.partyId));
|
|
||||||
|
|
||||||
const snapshot = await getPartyStatus(membership.partyId);
|
|
||||||
if (snapshot) {
|
|
||||||
ws.send(
|
|
||||||
JSON.stringify({
|
|
||||||
type: "snapshot",
|
|
||||||
party: snapshot.party,
|
|
||||||
members: snapshot.members,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
await broadcastQuizState(ws, membership.partyId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
@ -6,15 +6,15 @@ import { db } from "../db";
|
||||||
import { getMemberRecord, getPartyStatus } from "../party-data";
|
import { getMemberRecord, getPartyStatus } from "../party-data";
|
||||||
import type { PartySocketEvent, QuizState } from "../party-types";
|
import type { PartySocketEvent, QuizState } from "../party-types";
|
||||||
|
|
||||||
export function userTopic(userId: string) {
|
function userTopic(userId: string) {
|
||||||
return `user:${userId}`;
|
return `user:${userId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function partyTopic(partyId: string) {
|
function partyTopic(partyId: string) {
|
||||||
return `party:${partyId}`;
|
return `party:${partyId}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const socketPartyId = new WeakMap<object, string>();
|
const socketPartyId = new WeakMap<object, string>();
|
||||||
|
|
||||||
export const pubsub = {
|
export const pubsub = {
|
||||||
_server: null as ReturnType<typeof Bun.serve> | null,
|
_server: null as ReturnType<typeof Bun.serve> | null,
|
||||||
|
|
@ -29,7 +29,7 @@ export const pubsub = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function broadcastQuizState(ws: any, partyId: string) {
|
async function broadcastQuizState(ws: any, partyId: string) {
|
||||||
const partyRecord = await db.query.party.findFirst({
|
const partyRecord = await db.query.party.findFirst({
|
||||||
where: { id: partyId },
|
where: { id: partyId },
|
||||||
});
|
});
|
||||||
|
|
@ -74,10 +74,10 @@ export const partySocketApp = new Elysia()
|
||||||
if (!membership) {
|
if (!membership) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "party_status",
|
type: "snapshot",
|
||||||
party: null,
|
party: null,
|
||||||
members: [],
|
members: [],
|
||||||
} as PartySocketEvent),
|
}),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -89,10 +89,10 @@ export const partySocketApp = new Elysia()
|
||||||
if (snapshot) {
|
if (snapshot) {
|
||||||
ws.send(
|
ws.send(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
type: "party_status",
|
type: "snapshot",
|
||||||
party: snapshot.party,
|
party: snapshot.party,
|
||||||
members: snapshot.members,
|
members: snapshot.members,
|
||||||
} as PartySocketEvent),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
await broadcastQuizState(ws, membership.partyId);
|
await broadcastQuizState(ws, membership.partyId);
|
||||||
|
|
@ -143,7 +143,7 @@ export const partySocketApp = new Elysia()
|
||||||
type: "member_payload",
|
type: "member_payload",
|
||||||
fromUserId: user.id,
|
fromUserId: user.id,
|
||||||
payload: parsed.payload,
|
payload: parsed.payload,
|
||||||
} as PartySocketEvent),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
close: async (ws) => {
|
close: async (ws) => {
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,7 @@ import { partyMember } from "../db/schema";
|
||||||
import { generatePartyQuestion } from "../party/question-generator";
|
import { generatePartyQuestion } from "../party/question-generator";
|
||||||
import type { PartyAnalytics } from "../party/question-utils";
|
import type { PartyAnalytics } from "../party/question-utils";
|
||||||
import { updatePartyData } from "../party/state";
|
import { updatePartyData } from "../party/state";
|
||||||
import type {
|
import type { Question, QuizResponse, QuizRound, QuizState } from "../party-types";
|
||||||
Question,
|
|
||||||
QuizResponse,
|
|
||||||
QuizRound,
|
|
||||||
QuizState,
|
|
||||||
} from "../party-types";
|
|
||||||
import { partyAnalysisWorkflow } from "./party-analysis";
|
|
||||||
|
|
||||||
const TOTAL_QUESTIONS = 5;
|
const TOTAL_QUESTIONS = 5;
|
||||||
|
|
||||||
|
|
@ -42,13 +36,11 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
history: [],
|
history: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await partyAnalysisWorkflow.analyzeParty(partyId);
|
|
||||||
|
|
||||||
// Initialize quiz state
|
// Initialize quiz state
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
|
||||||
// Get party members to initialize scores
|
// Get party members to initialize scores
|
||||||
let members = await QuizWorkflow.getPartyMembers(partyId);
|
const members = await QuizWorkflow.getPartyMembers(partyId);
|
||||||
for (const member of members) {
|
for (const member of members) {
|
||||||
quizState.scores[member.userId] = 0;
|
quizState.scores[member.userId] = 0;
|
||||||
}
|
}
|
||||||
|
|
@ -71,7 +63,6 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
quizState.history.push(round);
|
quizState.history.push(round);
|
||||||
|
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
members = await QuizWorkflow.getPartyMembers(partyId);
|
|
||||||
// Wait for all responses with timeout
|
// Wait for all responses with timeout
|
||||||
const memberIds = new Set(members.map((m) => m.userId));
|
const memberIds = new Set(members.map((m) => m.userId));
|
||||||
const receivedPlayers = new Set<string>();
|
const receivedPlayers = new Set<string>();
|
||||||
|
|
@ -84,7 +75,6 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
if (response === null) {
|
if (response === null) {
|
||||||
// Timeout - fill in missing players with no answer
|
// Timeout - fill in missing players with no answer
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (now < question.endTimestamp) continue;
|
|
||||||
for (const memberId of memberIds) {
|
for (const memberId of memberIds) {
|
||||||
if (!receivedPlayers.has(memberId)) {
|
if (!receivedPlayers.has(memberId)) {
|
||||||
receivedPlayers.add(memberId);
|
receivedPlayers.add(memberId);
|
||||||
|
|
@ -103,8 +93,6 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (receivedPlayers.has(response.playerId)) continue;
|
|
||||||
|
|
||||||
receivedPlayers.add(response.playerId);
|
receivedPlayers.add(response.playerId);
|
||||||
const answeredAt = Date.now();
|
const answeredAt = Date.now();
|
||||||
const selectedValue = response.selected;
|
const selectedValue = response.selected;
|
||||||
|
|
@ -123,7 +111,8 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
for (const [playerId, gained] of QuizWorkflow.scoreRound(round)) {
|
||||||
quizState.scores[playerId] = (quizState.scores[playerId] ?? 0) + gained;
|
quizState.scores[playerId] =
|
||||||
|
(quizState.scores[playerId] ?? 0) + gained;
|
||||||
}
|
}
|
||||||
|
|
||||||
await QuizWorkflow.updatePartyData(partyId, quizState);
|
await QuizWorkflow.updatePartyData(partyId, quizState);
|
||||||
|
|
@ -175,10 +164,7 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
const ordered = round.responses
|
const ordered = round.responses
|
||||||
.map((response) => ({
|
.map((response) => ({
|
||||||
response,
|
response,
|
||||||
distance: Math.abs(
|
distance: Math.abs((response.selectedValue ?? response.selected) - round.question.correct),
|
||||||
(response.selectedValue ?? response.selected) -
|
|
||||||
round.question.correct,
|
|
||||||
),
|
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => a.distance - b.distance);
|
.sort((a, b) => a.distance - b.distance);
|
||||||
|
|
||||||
|
|
@ -192,12 +178,9 @@ export class QuizWorkflow extends ConfiguredInstance {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const _scoringGroups = groups.slice(0, Math.max(0, groups.length - 1));
|
const scoringGroups = groups.slice(0, Math.max(0, groups.length - 1));
|
||||||
if (groups.length <= 1) {
|
if (groups.length <= 1) {
|
||||||
return round.responses.map((response) => [
|
return round.responses.map((response) => [response.playerId, round.question.points]);
|
||||||
response.playerId,
|
|
||||||
round.question.points,
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return groups.flatMap((group, index) => {
|
return groups.flatMap((group, index) => {
|
||||||
|
|
|
||||||
34
dev-proxy/.gitignore
vendored
34
dev-proxy/.gitignore
vendored
|
|
@ -1,34 +0,0 @@
|
||||||
# dependencies (bun install)
|
|
||||||
node_modules
|
|
||||||
|
|
||||||
# output
|
|
||||||
out
|
|
||||||
dist
|
|
||||||
*.tgz
|
|
||||||
|
|
||||||
# code coverage
|
|
||||||
coverage
|
|
||||||
*.lcov
|
|
||||||
|
|
||||||
# logs
|
|
||||||
logs
|
|
||||||
_.log
|
|
||||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
|
||||||
|
|
||||||
# dotenv environment variable files
|
|
||||||
.env
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
.env.local
|
|
||||||
|
|
||||||
# caches
|
|
||||||
.eslintcache
|
|
||||||
.cache
|
|
||||||
*.tsbuildinfo
|
|
||||||
|
|
||||||
# IntelliJ based IDEs
|
|
||||||
.idea
|
|
||||||
|
|
||||||
# Finder (MacOS) folder config
|
|
||||||
.DS_Store
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
# dev-proxy
|
|
||||||
|
|
||||||
To install dependencies:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun install
|
|
||||||
```
|
|
||||||
|
|
||||||
To run:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bun run index.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
This project was created using `bun init` in bun v1.3.11. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
{
|
|
||||||
"lockfileVersion": 1,
|
|
||||||
"configVersion": 1,
|
|
||||||
"workspaces": {
|
|
||||||
"": {
|
|
||||||
"name": "dev-proxy",
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest",
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
"packages": {
|
|
||||||
"@types/bun": ["@types/bun@1.3.13", "", { "dependencies": { "bun-types": "1.3.13" } }, "sha512-9fqXWk5YIHGGnUau9TEi+qdlTYDAnOj+xLCmSTwXfAIqXr2x4tytJb43E9uCvt09zJURKXwAtkoH4nLQfzeTXw=="],
|
|
||||||
|
|
||||||
"@types/node": ["@types/node@25.6.0", "", { "dependencies": { "undici-types": "~7.19.0" } }, "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ=="],
|
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="],
|
|
||||||
|
|
||||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
|
||||||
|
|
||||||
"undici-types": ["undici-types@7.19.2", "", {}, "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg=="],
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,42 +0,0 @@
|
||||||
import type {
|
|
||||||
PartySocketEvent,
|
|
||||||
PartyState,
|
|
||||||
} from "../api/src/party-types";
|
|
||||||
let ws: WebSocket | null = null;
|
|
||||||
|
|
||||||
Bun.listen({
|
|
||||||
hostname: "0.0.0.0",
|
|
||||||
port: 7070,
|
|
||||||
socket: {
|
|
||||||
data(socket, data) {
|
|
||||||
// in1={} in2={} in3={} in4={} angle={}
|
|
||||||
console.log("recv", data)
|
|
||||||
}, // message received from client
|
|
||||||
open(socket) {
|
|
||||||
console.log("Connected");
|
|
||||||
ws = new WebSocket("ws://localhost:3000/api/dev-socket/ws");
|
|
||||||
|
|
||||||
ws.onmessage = e => {
|
|
||||||
const data = JSON.parse(e.data) as PartySocketEvent;
|
|
||||||
switch (data.type) {
|
|
||||||
case "party_status":
|
|
||||||
const { party: { data: { currentQuestion } } } = data;
|
|
||||||
console.log(currentQuestion)
|
|
||||||
let text = currentQuestion?.text
|
|
||||||
if (text) {
|
|
||||||
ws?.send(text)
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, // socket opened
|
|
||||||
close(socket, error) {
|
|
||||||
ws?.close();
|
|
||||||
ws = null;
|
|
||||||
}, // socket closed
|
|
||||||
drain(socket) {}, // socket ready for more data
|
|
||||||
error(socket, error) {}, // error handler
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("Started on :7070")
|
|
||||||
|
|
@ -1,12 +0,0 @@
|
||||||
{
|
|
||||||
"name": "dev-proxy",
|
|
||||||
"module": "index.ts",
|
|
||||||
"type": "module",
|
|
||||||
"private": true,
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/bun": "latest"
|
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"typescript": "^5"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
// Environment setup & latest features
|
|
||||||
"lib": ["ESNext"],
|
|
||||||
"target": "ESNext",
|
|
||||||
"module": "Preserve",
|
|
||||||
"moduleDetection": "force",
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"allowJs": true,
|
|
||||||
|
|
||||||
// Bundler mode
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"verbatimModuleSyntax": true,
|
|
||||||
"noEmit": true,
|
|
||||||
|
|
||||||
// Best practices
|
|
||||||
"strict": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"noFallthroughCasesInSwitch": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"noImplicitOverride": true,
|
|
||||||
|
|
||||||
// Some stricter flags (disabled by default)
|
|
||||||
"noUnusedLocals": false,
|
|
||||||
"noUnusedParameters": false,
|
|
||||||
"noPropertyAccessFromIndexSignature": false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -3,8 +3,6 @@
|
||||||
|
|
||||||
use arrayvec::ArrayVec;
|
use arrayvec::ArrayVec;
|
||||||
use core::str::FromStr;
|
use core::str::FromStr;
|
||||||
use embassy_net::tcp::ConnectError;
|
|
||||||
use embedded_io::ReadReady;
|
|
||||||
|
|
||||||
use ag_lcd::{Blink, Cursor, Display, LcdDisplay};
|
use ag_lcd::{Blink, Cursor, Display, LcdDisplay};
|
||||||
use as5600::As5600;
|
use as5600::As5600;
|
||||||
|
|
@ -24,7 +22,7 @@ use embassy_rp::{bind_interrupts, dma};
|
||||||
use embassy_rp::{peripherals::USB, usb};
|
use embassy_rp::{peripherals::USB, usb};
|
||||||
use embassy_time::{Delay, Duration, Timer};
|
use embassy_time::{Delay, Duration, Timer};
|
||||||
use static_cell::StaticCell;
|
use static_cell::StaticCell;
|
||||||
use ufmt::{uWrite, uwrite};
|
use ufmt::uwrite;
|
||||||
use {defmt_rtt as _, panic_probe as _};
|
use {defmt_rtt as _, panic_probe as _};
|
||||||
|
|
||||||
bind_interrupts!(struct Irqs {
|
bind_interrupts!(struct Irqs {
|
||||||
|
|
@ -51,7 +49,7 @@ async fn net_task(mut runner: embassy_net::Runner<'static, cyw43::NetDriver<'sta
|
||||||
runner.run().await
|
runner.run().await
|
||||||
}
|
}
|
||||||
|
|
||||||
const WIFI_NETWORK: &str = "aura";
|
const WIFI_NETWORK: &str = "flamme";
|
||||||
const WIFI_PASSWORD: &str = "12345678";
|
const WIFI_PASSWORD: &str = "12345678";
|
||||||
|
|
||||||
#[embassy_executor::main]
|
#[embassy_executor::main]
|
||||||
|
|
@ -107,8 +105,6 @@ async fn main(spawner: Spawner) {
|
||||||
.with_display(Display::On)
|
.with_display(Display::On)
|
||||||
.with_blink(Blink::On)
|
.with_blink(Blink::On)
|
||||||
.with_cursor(Cursor::On)
|
.with_cursor(Cursor::On)
|
||||||
.with_lines(ag_lcd::Lines::TwoLines)
|
|
||||||
// .with_autoscroll(ag_lcd::AutoScroll::On)
|
|
||||||
.build();
|
.build();
|
||||||
lcd.set_cursor(Cursor::Off);
|
lcd.set_cursor(Cursor::Off);
|
||||||
lcd.set_blink(Blink::Off);
|
lcd.set_blink(Blink::Off);
|
||||||
|
|
@ -126,8 +122,6 @@ async fn main(spawner: Spawner) {
|
||||||
rng.next_u64(),
|
rng.next_u64(),
|
||||||
);
|
);
|
||||||
spawner.spawn(unwrap!(net_task(runner)));
|
spawner.spawn(unwrap!(net_task(runner)));
|
||||||
uwrite!(lcd, "con.");
|
|
||||||
|
|
||||||
while let Err(err) = control
|
while let Err(err) = control
|
||||||
.join(WIFI_NETWORK, JoinOptions::new(WIFI_PASSWORD.as_bytes()))
|
.join(WIFI_NETWORK, JoinOptions::new(WIFI_PASSWORD.as_bytes()))
|
||||||
.await
|
.await
|
||||||
|
|
@ -145,13 +139,11 @@ async fn main(spawner: Spawner) {
|
||||||
stack.wait_link_up().await;
|
stack.wait_link_up().await;
|
||||||
|
|
||||||
lcd.clear();
|
lcd.clear();
|
||||||
// lcd.home();
|
|
||||||
uwrite!(lcd, "dhcp.");
|
uwrite!(lcd, "dhcp.");
|
||||||
stack.wait_config_up().await;
|
stack.wait_config_up().await;
|
||||||
let cfg = wait_for_config(stack).await;
|
let cfg = wait_for_config(stack).await;
|
||||||
let local_addr = cfg.address.address();
|
let local_addr = cfg.address.address();
|
||||||
info!("IP address: {:?}", local_addr);
|
uwrite!(lcd, "IP address: {:?}", local_addr.octets());
|
||||||
// uwrite!(lcd, "IP address: {:?}", local_addr.octets());
|
|
||||||
|
|
||||||
let i2c = I2c::new_async(p.I2C0, p.PIN_5, p.PIN_4, Irqs, Config::default());
|
let i2c = I2c::new_async(p.I2C0, p.PIN_5, p.PIN_4, Irqs, Config::default());
|
||||||
let mut as5600 = As5600::new(i2c);
|
let mut as5600 = As5600::new(i2c);
|
||||||
|
|
@ -170,23 +162,9 @@ async fn main(spawner: Spawner) {
|
||||||
|
|
||||||
led.set_low();
|
led.set_low();
|
||||||
info!("Connecting...");
|
info!("Connecting...");
|
||||||
uwrite!(lcd, "con2.");
|
let host_addr = embassy_net::Ipv4Address::from_str("84.238.32.253").unwrap();
|
||||||
let host_addr = embassy_net::Ipv4Address::from_str("192.168.12.1").unwrap();
|
|
||||||
if let Err(e) = socket.connect((host_addr, 7070)).await {
|
if let Err(e) = socket.connect((host_addr, 7070)).await {
|
||||||
lcd.clear();
|
|
||||||
uwrite!(lcd, "conerr");
|
|
||||||
Timer::after(Duration::from_micros(100)).await;
|
|
||||||
lcd.set_position(0, 1);
|
|
||||||
let emsg = match e {
|
|
||||||
ConnectError::ConnectionReset => "rst",
|
|
||||||
ConnectError::InvalidState => "inv",
|
|
||||||
ConnectError::TimedOut => "tout",
|
|
||||||
ConnectError::NoRoute => "nroute",
|
|
||||||
};
|
|
||||||
uwrite!(lcd, "{}", emsg);
|
|
||||||
warn!("connect error: {:?}", e);
|
warn!("connect error: {:?}", e);
|
||||||
} else {
|
|
||||||
uwrite!(lcd, "conok");
|
|
||||||
}
|
}
|
||||||
info!("Connected to {:?}", socket.remote_endpoint());
|
info!("Connected to {:?}", socket.remote_endpoint());
|
||||||
|
|
||||||
|
|
@ -200,7 +178,7 @@ async fn main(spawner: Spawner) {
|
||||||
let angle = as5600.angle().unwrap_or(0);
|
let angle = as5600.angle().unwrap_or(0);
|
||||||
{
|
{
|
||||||
use embedded_io::Write;
|
use embedded_io::Write;
|
||||||
let _ = core::writeln!(
|
let _ = core::write!(
|
||||||
&mut buffer[..],
|
&mut buffer[..],
|
||||||
"in1={} in2={} in3={} in4={} angle={}",
|
"in1={} in2={} in3={} in4={} angle={}",
|
||||||
in1,
|
in1,
|
||||||
|
|
@ -214,22 +192,6 @@ async fn main(spawner: Spawner) {
|
||||||
use embedded_io_async::Write;
|
use embedded_io_async::Write;
|
||||||
let _ = socket.write_all(&*buffer).await;
|
let _ = socket.write_all(&*buffer).await;
|
||||||
}
|
}
|
||||||
if socket.read_ready().unwrap_or(false) {
|
|
||||||
let mut rx_buffer = [0; 4096];
|
|
||||||
let n = socket.read(&mut rx_buffer).await.unwrap_or(0);
|
|
||||||
if n > 0 {
|
|
||||||
lcd.clear();
|
|
||||||
lcd.home();
|
|
||||||
let s = core::str::from_utf8(&rx_buffer[..n]).unwrap_or("");
|
|
||||||
let npos = s.find('\n').unwrap_or(n);
|
|
||||||
let display_text = &s[..npos];
|
|
||||||
lcd.write_str(display_text).ok();
|
|
||||||
|
|
||||||
Timer::after(Duration::from_micros(100)).await;
|
|
||||||
lcd.set_position(0, 1);
|
|
||||||
lcd.write_str(&s[npos..]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Timer::after(delay).await;
|
Timer::after(delay).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -132,6 +132,12 @@ export function Question() {
|
||||||
{question.type === "numeric" ? (
|
{question.type === "numeric" ? (
|
||||||
<Item variant="muted">
|
<Item variant="muted">
|
||||||
<ItemContent>
|
<ItemContent>
|
||||||
|
<ItemHeader>
|
||||||
|
<ItemTitle>Choose a value</ItemTitle>
|
||||||
|
<ItemDescription>
|
||||||
|
Closest guesses get more points
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemHeader>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Slider
|
<Slider
|
||||||
min={numericMin}
|
min={numericMin}
|
||||||
|
|
@ -142,7 +148,7 @@ export function Question() {
|
||||||
? [currentNumericSelection]
|
? [currentNumericSelection]
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
onValueChange={(value) => setSelectedValue(typeof value === "number" ? value : value[0] ?? null)}
|
onValueChange={(value) => setSelectedValue(value[0] ?? null)}
|
||||||
/>
|
/>
|
||||||
<div className="text-sm text-muted-foreground">
|
<div className="text-sm text-muted-foreground">
|
||||||
Exact value:{" "}
|
Exact value:{" "}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue