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 };
|
||||
};
|
||||
207
server/dice-sync.test.mjs
Normal file
207
server/dice-sync.test.mjs
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { createServer } from "node:http";
|
||||
import test from "node:test";
|
||||
import { WebSocket } from "ws";
|
||||
import { attachDiceSyncServer } from "./dice-sync.mjs";
|
||||
|
||||
const createInbox = (socket) => {
|
||||
const messages = [];
|
||||
const waiters = [];
|
||||
socket.on("message", (raw) => {
|
||||
const message = JSON.parse(raw.toString());
|
||||
const waiterIndex = waiters.findIndex(
|
||||
(waiter) => waiter.type === message.type && waiter.predicate(message),
|
||||
);
|
||||
if (waiterIndex >= 0) {
|
||||
const [waiter] = waiters.splice(waiterIndex, 1);
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.resolve(message);
|
||||
} else {
|
||||
messages.push(message);
|
||||
}
|
||||
});
|
||||
return {
|
||||
next(type, predicate = () => true) {
|
||||
const messageIndex = messages.findIndex(
|
||||
(message) => message.type === type && predicate(message),
|
||||
);
|
||||
if (messageIndex >= 0)
|
||||
return Promise.resolve(messages.splice(messageIndex, 1)[0]);
|
||||
return new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error(`Timed out waiting for ${type}`)),
|
||||
2000,
|
||||
);
|
||||
waiters.push({ type, predicate, resolve, timeout });
|
||||
});
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const connect = (url) =>
|
||||
new Promise((resolve, reject) => {
|
||||
const socket = new WebSocket(url);
|
||||
socket.once("open", () => resolve(socket));
|
||||
socket.once("error", reject);
|
||||
});
|
||||
|
||||
test("a host creates a room and broadcasts one authoritative roll", async () => {
|
||||
const httpServer = createServer();
|
||||
const { wss } = attachDiceSyncServer(httpServer);
|
||||
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
|
||||
const address = httpServer.address();
|
||||
const url = `ws://127.0.0.1:${address.port}/sync`;
|
||||
const host = await connect(url);
|
||||
const guest = await connect(url);
|
||||
const hostInbox = createInbox(host);
|
||||
const guestInbox = createInbox(guest);
|
||||
|
||||
host.send(JSON.stringify({ type: "create" }));
|
||||
const hostState = await hostInbox.next("room-state");
|
||||
const tokenMessage = await hostInbox.next("host-token");
|
||||
assert.equal(hostState.isHost, true);
|
||||
assert.equal(tokenMessage.roomId, hostState.roomId);
|
||||
|
||||
guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
|
||||
const guestState = await guestInbox.next("room-state");
|
||||
assert.equal(guestState.isHost, false);
|
||||
|
||||
const hostRoll = hostInbox.next("rolled");
|
||||
const guestRoll = guestInbox.next("rolled");
|
||||
host.send(JSON.stringify({ type: "roll" }));
|
||||
const [hostMessage, guestMessage] = await Promise.all([hostRoll, guestRoll]);
|
||||
assert.deepEqual(guestMessage.roll, hostMessage.roll);
|
||||
assert.equal(
|
||||
hostMessage.roll.sum,
|
||||
hostMessage.roll.dice[0] + hostMessage.roll.dice[1],
|
||||
);
|
||||
|
||||
const forbidden = guestInbox.next("error");
|
||||
guest.send(JSON.stringify({ type: "roll" }));
|
||||
assert.match((await forbidden).message, /Only the room host/);
|
||||
|
||||
host.terminate();
|
||||
guest.terminate();
|
||||
await new Promise((resolve) => wss.close(resolve));
|
||||
await new Promise((resolve) => httpServer.close(resolve));
|
||||
});
|
||||
|
||||
test("level settings and re-rolls stay authoritative across a classroom", async () => {
|
||||
const httpServer = createServer();
|
||||
const { wss } = attachDiceSyncServer(httpServer);
|
||||
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
|
||||
const address = httpServer.address();
|
||||
const url = `ws://127.0.0.1:${address.port}/sync`;
|
||||
const host = await connect(url);
|
||||
const guest = await connect(url);
|
||||
const hostInbox = createInbox(host);
|
||||
const guestInbox = createInbox(guest);
|
||||
|
||||
host.send(
|
||||
JSON.stringify({
|
||||
type: "create",
|
||||
settings: { gridSize: 9, undoLimit: 0, rerollLimit: 2 },
|
||||
}),
|
||||
);
|
||||
const hostState = await hostInbox.next("room-state");
|
||||
await hostInbox.next("host-token");
|
||||
assert.deepEqual(hostState.settings, {
|
||||
gridSize: 9,
|
||||
undoLimit: 0,
|
||||
rerollLimit: 2,
|
||||
});
|
||||
assert.equal(hostState.rerollsUsed, 0);
|
||||
|
||||
guest.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
|
||||
const guestState = await guestInbox.next("room-state");
|
||||
assert.deepEqual(guestState.settings, hostState.settings);
|
||||
|
||||
const configuredSettings = {
|
||||
gridSize: 8,
|
||||
undoLimit: 4,
|
||||
rerollLimit: 1,
|
||||
};
|
||||
const hostSettings = hostInbox.next("settings");
|
||||
const guestSettings = guestInbox.next("settings");
|
||||
host.send(
|
||||
JSON.stringify({ type: "configure", settings: configuredSettings }),
|
||||
);
|
||||
const [hostSettingsMessage, guestSettingsMessage] = await Promise.all([
|
||||
hostSettings,
|
||||
guestSettings,
|
||||
]);
|
||||
assert.deepEqual(hostSettingsMessage.settings, configuredSettings);
|
||||
assert.deepEqual(guestSettingsMessage.settings, configuredSettings);
|
||||
|
||||
const forbiddenConfiguration = guestInbox.next("error");
|
||||
guest.send(
|
||||
JSON.stringify({
|
||||
type: "configure",
|
||||
settings: { gridSize: 5, undoLimit: 3, rerollLimit: 3 },
|
||||
}),
|
||||
);
|
||||
assert.match((await forbiddenConfiguration).message, /Only the room host/);
|
||||
|
||||
const firstHostRoll = hostInbox.next("rolled");
|
||||
const firstGuestRoll = guestInbox.next("rolled");
|
||||
host.send(JSON.stringify({ type: "roll" }));
|
||||
const [hostRollMessage] = await Promise.all([firstHostRoll, firstGuestRoll]);
|
||||
|
||||
const hostReroll = hostInbox.next("rerolled");
|
||||
const guestReroll = guestInbox.next("rerolled");
|
||||
host.send(JSON.stringify({ type: "reroll" }));
|
||||
const [hostRerollMessage, guestRerollMessage] = await Promise.all([
|
||||
hostReroll,
|
||||
guestReroll,
|
||||
]);
|
||||
assert.deepEqual(guestRerollMessage.roll, hostRerollMessage.roll);
|
||||
assert.equal(hostRerollMessage.roll.id, hostRollMessage.roll.id);
|
||||
assert.equal(hostRerollMessage.rerollsUsed, 1);
|
||||
|
||||
const exhausted = hostInbox.next("error");
|
||||
host.send(JSON.stringify({ type: "reroll" }));
|
||||
assert.match((await exhausted).message, /no re-rolls remaining/);
|
||||
|
||||
const firstHostReset = hostInbox.next("round-reset");
|
||||
const firstGuestReset = guestInbox.next("round-reset");
|
||||
host.send(JSON.stringify({ type: "reset" }));
|
||||
const [resetMessage] = await Promise.all([firstHostReset, firstGuestReset]);
|
||||
assert.equal(resetMessage.rerollsUsed, 0);
|
||||
|
||||
const secondHostRoll = hostInbox.next("rolled");
|
||||
const secondGuestRoll = guestInbox.next("rolled");
|
||||
host.send(JSON.stringify({ type: "roll" }));
|
||||
await Promise.all([secondHostRoll, secondGuestRoll]);
|
||||
|
||||
const placedUpdate = hostInbox.next("leaderboard", (message) =>
|
||||
message.leaderboard.some(
|
||||
(player) => player.id === guestState.playerId && player.placed === 1,
|
||||
),
|
||||
);
|
||||
guest.send(
|
||||
JSON.stringify({ type: "progress", score: 8, placed: 1, finished: false }),
|
||||
);
|
||||
await placedUpdate;
|
||||
|
||||
const alreadyPlaced = hostInbox.next("error");
|
||||
host.send(JSON.stringify({ type: "reroll" }));
|
||||
assert.match((await alreadyPlaced).message, /already been placed/);
|
||||
|
||||
const secondHostReset = hostInbox.next("round-reset");
|
||||
const secondGuestReset = guestInbox.next("round-reset");
|
||||
host.send(JSON.stringify({ type: "reset" }));
|
||||
await Promise.all([secondHostReset, secondGuestReset]);
|
||||
|
||||
const newcomer = await connect(url);
|
||||
const newcomerInbox = createInbox(newcomer);
|
||||
newcomer.send(JSON.stringify({ type: "join", roomId: hostState.roomId }));
|
||||
const newcomerState = await newcomerInbox.next("room-state");
|
||||
assert.deepEqual(newcomerState.settings, configuredSettings);
|
||||
assert.equal(newcomerState.rerollsUsed, 0);
|
||||
|
||||
host.terminate();
|
||||
guest.terminate();
|
||||
newcomer.terminate();
|
||||
await new Promise((resolve) => wss.close(resolve));
|
||||
await new Promise((resolve) => httpServer.close(resolve));
|
||||
});
|
||||
99
server/index.mjs
Normal file
99
server/index.mjs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { createReadStream, existsSync } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
import { createServer } from "node:http";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { attachDiceSyncServer } from "./dice-sync.mjs";
|
||||
|
||||
const serverDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const distDir = path.resolve(serverDir, "../dist");
|
||||
const port = Number(process.env.PORT || 3000);
|
||||
|
||||
const contentTypes = {
|
||||
".css": "text/css; charset=utf-8",
|
||||
".html": "text/html; charset=utf-8",
|
||||
".ico": "image/x-icon",
|
||||
".jpeg": "image/jpeg",
|
||||
".jpg": "image/jpeg",
|
||||
".js": "text/javascript; charset=utf-8",
|
||||
".json": "application/json; charset=utf-8",
|
||||
".png": "image/png",
|
||||
".svg": "image/svg+xml",
|
||||
".webp": "image/webp",
|
||||
".woff": "font/woff",
|
||||
".woff2": "font/woff2",
|
||||
};
|
||||
|
||||
const sendFile = async (request, response, filePath) => {
|
||||
const fileStat = await stat(filePath);
|
||||
response.writeHead(200, {
|
||||
"Content-Type":
|
||||
contentTypes[path.extname(filePath)] || "application/octet-stream",
|
||||
"Content-Length": fileStat.size,
|
||||
"Cache-Control": filePath.endsWith("index.html")
|
||||
? "no-cache"
|
||||
: "public, max-age=31536000, immutable",
|
||||
});
|
||||
if (request.method === "HEAD") response.end();
|
||||
else createReadStream(filePath).pipe(response);
|
||||
};
|
||||
|
||||
const httpServer = createServer(async (request, response) => {
|
||||
try {
|
||||
const requestUrl = new URL(request.url || "/", "http://localhost");
|
||||
if (requestUrl.pathname === "/api/health") {
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Cache-Control": "no-store",
|
||||
});
|
||||
response.end(JSON.stringify({ ok: true }));
|
||||
return;
|
||||
}
|
||||
if (!["GET", "HEAD"].includes(request.method || "")) {
|
||||
response.writeHead(405, { Allow: "GET, HEAD" });
|
||||
response.end("Method not allowed");
|
||||
return;
|
||||
}
|
||||
|
||||
const decodedPath = decodeURIComponent(requestUrl.pathname);
|
||||
const requestedFile = path.resolve(distDir, `.${decodedPath}`);
|
||||
const safePath = requestedFile.startsWith(`${distDir}${path.sep}`)
|
||||
? requestedFile
|
||||
: path.join(distDir, "index.html");
|
||||
let filePath = path.join(distDir, "404.html");
|
||||
if (existsSync(safePath)) {
|
||||
const safeStat = await stat(safePath);
|
||||
if (safeStat.isFile()) filePath = safePath;
|
||||
else if (
|
||||
safeStat.isDirectory() &&
|
||||
existsSync(path.join(safePath, "index.html"))
|
||||
)
|
||||
filePath = path.join(safePath, "index.html");
|
||||
}
|
||||
await sendFile(request, response, filePath);
|
||||
} catch {
|
||||
response.writeHead(500, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
response.end("Internal server error");
|
||||
}
|
||||
});
|
||||
|
||||
attachDiceSyncServer(httpServer);
|
||||
|
||||
httpServer.listen(port, "0.0.0.0", () => {
|
||||
console.log(`Interactives is listening on port ${port}`);
|
||||
if (
|
||||
typeof process.getuid === "function" &&
|
||||
process.getuid() === 0 &&
|
||||
typeof process.setuid === "function"
|
||||
) {
|
||||
try {
|
||||
process.setuid("node");
|
||||
} catch {
|
||||
// The container can still run safely with its filesystem mounted read-only by the platform.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const shutdown = () => httpServer.close(() => process.exit(0));
|
||||
process.on("SIGTERM", shutdown);
|
||||
process.on("SIGINT", shutdown);
|
||||
Loading…
Add table
Add a link
Reference in a new issue