This commit is contained in:
parent
52615a5086
commit
e6fa86519c
16 changed files with 3277 additions and 6 deletions
6
.dockerignore
Normal file
6
.dockerignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
.git
|
||||
.astro
|
||||
.DS_Store
|
||||
dist
|
||||
node_modules
|
||||
npm-debug.log*
|
||||
18
.forgejo/workflows/ci.yml
Normal file
18
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- run: npm run ci
|
||||
29
Dockerfile
Normal file
29
Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
FROM node:22-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
RUN npm run ci
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV PORT=80
|
||||
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci --omit=dev && npm cache clean --force
|
||||
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY server ./server
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||
CMD wget -q --spider http://127.0.0.1/api/health || exit 1
|
||||
|
||||
CMD ["node", "server/index.mjs"]
|
||||
|
|
@ -21,6 +21,10 @@ npm run dev
|
|||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
## Forgejo and Dokploy
|
||||
|
||||
The `forgejo` remote points to `Websites/interactives`. Forgejo Actions runs lint, the classroom synchronization test, and the Astro production build on every push to `main`. Dokploy builds the checked-in `Dockerfile`; the container serves the static Astro site and the `/sync` WebSocket endpoint on port 80.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- Astro
|
||||
|
|
|
|||
|
|
@ -5,8 +5,26 @@ import sitemap from "@astrojs/sitemap";
|
|||
|
||||
import react from "@astrojs/react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { attachDiceSyncServer } from "./server/dice-sync.mjs";
|
||||
// Admin API runs as a standalone Netlify Function (see netlify/functions/admin.mts)
|
||||
|
||||
const attachedServers = new WeakSet();
|
||||
const classroomSync = () => ({
|
||||
name: "classroom-dice-sync",
|
||||
configureServer(server) {
|
||||
if (server.httpServer && !attachedServers.has(server.httpServer)) {
|
||||
attachDiceSyncServer(server.httpServer);
|
||||
attachedServers.add(server.httpServer);
|
||||
}
|
||||
},
|
||||
configurePreviewServer(server) {
|
||||
if (server.httpServer && !attachedServers.has(server.httpServer)) {
|
||||
attachDiceSyncServer(server.httpServer);
|
||||
attachedServers.add(server.httpServer);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// https://astro.build/config
|
||||
export default defineConfig({
|
||||
site: "https://interactives.neeldhara.com",
|
||||
|
|
@ -22,14 +40,14 @@ export default defineConfig({
|
|||
],
|
||||
|
||||
vite: {
|
||||
plugins: [tailwindcss()],
|
||||
plugins: [tailwindcss(), classroomSync()],
|
||||
build: {
|
||||
target: "es2022",
|
||||
minify: "esbuild",
|
||||
// Sanitize chunk filenames — Netlify esbuild chokes on ! and ~ in names
|
||||
rollupOptions: {
|
||||
output: {
|
||||
sanitizeFileName: (name) => name.replace(/[^\w./-]/g, '_'),
|
||||
sanitizeFileName: (name) => name.replace(/[^\w./-]/g, "_"),
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
24
package-lock.json
generated
24
package-lock.json
generated
|
|
@ -47,7 +47,8 @@
|
|||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
|
|
@ -21326,6 +21327,27 @@
|
|||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.21.1",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.1.tgz",
|
||||
"integrity": "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"bufferutil": "^4.0.1",
|
||||
"utf-8-validate": ">=5.0.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"bufferutil": {
|
||||
"optional": true
|
||||
},
|
||||
"utf-8-validate": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wsl-utils": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.3.1.tgz",
|
||||
|
|
|
|||
|
|
@ -6,12 +6,15 @@
|
|||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"ci": "npm run test:sync && npm run build",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"lint": "eslint . --ext .js,.jsx,.ts,.tsx,.astro",
|
||||
"lint:fix": "eslint . --ext .js,.jsx,.ts,.tsx,.astro --fix",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
"format:check": "prettier --check .",
|
||||
"start": "node server/index.mjs",
|
||||
"test:sync": "node --test server/dice-sync.test.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
|
|
@ -56,7 +59,8 @@
|
|||
"tailwind-merge": "^3.5.0",
|
||||
"three": "^0.183.2",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"vaul": "^1.1.2"
|
||||
"vaul": "^1.1.2",
|
||||
"ws": "^8.21.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@astrojs/mdx": "^5.0.3",
|
||||
|
|
|
|||
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);
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import React, { type ComponentType,lazy, Suspense } from "react";
|
||||
|
||||
const componentMap: Record<string, () => Promise<{ default: ComponentType<any> }>> = {
|
||||
"akkam-bakkam": () => import("./interactives/AkkamBakkam"),
|
||||
"binary-number-game": () => import("./interactives/BinaryNumberGame"),
|
||||
"ternary-number-game": () => import("./interactives/TernaryNumberGame"),
|
||||
"balanced-ternary-game": () => import("./interactives/BalancedTernaryGame"),
|
||||
|
|
|
|||
2073
src/components/interactives/AkkamBakkam.tsx
Normal file
2073
src/components/interactives/AkkamBakkam.tsx
Normal file
File diff suppressed because it is too large
Load diff
312
src/hooks/useClassroomDice.ts
Normal file
312
src/hooks/useClassroomDice.ts
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export interface DiceRoll {
|
||||
id: number;
|
||||
dice: [number, number];
|
||||
sum: number;
|
||||
rolledAt: number;
|
||||
}
|
||||
|
||||
export interface LevelSettings {
|
||||
gridSize: number;
|
||||
undoLimit: number;
|
||||
rerollLimit: number;
|
||||
}
|
||||
|
||||
export const DEFAULT_LEVEL_SETTINGS: LevelSettings = {
|
||||
gridSize: 5,
|
||||
undoLimit: 3,
|
||||
rerollLimit: 3,
|
||||
};
|
||||
|
||||
type ConnectionStatus =
|
||||
| "solo"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "error";
|
||||
|
||||
export interface LeaderboardPlayer {
|
||||
id: string;
|
||||
name: string;
|
||||
score: number;
|
||||
placed: number;
|
||||
finished: boolean;
|
||||
connected: boolean;
|
||||
}
|
||||
|
||||
interface ClassroomState {
|
||||
roomId: string | null;
|
||||
roundId: string | null;
|
||||
isHost: boolean;
|
||||
participants: number;
|
||||
history: DiceRoll[];
|
||||
playerId: string | null;
|
||||
playerName: string | null;
|
||||
leaderboard: LeaderboardPlayer[];
|
||||
settings: LevelSettings;
|
||||
rerollsUsed: number;
|
||||
status: ConnectionStatus;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
const initialState: ClassroomState = {
|
||||
roomId: null,
|
||||
roundId: null,
|
||||
isHost: false,
|
||||
participants: 0,
|
||||
history: [],
|
||||
playerId: null,
|
||||
playerName: null,
|
||||
leaderboard: [],
|
||||
settings: DEFAULT_LEVEL_SETTINGS,
|
||||
rerollsUsed: 0,
|
||||
status: "solo",
|
||||
error: null,
|
||||
};
|
||||
|
||||
const hostTokenKey = (roomId: string) => `akkam-bakkam-host-${roomId}`;
|
||||
const playerTokenKey = (roomId: string) => `akkam-bakkam-player-${roomId}`;
|
||||
|
||||
export const useClassroomDice = () => {
|
||||
const [state, setState] = useState<ClassroomState>(initialState);
|
||||
const socketRef = useRef<WebSocket | null>(null);
|
||||
const reconnectTimerRef = useRef<number | null>(null);
|
||||
const manualCloseRef = useRef(false);
|
||||
const desiredRoomRef = useRef<string | null>(null);
|
||||
|
||||
const clearReconnectTimer = () => {
|
||||
if (reconnectTimerRef.current !== null)
|
||||
window.clearTimeout(reconnectTimerRef.current);
|
||||
reconnectTimerRef.current = null;
|
||||
};
|
||||
|
||||
const connect = useCallback(
|
||||
(
|
||||
action: "create" | "join",
|
||||
roomId?: string,
|
||||
settings: LevelSettings = DEFAULT_LEVEL_SETTINGS,
|
||||
) => {
|
||||
clearReconnectTimer();
|
||||
manualCloseRef.current = false;
|
||||
socketRef.current?.close();
|
||||
const normalizedRoomId = roomId?.trim().toUpperCase();
|
||||
desiredRoomRef.current = normalizedRoomId || null;
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: current.roomId ? "reconnecting" : "connecting",
|
||||
error: null,
|
||||
}));
|
||||
|
||||
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||
const socket = new WebSocket(`${protocol}//${window.location.host}/sync`);
|
||||
socketRef.current = socket;
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
if (action === "create") {
|
||||
socket.send(JSON.stringify({ type: "create", settings }));
|
||||
} else {
|
||||
const hostToken = normalizedRoomId
|
||||
? sessionStorage.getItem(hostTokenKey(normalizedRoomId))
|
||||
: null;
|
||||
const playerToken = normalizedRoomId
|
||||
? sessionStorage.getItem(playerTokenKey(normalizedRoomId))
|
||||
: null;
|
||||
socket.send(
|
||||
JSON.stringify({
|
||||
type: "join",
|
||||
roomId: normalizedRoomId,
|
||||
hostToken,
|
||||
playerToken,
|
||||
}),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "room-state") {
|
||||
desiredRoomRef.current = message.roomId;
|
||||
setState({
|
||||
roomId: message.roomId,
|
||||
roundId: message.roundId,
|
||||
isHost: message.isHost,
|
||||
participants: message.participants,
|
||||
history: message.history,
|
||||
playerId: message.playerId,
|
||||
playerName: message.playerName,
|
||||
leaderboard: message.leaderboard,
|
||||
settings: message.settings ?? DEFAULT_LEVEL_SETTINGS,
|
||||
rerollsUsed: message.rerollsUsed ?? 0,
|
||||
status: "connected",
|
||||
error: null,
|
||||
});
|
||||
} else if (message.type === "host-token") {
|
||||
sessionStorage.setItem(
|
||||
hostTokenKey(message.roomId),
|
||||
message.hostToken,
|
||||
);
|
||||
setState((current) => ({ ...current, isHost: true }));
|
||||
} else if (message.type === "player-token") {
|
||||
sessionStorage.setItem(
|
||||
playerTokenKey(message.roomId),
|
||||
message.playerToken,
|
||||
);
|
||||
} else if (message.type === "presence") {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
participants: message.participants,
|
||||
}));
|
||||
} else if (message.type === "settings") {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
settings: message.settings,
|
||||
rerollsUsed: message.rerollsUsed ?? current.rerollsUsed,
|
||||
error: null,
|
||||
}));
|
||||
} else if (message.type === "rolled") {
|
||||
setState((current) =>
|
||||
current.roundId === message.roundId
|
||||
? {
|
||||
...current,
|
||||
history: [...current.history, message.roll],
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} else if (message.type === "rerolled") {
|
||||
setState((current) =>
|
||||
current.roundId === message.roundId && current.history.length > 0
|
||||
? {
|
||||
...current,
|
||||
history: [...current.history.slice(0, -1), message.roll],
|
||||
rerollsUsed: message.rerollsUsed,
|
||||
error: null,
|
||||
}
|
||||
: current,
|
||||
);
|
||||
} else if (message.type === "round-reset") {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
roundId: message.roundId,
|
||||
history: [],
|
||||
rerollsUsed: message.rerollsUsed ?? 0,
|
||||
error: null,
|
||||
}));
|
||||
} else if (message.type === "leaderboard") {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
leaderboard: message.leaderboard,
|
||||
}));
|
||||
} else if (message.type === "error") {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: current.roomId ? current.status : "error",
|
||||
error: message.message,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
if (socketRef.current !== socket || manualCloseRef.current) return;
|
||||
const roomToRejoin = desiredRoomRef.current;
|
||||
if (!roomToRejoin) {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
status: "error",
|
||||
error: current.error || "Could not reach the classroom server.",
|
||||
}));
|
||||
return;
|
||||
}
|
||||
setState((current) => ({ ...current, status: "reconnecting" }));
|
||||
reconnectTimerRef.current = window.setTimeout(
|
||||
() => connect("join", roomToRejoin),
|
||||
1500,
|
||||
);
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
setState((current) => ({
|
||||
...current,
|
||||
error: "The classroom connection was interrupted.",
|
||||
}));
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const createRoom = useCallback(
|
||||
(settings: LevelSettings = DEFAULT_LEVEL_SETTINGS) =>
|
||||
connect("create", undefined, settings),
|
||||
[connect],
|
||||
);
|
||||
const joinRoom = useCallback(
|
||||
(roomId: string) => connect("join", roomId),
|
||||
[connect],
|
||||
);
|
||||
|
||||
const leaveRoom = useCallback(() => {
|
||||
clearReconnectTimer();
|
||||
manualCloseRef.current = true;
|
||||
desiredRoomRef.current = null;
|
||||
socketRef.current?.close();
|
||||
socketRef.current = null;
|
||||
setState(initialState);
|
||||
}, []);
|
||||
|
||||
const roll = useCallback(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "roll" }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const configure = useCallback((settings: LevelSettings) => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "configure", settings }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reroll = useCallback(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "reroll" }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(JSON.stringify({ type: "reset" }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const updateProgress = useCallback(
|
||||
(score: number, placed: number, finished: boolean) => {
|
||||
if (socketRef.current?.readyState === WebSocket.OPEN) {
|
||||
socketRef.current.send(
|
||||
JSON.stringify({ type: "progress", score, placed, finished }),
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
manualCloseRef.current = true;
|
||||
clearReconnectTimer();
|
||||
socketRef.current?.close();
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return {
|
||||
...state,
|
||||
createRoom,
|
||||
joinRoom,
|
||||
leaveRoom,
|
||||
roll,
|
||||
configure,
|
||||
reroll,
|
||||
reset,
|
||||
updateProgress,
|
||||
};
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import type { ComponentType } from "react";
|
|||
type LazyImport = () => Promise<{ default: ComponentType<any> }>;
|
||||
|
||||
export const componentMap: Record<string, LazyImport> = {
|
||||
"akkam-bakkam": () => import("@/components/interactives/AkkamBakkam"),
|
||||
"binary-number-game": () => import("@/components/interactives/BinaryNumberGame"),
|
||||
"ternary-number-game": () => import("@/components/interactives/TernaryNumberGame"),
|
||||
"balanced-ternary-game": () => import("@/components/interactives/BalancedTernaryGame"),
|
||||
|
|
|
|||
|
|
@ -390,6 +390,17 @@ export const allInteractives: Interactive[] = [
|
|||
status: "published" as const,
|
||||
},
|
||||
// === Games ===
|
||||
{
|
||||
slug: "akkam-bakkam",
|
||||
title: "Akkam Bakkam",
|
||||
description:
|
||||
"Place shared dice sums on a private grid, score equal neighbors, and play live with a classroom leaderboard.",
|
||||
tags: ["dice", "strategy", "classroom", "synchronized", "grid-game"],
|
||||
themes: ["Games"],
|
||||
dateAdded: "2026-07-20",
|
||||
hasGreenScreen: false,
|
||||
status: "published" as const,
|
||||
},
|
||||
{
|
||||
slug: "game-of-sim",
|
||||
title: "Game of Sim",
|
||||
|
|
|
|||
|
|
@ -12,6 +12,20 @@
|
|||
@custom-variant fhd (@media (min-width: 1920px));
|
||||
@custom-variant uw (@media (min-width: 2250px));
|
||||
|
||||
@keyframes akkam-die-arrival {
|
||||
0% { opacity: 0; transform: translateY(-8px) rotate(-8deg) scale(0.9); }
|
||||
65% { opacity: 1; transform: translateY(2px) rotate(2deg) scale(1.03); }
|
||||
100% { opacity: 1; transform: translateY(0) rotate(0) scale(1); }
|
||||
}
|
||||
|
||||
.akkam-die-arrival {
|
||||
animation: akkam-die-arrival 280ms ease-out both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.akkam-die-arrival { animation: none; }
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0); /* #FFFFFF */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue