452 lines
12 KiB
JavaScript
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 };
|
|
};
|