Compare commits

..

6 commits

Author SHA1 Message Date
Daniel Bulant
e4e392de51
improved social questions 2026-05-04 11:51:41 +02:00
Daniel Bulant
834db89c9e
ignore double submissions 2026-05-04 11:49:04 +02:00
Daniel Bulant
33b35e7735
fix party event 2026-05-04 11:46:17 +02:00
Daniel Bulant
7b8ec190a8
improved questions 2026-05-04 11:30:31 +02:00
Daniel Bulant
14ccaee48c
improved 2026-05-04 11:06:01 +02:00
Daniel Bulant
b01eccb447
wip device connection 2026-05-04 10:47:12 +02:00
16 changed files with 518 additions and 87 deletions

View file

@ -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,8 +83,11 @@ export type QuizState = {
}; };
export type PartySocketEvent = export type PartySocketEvent =
| { type: "snapshot"; party: Party | null; members: PartyMemberWithUser[] } | {
| { type: "party_status"; party: Party; members: PartyMemberWithUser[] } type: "party_status";
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" };

View file

@ -1,10 +1,13 @@
import type { Question } from "../party-types"; import type { Question } from "../party-types";
import { import {
buildOptionsWithCorrect,
buildOrderedOptions,
buildQuestionWindow, buildQuestionWindow,
getTopArtistName, getMostSharedGenreNames,
getTopGenreName, getTopClusterArtists,
getTopTrackName, getTopClusterTracks,
type PartyAnalytics, type PartyAnalytics,
pickRandom,
} from "./question-utils"; } from "./question-utils";
export function buildAudioMetadataQuestion( export function buildAudioMetadataQuestion(
@ -12,34 +15,104 @@ export function buildAudioMetadataQuestion(
index: number, index: number,
): Question { ): Question {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [ const questions: Array<
{ 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: [getTopGenreName(analytics), "Electronic", "Rock", "Jazz"], options: genreOptions,
correct: 0, correct: 0,
points: 10, points: 10,
}, });
{ }
type: "choice",
text: "Which artist shows up most often in the shared audio data?", const topArtists = getTopClusterArtists(analytics);
options: [ const topArtist = topArtists[0];
getTopArtistName(analytics), if (topArtist) {
"Artist B", const artistOptions = buildOptionsWithCorrect(topArtist, topArtists, 4);
"Artist C", if (artistOptions) {
"Artist D", questions.push({
], type: "choice",
correct: 0, text: "Which artist shows up most often in the shared audio data?",
points: 10, options: artistOptions,
}, 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,
points: 10, const topTracks = getTopClusterTracks(analytics);
}, 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");

View file

@ -3,7 +3,10 @@ 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<Extract<Question, { type: "numeric" }>, "startTimestamp" | "endTimestamp">; type NumericQuestion = Omit<
Extract<Question, { type: "numeric" }>,
"startTimestamp" | "endTimestamp"
>;
type BuildNumericQuestionInput = { type BuildNumericQuestionInput = {
db: typeof db; db: typeof db;
@ -30,10 +33,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 ?? "the album"; const subject = track?.album?.name ?? track?.name ?? "unknown album";
return { return {
type: "numeric", type: "numeric",
text: `What number best matches ${subject}?`, text: `What's the release year of ${subject}?`,
correct, correct,
range: getQuestionRange(correct, 5), range: getQuestionRange(correct, 5),
points: 10, points: 10,

View file

@ -10,8 +10,14 @@ export type PartyAnalytics = {
mostSharedGenres?: { name: string }[]; mostSharedGenres?: { name: string }[];
}; };
storyClusters?: { storyClusters?: {
tracks?: { name: string }[]; tracks?: {
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 }[];
@ -78,16 +84,66 @@ export function getQuestionRange(
}; };
} }
export function getTopGenreName(analytics: PartyAnalytics): string { export function getMostSharedGenreNames(analytics: PartyAnalytics): string[] {
return analytics?.groupSummary?.mostSharedGenres?.[0]?.name ?? "Hip-Hop"; return (analytics?.groupSummary?.mostSharedGenres ?? []).map(
(genre) => genre.name,
);
} }
export function getTopArtistName(analytics: PartyAnalytics): string { export function getTopClusterArtists(analytics: PartyAnalytics): string[] {
return analytics?.storyClusters?.[0]?.artists?.[0]?.name ?? "Artist A"; return (analytics?.storyClusters?.[0]?.artists ?? []).map(
(artist) => artist.name,
);
} }
export function getTopTrackName(analytics: PartyAnalytics): string { export function getTopClusterTracks(analytics: PartyAnalytics): Array<{
return analytics?.storyClusters?.[0]?.tracks?.[0]?.name ?? "Track A"; name: string;
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(
@ -104,6 +160,17 @@ 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[],

View file

@ -5,8 +5,12 @@ 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(
@ -16,33 +20,60 @@ export function buildSocialQuestion(
index: number, index: number,
): Question { ): Question {
type ChoiceQuestion = Extract<Question, { type: "choice" }>; type ChoiceQuestion = Extract<Question, { type: "choice" }>;
const leader = getCurrentLeader(quizState, members); const questions: Array<
const diverse = getMostDiverseMember(analytics, members); Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">
const aligned = getMostAlignedMember(analytics, members); > = [];
const questions: Array<Omit<ChoiceQuestion, "startTimestamp" | "endTimestamp">> = [ const hasMultipleMembers = members.length >= 2;
{ 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");

View file

@ -0,0 +1,47 @@
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);
}
},
}),
);

View file

@ -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";
function userTopic(userId: string) { export function userTopic(userId: string) {
return `user:${userId}`; return `user:${userId}`;
} }
function partyTopic(partyId: string) { export function partyTopic(partyId: string) {
return `party:${partyId}`; return `party:${partyId}`;
} }
const socketPartyId = new WeakMap<object, string>(); export 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 = {
}, },
}; };
async function broadcastQuizState(ws: any, partyId: string) { export 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: "snapshot", type: "party_status",
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: "snapshot", type: "party_status",
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) => {

View file

@ -5,7 +5,13 @@ 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 { Question, QuizResponse, QuizRound, QuizState } from "../party-types"; import type {
Question,
QuizResponse,
QuizRound,
QuizState,
} from "../party-types";
import { partyAnalysisWorkflow } from "./party-analysis";
const TOTAL_QUESTIONS = 5; const TOTAL_QUESTIONS = 5;
@ -36,11 +42,13 @@ 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
const members = await QuizWorkflow.getPartyMembers(partyId); let 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;
} }
@ -63,6 +71,7 @@ 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>();
@ -75,6 +84,7 @@ 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);
@ -93,6 +103,8 @@ 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;
@ -111,8 +123,7 @@ 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] = (quizState.scores[playerId] ?? 0) + gained;
(quizState.scores[playerId] ?? 0) + gained;
} }
await QuizWorkflow.updatePartyData(partyId, quizState); await QuizWorkflow.updatePartyData(partyId, quizState);
@ -164,7 +175,10 @@ export class QuizWorkflow extends ConfiguredInstance {
const ordered = round.responses const ordered = round.responses
.map((response) => ({ .map((response) => ({
response, response,
distance: Math.abs((response.selectedValue ?? response.selected) - round.question.correct), distance: Math.abs(
(response.selectedValue ?? response.selected) -
round.question.correct,
),
})) }))
.sort((a, b) => a.distance - b.distance); .sort((a, b) => a.distance - b.distance);
@ -178,9 +192,12 @@ 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) => [response.playerId, round.question.points]); return round.responses.map((response) => [
response.playerId,
round.question.points,
]);
} }
return groups.flatMap((group, index) => { return groups.flatMap((group, index) => {

34
dev-proxy/.gitignore vendored Normal file
View file

@ -0,0 +1,34 @@
# 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

15
dev-proxy/README.md Normal file
View file

@ -0,0 +1,15 @@
# 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.

26
dev-proxy/bun.lock Normal file
View file

@ -0,0 +1,26 @@
{
"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=="],
}
}

42
dev-proxy/index.ts Normal file
View file

@ -0,0 +1,42 @@
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")

12
dev-proxy/package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "dev-proxy",
"module": "index.ts",
"type": "module",
"private": true,
"devDependencies": {
"@types/bun": "latest"
},
"peerDependencies": {
"typescript": "^5"
}
}

29
dev-proxy/tsconfig.json Normal file
View file

@ -0,0 +1,29 @@
{
"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
}
}

View file

@ -3,6 +3,8 @@
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;
@ -22,7 +24,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; use ufmt::{uWrite, uwrite};
use {defmt_rtt as _, panic_probe as _}; use {defmt_rtt as _, panic_probe as _};
bind_interrupts!(struct Irqs { bind_interrupts!(struct Irqs {
@ -49,7 +51,7 @@ async fn net_task(mut runner: embassy_net::Runner<'static, cyw43::NetDriver<'sta
runner.run().await runner.run().await
} }
const WIFI_NETWORK: &str = "flamme"; const WIFI_NETWORK: &str = "aura";
const WIFI_PASSWORD: &str = "12345678"; const WIFI_PASSWORD: &str = "12345678";
#[embassy_executor::main] #[embassy_executor::main]
@ -105,6 +107,8 @@ 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);
@ -122,6 +126,8 @@ 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
@ -139,11 +145,13 @@ 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();
uwrite!(lcd, "IP address: {:?}", local_addr.octets()); info!("IP address: {:?}", local_addr);
// 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);
@ -162,9 +170,23 @@ async fn main(spawner: Spawner) {
led.set_low(); led.set_low();
info!("Connecting..."); info!("Connecting...");
let host_addr = embassy_net::Ipv4Address::from_str("84.238.32.253").unwrap(); uwrite!(lcd, "con2.");
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());
@ -178,7 +200,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::write!( let _ = core::writeln!(
&mut buffer[..], &mut buffer[..],
"in1={} in2={} in3={} in4={} angle={}", "in1={} in2={} in3={} in4={} angle={}",
in1, in1,
@ -192,6 +214,22 @@ 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;
} }
} }

View file

@ -132,12 +132,6 @@ 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}
@ -148,7 +142,7 @@ export function Question() {
? [currentNumericSelection] ? [currentNumericSelection]
: undefined : undefined
} }
onValueChange={(value) => setSelectedValue(value[0] ?? null)} onValueChange={(value) => setSelectedValue(typeof value === "number" ? value : value[0] ?? null)}
/> />
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
Exact value:{" "} Exact value:{" "}