This commit is contained in:
parent
52615a5086
commit
e6fa86519c
16 changed files with 3277 additions and 6 deletions
452
server/dice-sync.mjs
Normal file
452
server/dice-sync.mjs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
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 };
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue