Add desktop ready checks, smarter chat scrolling, and fight-night reactions
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user