interactives/server/dice-sync.mjs
Neeldhara Misra e6fa86519c
Some checks failed
CI / validate (push) Has been cancelled
Add Akkam Bakkam classroom game
2026-07-20 17:23:12 +05:30

452 lines
12 KiB
JavaScript

import { randomInt, randomUUID } from "node:crypto";
import { WebSocket, WebSocketServer } from "ws";
const ROOM_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
const ROOM_TTL_MS = 12 * 60 * 60 * 1000;
const MAX_ROOMS = 200;
const MAX_ROLLS = 120;
const MIN_ROLL_INTERVAL_MS = 350;
const DEFAULT_LEVEL_SETTINGS = Object.freeze({
gridSize: 5,
undoLimit: 3,
rerollLimit: 3,
});
const NAME_ADJECTIVES = [
"Bouncy",
"Brave",
"Curious",
"Dapper",
"Dizzy",
"Gentle",
"Jolly",
"Merry",
"Mighty",
"Nimble",
"Peppy",
"Quiet",
"Silly",
"Sunny",
"Wiggly",
"Wobbly",
];
const NAME_NOUNS = [
"Badger",
"Biscuit",
"Crumpet",
"Doodle",
"Kettle",
"Mango",
"Marble",
"Otter",
"Pancake",
"Penguin",
"Pickle",
"Puffin",
"Rocket",
"Teacup",
"Turnip",
"Wombat",
];
const makeRoomId = () =>
Array.from(
{ length: 6 },
() => ROOM_ALPHABET[randomInt(ROOM_ALPHABET.length)],
).join("");
const parseLevelSettings = (candidate) => {
const settings = candidate ?? DEFAULT_LEVEL_SETTINGS;
if (!settings || typeof settings !== "object") return null;
const { gridSize, undoLimit, rerollLimit } = settings;
if (
!Number.isInteger(gridSize) ||
gridSize < 5 ||
gridSize > 9 ||
!Number.isInteger(undoLimit) ||
undoLimit < 0 ||
undoLimit > 9 ||
!Number.isInteger(rerollLimit) ||
rerollLimit < 0 ||
rerollLimit > 9
) {
return null;
}
return { gridSize, undoLimit, rerollLimit };
};
const makePlayerName = (room) => {
const usedNames = new Set(
[...room.players.values()].map((player) => player.name),
);
for (let attempt = 0; attempt < 100; attempt += 1) {
const name = `${NAME_ADJECTIVES[randomInt(NAME_ADJECTIVES.length)]} ${NAME_NOUNS[randomInt(NAME_NOUNS.length)]}`;
if (!usedNames.has(name)) return name;
}
return `Jolly Marble ${room.players.size + 1}`;
};
const send = (socket, message) => {
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify(message));
}
};
const leaderboardFor = (room) =>
[...room.players.values()]
.map(({ token: _token, ...player }) => player)
.sort(
(left, right) =>
right.score - left.score ||
right.placed - left.placed ||
left.name.localeCompare(right.name),
);
const roomSnapshot = (room, socket, hostToken) => ({
type: "room-state",
roomId: room.id,
roundId: room.roundId,
isHost: hostToken === room.hostToken,
participants: room.clients.size,
history: room.history,
playerId: socket.playerToken,
playerName: room.players.get(socket.playerToken)?.name,
leaderboard: leaderboardFor(room),
settings: room.settings,
rerollsUsed: room.rerollsUsed,
});
export const attachDiceSyncServer = (httpServer) => {
const rooms = new Map();
const wss = new WebSocketServer({
server: httpServer,
path: "/sync",
maxPayload: 2048,
});
const broadcast = (room, message) => {
for (const client of room.clients) send(client, message);
};
const broadcastPresence = (room) => {
broadcast(room, { type: "presence", participants: room.clients.size });
};
const broadcastLeaderboard = (room) => {
broadcast(room, { type: "leaderboard", leaderboard: leaderboardFor(room) });
};
const leaveCurrentRoom = (socket) => {
const roomId = socket.roomId;
if (!roomId) return;
const room = rooms.get(roomId);
socket.roomId = undefined;
socket.hostToken = undefined;
if (!room) return;
room.clients.delete(socket);
const player = room.players.get(socket.playerToken);
if (player) {
player.connected = [...room.clients].some(
(client) => client.playerToken === socket.playerToken,
);
}
room.lastActiveAt = Date.now();
broadcastPresence(room);
broadcastLeaderboard(room);
};
const joinRoom = (socket, room, hostToken, requestedPlayerToken) => {
leaveCurrentRoom(socket);
let player = requestedPlayerToken
? room.players.get(requestedPlayerToken)
: null;
if (!player) {
const token = randomUUID();
player = {
id: token,
token,
name: makePlayerName(room),
score: 0,
placed: 0,
finished: false,
connected: true,
};
room.players.set(token, player);
}
player.connected = true;
socket.roomId = room.id;
socket.hostToken = hostToken;
socket.playerToken = player.token;
room.clients.add(socket);
room.lastActiveAt = Date.now();
send(socket, roomSnapshot(room, socket, hostToken));
send(socket, {
type: "player-token",
roomId: room.id,
playerToken: player.token,
});
broadcastPresence(room);
broadcastLeaderboard(room);
};
const createRoom = (socket, requestedSettings) => {
if (rooms.size >= MAX_ROOMS) {
send(socket, {
type: "error",
message: "The classroom server is full. Please try again shortly.",
});
return;
}
const settings = parseLevelSettings(requestedSettings);
if (!settings) {
send(socket, {
type: "error",
message: "Those level settings are not valid.",
});
return;
}
let id = makeRoomId();
while (rooms.has(id)) id = makeRoomId();
const hostToken = randomUUID();
const room = {
id,
hostToken,
roundId: randomUUID(),
clients: new Set(),
players: new Map(),
history: [],
settings,
rerollsUsed: 0,
createdAt: Date.now(),
lastActiveAt: Date.now(),
lastRollAt: 0,
};
rooms.set(id, room);
joinRoom(socket, room, hostToken);
send(socket, { type: "host-token", roomId: id, hostToken });
};
const requireHost = (socket) => {
const room = rooms.get(socket.roomId);
if (!room) {
send(socket, {
type: "error",
message: "This classroom room is no longer available.",
});
return null;
}
if (socket.hostToken !== room.hostToken) {
send(socket, {
type: "error",
message: "Only the room host can change the classroom game.",
});
return null;
}
return room;
};
wss.on("connection", (socket) => {
socket.on("message", (raw) => {
let message;
try {
message = JSON.parse(raw.toString());
} catch {
send(socket, {
type: "error",
message: "That classroom message could not be read.",
});
return;
}
if (message.type === "create") {
createRoom(socket, message.settings);
return;
}
if (message.type === "configure") {
const room = requireHost(socket);
if (!room) return;
if (room.history.length > 0) {
send(socket, {
type: "error",
message:
"Level settings can only be changed before the first roll.",
});
return;
}
const settings = parseLevelSettings(message.settings);
if (!settings) {
send(socket, {
type: "error",
message: "Those level settings are not valid.",
});
return;
}
room.settings = settings;
room.rerollsUsed = 0;
room.lastActiveAt = Date.now();
broadcast(room, {
type: "settings",
settings: room.settings,
rerollsUsed: room.rerollsUsed,
});
return;
}
if (message.type === "join") {
const roomId = String(message.roomId ?? "")
.trim()
.toUpperCase();
const room = rooms.get(roomId);
if (!room) {
send(socket, {
type: "error",
message: `No active classroom game was found for ${roomId || "that ID"}.`,
});
return;
}
const hostToken =
typeof message.hostToken === "string" ? message.hostToken : undefined;
const playerToken =
typeof message.playerToken === "string"
? message.playerToken
: undefined;
joinRoom(socket, room, hostToken, playerToken);
return;
}
if (message.type === "progress") {
const room = rooms.get(socket.roomId);
const player = room?.players.get(socket.playerToken);
if (!room || !player) return;
player.score = Math.max(
0,
Math.min(10000, Math.round(Number(message.score) || 0)),
);
player.placed = Math.max(
0,
Math.min(MAX_ROLLS, Math.round(Number(message.placed) || 0)),
);
player.finished = Boolean(message.finished);
room.lastActiveAt = Date.now();
broadcastLeaderboard(room);
return;
}
if (message.type === "roll") {
const room = requireHost(socket);
if (!room) return;
const now = Date.now();
if (now - room.lastRollAt < MIN_ROLL_INTERVAL_MS) return;
if (room.history.length >= MAX_ROLLS) {
send(socket, {
type: "error",
message:
"This round has reached its roll limit. Start a new round to continue.",
});
return;
}
const dice = [randomInt(1, 7), randomInt(1, 7)];
const roll = {
id: room.history.length + 1,
dice,
sum: dice[0] + dice[1],
rolledAt: now,
};
room.history.push(roll);
room.lastRollAt = now;
room.lastActiveAt = now;
broadcast(room, { type: "rolled", roundId: room.roundId, roll });
return;
}
if (message.type === "reroll") {
const room = requireHost(socket);
if (!room) return;
if (room.history.length === 0) {
send(socket, {
type: "error",
message: "Roll the dice before using a re-roll.",
});
return;
}
if (room.rerollsUsed >= room.settings.rerollLimit) {
send(socket, {
type: "error",
message: "This round has no re-rolls remaining.",
});
return;
}
if (
[...room.players.values()].some(
(player) => player.placed >= room.history.length,
)
) {
send(socket, {
type: "error",
message: "That roll has already been placed by a player.",
});
return;
}
const now = Date.now();
const dice = [randomInt(1, 7), randomInt(1, 7)];
const previousRoll = room.history.at(-1);
const roll = {
id: previousRoll.id,
dice,
sum: dice[0] + dice[1],
rolledAt: now,
};
room.history[room.history.length - 1] = roll;
room.rerollsUsed += 1;
room.lastActiveAt = now;
broadcast(room, {
type: "rerolled",
roundId: room.roundId,
roll,
rerollsUsed: room.rerollsUsed,
});
return;
}
if (message.type === "reset") {
const room = requireHost(socket);
if (!room) return;
room.roundId = randomUUID();
room.history = [];
room.rerollsUsed = 0;
for (const player of room.players.values()) {
player.score = 0;
player.placed = 0;
player.finished = false;
}
room.lastRollAt = 0;
room.lastActiveAt = Date.now();
broadcast(room, {
type: "round-reset",
roundId: room.roundId,
rerollsUsed: room.rerollsUsed,
});
broadcastLeaderboard(room);
}
});
socket.on("close", () => leaveCurrentRoom(socket));
});
const pruneTimer = setInterval(
() => {
const cutoff = Date.now() - ROOM_TTL_MS;
for (const [id, room] of rooms) {
if (room.clients.size === 0 && room.lastActiveAt < cutoff)
rooms.delete(id);
}
},
30 * 60 * 1000,
);
pruneTimer.unref();
wss.on("close", () => clearInterval(pruneTimer));
return { wss, rooms };
};