Add desktop ready checks, smarter chat scrolling, and fight-night reactions

This commit is contained in:
Peter Stockings
2026-10-05 15:46:55 +11:00
parent 19576de166
commit f3eeda71e6
13 changed files with 1228 additions and 10 deletions
+133
View File
@@ -16,6 +16,9 @@ interface RoomState {
lastUpdate: number; // timestamp when position was last set
users: Map<string, WebSocket>;
chatHistory: ChatMessage[];
readyCheck?: { id: string; requestedBy: string; ready: Set<string> };
lastReactionAt?: number;
fightPoll?: { id: string; expiresAt: number; votes: Map<string, "yes" | "no"> };
}
interface ChatMessage {
@@ -56,6 +59,29 @@ function getUserList(room: RoomState): string[] {
return Array.from(room.users.keys());
}
function readyCheckState(room: RoomState) {
if (!room.readyCheck) return { type: "ready_check", active: false };
return {
type: "ready_check", active: true,
id: room.readyCheck.id, requestedBy: room.readyCheck.requestedBy,
participants: Array.from(room.users, ([username, ws]) => ({
username, ready: room.readyCheck!.ready.has(username), connected: ws.readyState === 1,
})),
};
}
function broadcastReadyCheck(room: RoomState) {
broadcastToRoom(room, readyCheckState(room));
}
function fightPollState(room: RoomState) {
const poll = room.fightPoll;
if (!poll) return { type: "fight_poll", active: false };
return { type: "fight_poll", id: poll.id, active: Date.now() < poll.expiresAt,
expiresAt: poll.expiresAt,
votes: Object.fromEntries(Array.from(poll.votes).filter(([username]) => room.users.has(username))) };
}
// --- MIME Types ---
const mimeTypes: Record<string, string> = {
".html": "text/html",
@@ -253,6 +279,8 @@ const server = Bun.serve<WSData>({
position: getCurrentPosition(room),
},
chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
})
);
@@ -267,12 +295,17 @@ const server = Bun.serve<WSData>({
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} joined`);
if (room.readyCheck) broadcastReadyCheck(room);
break;
}
case "sync": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
if (msg.action === "play" && room.readyCheck) {
room.readyCheck = undefined;
broadcastReadyCheck(room);
}
// Update authoritative state
if (msg.action === "play") {
@@ -305,6 +338,89 @@ const server = Bun.serve<WSData>({
break;
}
case "ready_check": {
const room = rooms.get(ws.data.roomCode);
const username = ws.data.username;
if (!room || room.users.get(username) !== (ws as unknown as WebSocket)) return;
if (msg.action === "begin") {
// Repeating /ready shows the existing check without resetting votes.
if (!room.readyCheck) {
room.readyCheck = { id: crypto.randomUUID(), requestedBy: username, ready: new Set() };
room.position = getCurrentPosition(room);
room.playing = false;
room.lastUpdate = Date.now();
broadcastToRoom(room, { type: "sync", action: "pause", position: room.position, playing: false });
}
} else {
const check = room.readyCheck;
if (!check || msg.id !== check.id) return;
if (msg.action === "vote" && typeof msg.ready === "boolean") {
if (msg.ready) check.ready.add(username);
else check.ready.delete(username);
} else if (msg.action === "cancel" && check.requestedBy === username) {
room.readyCheck = undefined;
} else if (msg.action === "start" && check.requestedBy === username) {
const everyoneReady = Array.from(room.users).every(([name, peer]) =>
peer.readyState === 1 && check.ready.has(name));
if (!everyoneReady) return;
room.readyCheck = undefined;
room.playing = true;
room.lastUpdate = Date.now();
broadcastToRoom(room, { type: "sync", action: "play", position: room.position, playing: true });
} else return;
}
broadcastReadyCheck(room);
break;
}
case "reaction": {
const room = rooms.get(ws.data.roomCode);
if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
if (!["ko", "bell", "tap", "robbery"].includes(msg.kind)) {
ws.send(JSON.stringify({ type: "reaction_error", message: "Unknown fight reaction." }));
return;
}
const now = Date.now();
if (room.lastReactionAt !== undefined && now - room.lastReactionAt < 3000) {
ws.send(JSON.stringify({ type: "reaction_error", message: "Fight reactions have a 3-second room cooldown." }));
return;
}
if (msg.kind === "robbery" && room.fightPoll && now < room.fightPoll.expiresAt) {
ws.send(JSON.stringify({ type: "reaction_error", message: "A robbery vote is already open." }));
ws.send(JSON.stringify(fightPollState(room)));
return;
}
room.lastReactionAt = now;
broadcastToRoom(room, { type: "reaction", kind: msg.kind,
username: ws.data.username, position: getCurrentPosition(room), timestamp: now });
const chatMsg = { username: ws.data.username, message: `🥊 /${msg.kind}`, timestamp: now };
room.chatHistory.push(chatMsg);
if (room.chatHistory.length > 200) room.chatHistory = room.chatHistory.slice(-100);
broadcastToRoom(room, { type: "chat", ...chatMsg });
if (msg.kind === "robbery") {
const poll = { id: crypto.randomUUID(), expiresAt: now + 30_000, votes: new Map<string, "yes" | "no">() };
room.fightPoll = poll;
broadcastToRoom(room, fightPollState(room));
setTimeout(() => {
if (rooms.get(room.code) === room && room.fightPoll?.id === poll.id) {
broadcastToRoom(room, fightPollState(room));
}
}, 30_000);
}
break;
}
case "fight_poll_vote": {
const room = rooms.get(ws.data.roomCode);
if (!room || room.users.get(ws.data.username) !== (ws as unknown as WebSocket)) return;
const poll = room.fightPoll;
if (!poll || poll.id !== msg.id || Date.now() >= poll.expiresAt) return;
if (msg.choice !== "yes" && msg.choice !== "no") return;
poll.votes.set(ws.data.username, msg.choice);
broadcastToRoom(room, fightPollState(room));
break;
}
case "chat": {
const room = rooms.get(ws.data.roomCode);
if (!room) return;
@@ -386,6 +502,8 @@ const server = Bun.serve<WSData>({
position: getCurrentPosition(room),
},
chatHistory: room.chatHistory.slice(-50),
readyCheck: readyCheckState(room),
fightPoll: fightPollState(room),
})
);
@@ -400,6 +518,7 @@ const server = Bun.serve<WSData>({
ws as unknown as WebSocket
);
console.log(`[Room ${code}] ${username} rejoined`);
if (room.readyCheck) broadcastReadyCheck(room);
break;
}
}
@@ -411,6 +530,11 @@ const server = Bun.serve<WSData>({
const room = rooms.get(roomCode);
if (!room) return;
if (room.users.get(username) !== (ws as unknown as WebSocket)) return;
if (room.readyCheck) {
room.readyCheck.ready.delete(username);
broadcastReadyCheck(room);
}
// Don't remove immediately — give a 90s grace period for reconnection
const disconnectKey = `${roomCode}:${username}`;
@@ -425,6 +549,15 @@ const server = Bun.serve<WSData>({
const currentWs = currentRoom.users.get(username);
if (currentWs === (ws as unknown as WebSocket)) {
currentRoom.users.delete(username);
if (currentRoom.fightPoll) {
currentRoom.fightPoll.votes.delete(username);
broadcastToRoom(currentRoom, fightPollState(currentRoom));
}
if (currentRoom.readyCheck) {
currentRoom.readyCheck.ready.delete(username);
if (currentRoom.readyCheck.requestedBy === username) currentRoom.readyCheck = undefined;
broadcastReadyCheck(currentRoom);
}
console.log(`[Room ${roomCode}] ${username} removed (grace period expired)`);
if (currentRoom.users.size === 0) {