waiting for party state

This commit is contained in:
Daniel Bulant 2026-05-12 22:54:23 +02:00
parent 0daba1bd73
commit cf66d9af6d
No known key found for this signature in database
3 changed files with 115 additions and 13 deletions

View file

@ -10,8 +10,14 @@ import { pubsub, topic } from "./party-socket";
type DeviceSocketMessage = type DeviceSocketMessage =
| { type: "device_message"; deviceId: string; payload: unknown } | { type: "device_message"; deviceId: string; payload: unknown }
| { type: "device_connected"; deviceId: string }
| { type: "hello" } | { type: "hello" }
| { type: "device_event"; deviceId: string; event: PartySocketEvent }; | { type: "device_event"; deviceId: string; event: DeviceProxyEvent };
type DeviceProxyEvent =
| PartySocketEvent
| { type: "device_connect_required" }
| { type: "device_connected" };
type DeviceQuizResponsePayload = { type DeviceQuizResponsePayload = {
QuizResponse: number; QuizResponse: number;
@ -30,6 +36,17 @@ function isDeviceMessage(
); );
} }
function isDeviceConnectedMessage(
value: unknown,
): value is Extract<DeviceSocketMessage, { type: "device_connected" }> {
return (
typeof value === "object" &&
value !== null &&
(value as { type?: unknown }).type === "device_connected" &&
typeof (value as { deviceId?: unknown }).deviceId === "string"
);
}
function isDeviceQuizResponsePayload( function isDeviceQuizResponsePayload(
value: unknown, value: unknown,
): value is DeviceQuizResponsePayload { ): value is DeviceQuizResponsePayload {
@ -41,7 +58,7 @@ function isDeviceQuizResponsePayload(
); );
} }
function sendDeviceEvent(deviceId: string, event: PartySocketEvent) { function sendDeviceEvent(deviceId: string, event: DeviceProxyEvent) {
if (!devProxySocket || devProxySocket.readyState !== WebSocket.OPEN) return; if (!devProxySocket || devProxySocket.readyState !== WebSocket.OPEN) return;
devProxySocket.send( devProxySocket.send(
@ -53,6 +70,25 @@ function sendDeviceEvent(deviceId: string, event: PartySocketEvent) {
); );
} }
async function syncDeviceConnectionStatus(deviceId: string) {
const device = await db
.select()
.from(deviceConnection)
.where(eq(deviceConnection.id, deviceId))
.then((rows) => rows[0]);
if (!device) {
sendDeviceEvent(deviceId, { type: "device_connect_required" });
return;
}
await db
.update(deviceConnection)
.set({ lastSeen: new Date() })
.where(eq(deviceConnection.id, deviceId));
sendDeviceEvent(deviceId, { type: "device_connected" });
}
export async function claimDeviceForUser(deviceId: string, userId: string) { export async function claimDeviceForUser(deviceId: string, userId: string) {
await db await db
.insert(deviceConnection) .insert(deviceConnection)
@ -181,8 +217,14 @@ export const deviceSocketApp = new Elysia().group("/dev-socket", (app) =>
return; return;
} }
if (!isDeviceMessage(parsed)) return; if (isDeviceConnectedMessage(parsed)) {
await forwardDevicePayload(parsed.deviceId, parsed.payload); await syncDeviceConnectionStatus(parsed.deviceId);
return;
}
if (isDeviceMessage(parsed)) {
await forwardDevicePayload(parsed.deviceId, parsed.payload);
}
}, },
close() { close() {
if (devProxySocket === null) return; if (devProxySocket === null) return;
@ -198,6 +240,7 @@ export const deviceClaimApp = new Elysia()
"/:deviceId/connect", "/:deviceId/connect",
async ({ user, params }) => { async ({ user, params }) => {
await claimDeviceForUser(params.deviceId, user.id); await claimDeviceForUser(params.deviceId, user.id);
sendDeviceEvent(params.deviceId, { type: "device_connected" });
return { ok: true, deviceId: params.deviceId, userId: user.id }; return { ok: true, deviceId: params.deviceId, userId: user.id };
}, },
{ auth: true }, { auth: true },

View file

@ -21,10 +21,15 @@ type DeviceQuestionData = {
type ProxyOutput = type ProxyOutput =
| { ConnectPrompt: string } | { ConnectPrompt: string }
| "WaitingForParty"
| { Question: DeviceQuestionData } | { Question: DeviceQuestionData }
| "Results" | "Results"
| { Error: string }; | { Error: string };
type ApiMessage =
| { type: "device_connected"; deviceId: string }
| { type: "device_message"; deviceId: string; payload: unknown };
type QuizQuestion = type QuizQuestion =
| { | {
type: "choice"; type: "choice";
@ -59,7 +64,15 @@ type ErrorEvent = {
message: string; message: string;
}; };
type PartySocketEvent = PartyStatusEvent | QuizStateEvent | ErrorEvent; type DeviceLifecycleEvent =
| { type: "device_connect_required" }
| { type: "device_connected" };
type PartySocketEvent =
| PartyStatusEvent
| QuizStateEvent
| ErrorEvent
| DeviceLifecycleEvent;
const sockets = new Map<string, Socket>(); const sockets = new Map<string, Socket>();
const socketIds = new WeakMap<Socket, string>(); const socketIds = new WeakMap<Socket, string>();
@ -81,6 +94,12 @@ function writeProxyOutput(socket: Socket, output: ProxyOutput) {
socket.write(`${JSON.stringify(output)}\n`); socket.write(`${JSON.stringify(output)}\n`);
} }
function sendApiMessage(message: ApiMessage) {
if (apiSocket.readyState !== WebSocket.OPEN) return false;
apiSocket.send(JSON.stringify(message));
return true;
}
function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null { function toDeviceQuestionData(quizData: QuizState): DeviceQuestionData | null {
if (!quizData.currentQuestion) return null; if (!quizData.currentQuestion) return null;
const question = quizData.currentQuestion; const question = quizData.currentQuestion;
@ -118,19 +137,23 @@ const listener = Bun.listen({
if ("DeviceId" in data) { if ("DeviceId" in data) {
registerSocket(socket, data.DeviceId); registerSocket(socket, data.DeviceId);
writeProxyOutput(socket, { ConnectPrompt: data.DeviceId }); if (
!sendApiMessage({ type: "device_connected", deviceId: data.DeviceId })
) {
writeProxyOutput(socket, {
Error: "API device socket not connected.",
});
}
return; return;
} }
if ("QuizResponse" in data) { if ("QuizResponse" in data) {
const deviceId = socketDeviceId(socket); const deviceId = socketDeviceId(socket);
if (!deviceId) return; if (!deviceId) return;
apiSocket?.send( sendApiMessage({
JSON.stringify({ type: "device_message",
type: "device_message", deviceId,
deviceId, payload: { QuizResponse: data.QuizResponse },
payload: { QuizResponse: data.QuizResponse }, });
}),
);
return; return;
} }
}, },
@ -156,6 +179,16 @@ apiSocket.onmessage = (e) => {
const socket = sockets.get(message.deviceId); const socket = sockets.get(message.deviceId);
if (!socket) return; if (!socket) return;
const event = message.event as PartySocketEvent; const event = message.event as PartySocketEvent;
if (event.type === "device_connect_required") {
writeProxyOutput(socket, { ConnectPrompt: message.deviceId });
return;
}
if (event.type === "device_connected") {
writeProxyOutput(socket, "WaitingForParty");
return;
}
if (event.type === "error") { if (event.type === "error") {
writeProxyOutput(socket, { Error: event.message }); writeProxyOutput(socket, { Error: event.message });
return; return;

View file

@ -17,6 +17,7 @@ const DOT: char = char::from_u32(0b1010_0101).unwrap();
pub enum ViewState { pub enum ViewState {
Loading, Loading,
ConnectPrompt, ConnectPrompt,
WaitingForParty,
Question, Question,
Results, Results,
} }
@ -46,6 +47,7 @@ pub struct QuestionDataNet<'a> {
#[derive(Deserialize)] #[derive(Deserialize)]
pub enum ProxyOutput<'a> { pub enum ProxyOutput<'a> {
ConnectPrompt(&'a str), ConnectPrompt(&'a str),
WaitingForParty,
Question(QuestionDataNet<'a>), Question(QuestionDataNet<'a>),
Results, Results,
Error(&'a str), Error(&'a str),
@ -134,6 +136,10 @@ impl DeviceState {
self.question = None; self.question = None;
self.view = ViewState::ConnectPrompt; self.view = ViewState::ConnectPrompt;
} }
ProxyOutput::WaitingForParty => {
self.question = None;
self.view = ViewState::WaitingForParty;
}
ProxyOutput::Question(data) => { ProxyOutput::Question(data) => {
let data: QuestionData = data.into(); let data: QuestionData = data.into();
let mut future_wheel = WheelData::empty(); let mut future_wheel = WheelData::empty();
@ -186,6 +192,13 @@ impl DeviceState {
)); ));
} }
if self.view == ViewState::WaitingForParty {
return Some((
OwnedStr::from_str("Connected").unwrap(),
OwnedStr::from_str("Waiting party").unwrap(),
));
}
if self.view != ViewState::Question { if self.view != ViewState::Question {
return None; return None;
} }
@ -370,6 +383,19 @@ mod tests {
assert_eq!(state.view_state(), ViewState::Results); assert_eq!(state.view_state(), ViewState::Results);
} }
#[test]
fn parses_and_renders_waiting_for_party() {
let data = parse_proxy_output(r#""WaitingForParty""#).unwrap();
let mut state = DeviceState::new();
state.apply_proxy_output(data);
assert_eq!(state.view_state(), ViewState::WaitingForParty);
let (line1, line2) = state.render_lines().unwrap();
assert_eq!(line1.as_str(), "Connected");
assert_eq!(line2.as_str(), "Waiting party");
}
#[test] #[test]
fn wraps_forward_across_zero() { fn wraps_forward_across_zero() {
assert_eq!(wheel_delta(4090, 5, false), 11); assert_eq!(wheel_delta(4090, 5, false), 11);