diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ff00f86 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +.git +.astro +.DS_Store +dist +node_modules +npm-debug.log* diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml new file mode 100644 index 0000000..4900fda --- /dev/null +++ b/.forgejo/workflows/ci.yml @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0bfb4ee --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md index d637626..069d831 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/astro.config.mjs b/astro.config.mjs index 8c65254..0936466 100644 --- a/astro.config.mjs +++ b/astro.config.mjs @@ -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, "_"), }, }, }, diff --git a/package-lock.json b/package-lock.json index 8960f7b..556e5b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index fc50de4..e58b637 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/dice-sync.mjs b/server/dice-sync.mjs new file mode 100644 index 0000000..2a5531d --- /dev/null +++ b/server/dice-sync.mjs @@ -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 }; +}; diff --git a/server/dice-sync.test.mjs b/server/dice-sync.test.mjs new file mode 100644 index 0000000..071534e --- /dev/null +++ b/server/dice-sync.test.mjs @@ -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)); +}); diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..443c2e9 --- /dev/null +++ b/server/index.mjs @@ -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); diff --git a/src/components/InteractiveRenderer.tsx b/src/components/InteractiveRenderer.tsx index 745df61..4998bfa 100644 --- a/src/components/InteractiveRenderer.tsx +++ b/src/components/InteractiveRenderer.tsx @@ -1,6 +1,7 @@ import React, { type ComponentType,lazy, Suspense } from "react"; const componentMap: Record Promise<{ default: ComponentType }>> = { + "akkam-bakkam": () => import("./interactives/AkkamBakkam"), "binary-number-game": () => import("./interactives/BinaryNumberGame"), "ternary-number-game": () => import("./interactives/TernaryNumberGame"), "balanced-ternary-game": () => import("./interactives/BalancedTernaryGame"), diff --git a/src/components/interactives/AkkamBakkam.tsx b/src/components/interactives/AkkamBakkam.tsx new file mode 100644 index 0000000..d8a06b1 --- /dev/null +++ b/src/components/interactives/AkkamBakkam.tsx @@ -0,0 +1,2073 @@ +import { + Check, + Copy, + Crown, + Dice1, + Dice2, + Dice3, + Dice4, + Dice5, + Dice6, + ExternalLink, + Loader2, + LogOut, + Move, + Plus, + QrCode, + RefreshCw, + Replace, + RotateCcw, + Settings2, + Sparkles, + Undo2, + Users, + Wifi, +} from "lucide-react"; +import QRCode from "qrcode"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; +import { Label } from "@/components/ui/label"; +import { Slider } from "@/components/ui/slider"; +import type { DiceRoll, LevelSettings } from "@/hooks/useClassroomDice"; +import { + DEFAULT_LEVEL_SETTINGS, + useClassroomDice, +} from "@/hooks/useClassroomDice"; +import { cn } from "@/lib/utils"; + +type Power = "replace" | "move" | "add"; +type Edge = "top" | "right" | "bottom" | "left"; +interface AddedCell { + edge: Edge; + index: number; + value: number; +} + +interface PowerFlags { + replace: boolean; + move: boolean; + add: boolean; +} + +interface GameSnapshot { + board: Array; + bank: Array; + usedRolls: number; + addedCell: AddedCell | null; + unlockedPowers: PowerFlags; + usedPowers: PowerFlags; +} + +interface ScoreLink { + key: string; + value: number; + from: { row: number; column: number }; + to: { row: number; column: number }; +} + +interface BenchmarkResult { + board: number[]; + score: number; + ceiling: number; + provenOptimal: boolean; +} + +const makeEmptyBoard = (size: number) => + Array(size * size).fill(null); +const makeEmptyBank = (size: number) => Array(size).fill(null); +const DIE_ICONS = [Dice1, Dice2, Dice3, Dice4, Dice5, Dice6]; +const EMPTY_POWERS: PowerFlags = { + replace: false, + move: false, + add: false, +}; + +const SUM_STYLES: Record = { + 2: { + base: "border-rose-200 bg-rose-100/70 text-rose-950 dark:border-rose-800 dark:bg-rose-950/45 dark:text-rose-100", + linked: + "border-rose-400 bg-rose-200/90 text-rose-950 ring-2 ring-rose-300/60 dark:border-rose-600 dark:bg-rose-900/65 dark:text-rose-50 dark:ring-rose-700/60", + }, + 3: { + base: "border-orange-200 bg-orange-100/70 text-orange-950 dark:border-orange-800 dark:bg-orange-950/45 dark:text-orange-100", + linked: + "border-orange-400 bg-orange-200/90 text-orange-950 ring-2 ring-orange-300/60 dark:border-orange-600 dark:bg-orange-900/65 dark:text-orange-50 dark:ring-orange-700/60", + }, + 4: { + base: "border-amber-200 bg-amber-100/70 text-amber-950 dark:border-amber-800 dark:bg-amber-950/45 dark:text-amber-100", + linked: + "border-amber-400 bg-amber-200/90 text-amber-950 ring-2 ring-amber-300/60 dark:border-amber-600 dark:bg-amber-900/65 dark:text-amber-50 dark:ring-amber-700/60", + }, + 5: { + base: "border-yellow-200 bg-yellow-100/70 text-yellow-950 dark:border-yellow-800 dark:bg-yellow-950/45 dark:text-yellow-100", + linked: + "border-yellow-400 bg-yellow-200/90 text-yellow-950 ring-2 ring-yellow-300/60 dark:border-yellow-600 dark:bg-yellow-900/65 dark:text-yellow-50 dark:ring-yellow-700/60", + }, + 6: { + base: "border-lime-200 bg-lime-100/70 text-lime-950 dark:border-lime-800 dark:bg-lime-950/45 dark:text-lime-100", + linked: + "border-lime-400 bg-lime-200/90 text-lime-950 ring-2 ring-lime-300/60 dark:border-lime-600 dark:bg-lime-900/65 dark:text-lime-50 dark:ring-lime-700/60", + }, + 7: { + base: "border-emerald-200 bg-emerald-100/70 text-emerald-950 dark:border-emerald-800 dark:bg-emerald-950/45 dark:text-emerald-100", + linked: + "border-emerald-400 bg-emerald-200/90 text-emerald-950 ring-2 ring-emerald-300/60 dark:border-emerald-600 dark:bg-emerald-900/65 dark:text-emerald-50 dark:ring-emerald-700/60", + }, + 8: { + base: "border-cyan-200 bg-cyan-100/70 text-cyan-950 dark:border-cyan-800 dark:bg-cyan-950/45 dark:text-cyan-100", + linked: + "border-cyan-400 bg-cyan-200/90 text-cyan-950 ring-2 ring-cyan-300/60 dark:border-cyan-600 dark:bg-cyan-900/65 dark:text-cyan-50 dark:ring-cyan-700/60", + }, + 9: { + base: "border-sky-200 bg-sky-100/70 text-sky-950 dark:border-sky-800 dark:bg-sky-950/45 dark:text-sky-100", + linked: + "border-sky-400 bg-sky-200/90 text-sky-950 ring-2 ring-sky-300/60 dark:border-sky-600 dark:bg-sky-900/65 dark:text-sky-50 dark:ring-sky-700/60", + }, + 10: { + base: "border-blue-200 bg-blue-100/70 text-blue-950 dark:border-blue-800 dark:bg-blue-950/45 dark:text-blue-100", + linked: + "border-blue-400 bg-blue-200/90 text-blue-950 ring-2 ring-blue-300/60 dark:border-blue-600 dark:bg-blue-900/65 dark:text-blue-50 dark:ring-blue-700/60", + }, + 11: { + base: "border-violet-200 bg-violet-100/70 text-violet-950 dark:border-violet-800 dark:bg-violet-950/45 dark:text-violet-100", + linked: + "border-violet-400 bg-violet-200/90 text-violet-950 ring-2 ring-violet-300/60 dark:border-violet-600 dark:bg-violet-900/65 dark:text-violet-50 dark:ring-violet-700/60", + }, + 12: { + base: "border-fuchsia-200 bg-fuchsia-100/70 text-fuchsia-950 dark:border-fuchsia-800 dark:bg-fuchsia-950/45 dark:text-fuchsia-100", + linked: + "border-fuchsia-400 bg-fuchsia-200/90 text-fuchsia-950 ring-2 ring-fuchsia-300/60 dark:border-fuchsia-600 dark:bg-fuchsia-900/65 dark:text-fuchsia-50 dark:ring-fuchsia-700/60", + }, +}; + +const sumStyle = (value: number | null, linked = false) => + value === null + ? "border-border bg-card text-foreground" + : SUM_STYLES[value][linked ? "linked" : "base"]; + +const randomDie = () => { + const values = new Uint32Array(1); + window.crypto.getRandomValues(values); + return (values[0] % 6) + 1; +}; + +const coordinateForAddedCell = (cell: AddedCell, gridSize: number) => { + if (cell.edge === "top") return [-1, cell.index]; + if (cell.edge === "bottom") return [gridSize, cell.index]; + if (cell.edge === "left") return [cell.index, -1]; + return [cell.index, gridSize]; +}; + +const calculateScore = ( + board: Array, + gridSize: number, + addedCell: AddedCell | null = null, +) => { + const cells = board.flatMap((value, index) => + value === null + ? [] + : [ + { + key: `board-${index}`, + value, + row: Math.floor(index / gridSize), + column: index % gridSize, + }, + ], + ); + if (addedCell) { + const [row, column] = coordinateForAddedCell(addedCell, gridSize); + cells.push({ key: "added", value: addedCell.value, row, column }); + } + let score = 0; + const linkedCells = new Set(); + const links: ScoreLink[] = []; + for (let left = 0; left < cells.length; left += 1) { + for (let right = left + 1; right < cells.length; right += 1) { + const a = cells[left]; + const b = cells[right]; + const adjacent = + Math.max(Math.abs(a.row - b.row), Math.abs(a.column - b.column)) === 1; + if (adjacent && a.value === b.value) { + score += a.value; + linkedCells.add(a.key); + linkedCells.add(b.key); + links.push({ + key: `${a.key}-${b.key}`, + value: a.value, + from: { row: a.row, column: a.column }, + to: { row: b.row, column: b.column }, + }); + } + } + } + return { score, linkedCells, links }; +}; + +const flatScore = (board: number[], gridSize: number) => + calculateScore(board, gridSize).score; + +const mulberry32 = (seed: number) => () => { + let value = (seed += 0x6d2b79f5); + value = Math.imul(value ^ (value >>> 15), value | 1); + value ^= value + Math.imul(value ^ (value >>> 7), value | 61); + return ((value ^ (value >>> 14)) >>> 0) / 4294967296; +}; + +const scoreLines = ( + links: ScoreLink[], + gridSize: number, + includeEdgeSlots = false, +) => { + const padding = includeEdgeSlots ? 1 : 0; + const viewBoxSize = gridSize + padding * 2; + return ( + + ); +}; + +const singleClusterMaxLinks = (gridSize: number) => { + const cellCount = gridSize * gridSize; + let previous = Array.from({ length: gridSize + 1 }, () => + Array(cellCount + 1).fill(Number.NEGATIVE_INFINITY), + ); + previous[0][0] = 0; + + for (let row = 0; row < gridSize; row += 1) { + const next = Array.from({ length: gridSize + 1 }, () => + Array(cellCount + 1).fill(Number.NEGATIVE_INFINITY), + ); + for ( + let previousLength = 0; + previousLength <= gridSize; + previousLength += 1 + ) { + for (let used = 0; used <= cellCount; used += 1) { + const currentScore = previous[previousLength][used]; + if (!Number.isFinite(currentScore)) continue; + for (let length = 0; length <= gridSize; length += 1) { + if (used + length > cellCount) break; + const shorter = Math.min(previousLength, length); + const crossRowLinks = + shorter === 0 + ? 0 + : 3 * shorter - + Math.max(0, 2 - Math.abs(previousLength - length)); + const horizontalLinks = Math.max(0, length - 1); + next[length][used + length] = Math.max( + next[length][used + length], + currentScore + crossRowLinks + horizontalLinks, + ); + } + } + } + previous = next; + } + + return Array.from({ length: cellCount + 1 }, (_, used) => + Math.max(...previous.map((scores) => scores[used])), + ); +}; + +const theoreticalCeiling = (values: number[], gridSize: number) => { + const capacity = gridSize * gridSize; + const frequencies = Array(13).fill(0); + values.forEach((value) => { + frequencies[value] += 1; + }); + const clusterLinks = singleClusterMaxLinks(gridSize); + let states: Array<{ score: number; counts: number[] } | null> = Array( + capacity + 1, + ).fill(null); + states[0] = { score: 0, counts: Array(13).fill(0) }; + + for (let value = 2; value <= 12; value += 1) { + const next: Array<{ score: number; counts: number[] } | null> = Array( + capacity + 1, + ).fill(null); + for (let used = 0; used <= capacity; used += 1) { + const state = states[used]; + if (!state) continue; + const maximumCount = Math.min(frequencies[value], capacity - used); + for (let count = 0; count <= maximumCount; count += 1) { + const nextUsed = used + count; + const nextScore = state.score + value * clusterLinks[count]; + if (!next[nextUsed] || nextScore > next[nextUsed].score) { + const counts = [...state.counts]; + counts[value] = count; + next[nextUsed] = { score: nextScore, counts }; + } + } + } + states = next; + } + + const result = states[capacity]; + return { + ceiling: result?.score ?? 0, + selectedCounts: result?.counts ?? Array(13).fill(0), + }; +}; + +const gridEdges = (gridSize: number) => { + const edges: Array<[number, number]> = []; + const directions = [ + [0, 1], + [1, -1], + [1, 0], + [1, 1], + ]; + for (let row = 0; row < gridSize; row += 1) { + for (let column = 0; column < gridSize; column += 1) { + for (const [rowDelta, columnDelta] of directions) { + const nextRow = row + rowDelta; + const nextColumn = column + columnDelta; + if ( + nextRow >= 0 && + nextRow < gridSize && + nextColumn >= 0 && + nextColumn < gridSize + ) { + edges.push([ + row * gridSize + column, + nextRow * gridSize + nextColumn, + ]); + } + } + } + } + return edges; +}; + +const findBenchmark = ( + rolls: DiceRoll[], + gridSize: number, + searchPass = 0, +): BenchmarkResult => { + const boardCellCount = gridSize * gridSize; + const values = rolls.map((roll) => roll.sum); + const { ceiling, selectedCounts } = theoreticalCeiling(values, gridSize); + const remainingCounts = Array(13).fill(0); + values.forEach((value) => { + remainingCounts[value] += 1; + }); + const selected: number[] = []; + for (let value = 2; value <= 12; value += 1) { + selected.push(...Array(selectedCounts[value]).fill(value)); + remainingCounts[value] -= selectedCounts[value]; + } + const leftovers: number[] = []; + for (let value = 2; value <= 12; value += 1) { + leftovers.push(...Array(remainingCounts[value]).fill(value)); + } + + const snakeOrder = Array.from({ length: gridSize }, (_, row) => + Array.from( + { length: gridSize }, + (_, column) => + row * gridSize + (row % 2 === 0 ? column : gridSize - 1 - column), + ), + ).flat(); + const groupedBoard = Array(boardCellCount); + selected.forEach((value, index) => { + groupedBoard[snakeOrder[index]] = value; + }); + let best = groupedBoard; + let bestScore = flatScore(best, gridSize); + const seed = values.reduce( + (total, value, index) => total + value * (index + 17), + 7919 + searchPass * 104729, + ); + const random = mulberry32(seed); + const edges = gridEdges(gridSize); + const incidentEdges = Array.from({ length: boardCellCount }, () => + Array(), + ); + edges.forEach(([from, to], edgeIndex) => { + incidentEdges[from].push(edgeIndex); + incidentEdges[to].push(edgeIndex); + }); + const edgeScore = (candidate: number[], edgeIndex: number) => { + const [from, to] = edges[edgeIndex]; + return candidate[from] === candidate[to] ? candidate[from] : 0; + }; + + const restarts = gridSize >= 8 ? 4 : 5; + const iterations = gridSize >= 8 ? 80000 : gridSize >= 7 ? 70000 : 60000; + for (let restart = 0; restart < restarts; restart += 1) { + const labelOrder = Array.from({ length: 11 }, (_, index) => index + 2); + if (restart > 0) { + for (let index = labelOrder.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(random() * (index + 1)); + [labelOrder[index], labelOrder[swapIndex]] = [ + labelOrder[swapIndex], + labelOrder[index], + ]; + } + } + const orderedValues = labelOrder.flatMap((value) => + Array(selectedCounts[value]).fill(value), + ); + const candidateBoard = Array(boardCellCount); + orderedValues.forEach((value, index) => { + candidateBoard[snakeOrder[index]] = value; + }); + const candidate = [...candidateBoard, ...leftovers]; + let candidateScore = flatScore(candidateBoard, gridSize); + if (candidateScore > bestScore) { + bestScore = candidateScore; + best = [...candidateBoard]; + } + if (bestScore === ceiling) break; + + for (let iteration = 0; iteration < iterations; iteration += 1) { + const first = Math.floor(random() * candidate.length); + const second = Math.floor(random() * candidate.length); + if ( + first === second || + (first >= boardCellCount && second >= boardCellCount) || + candidate[first] === candidate[second] + ) + continue; + const affectedEdges = new Set(); + if (first < boardCellCount) + incidentEdges[first].forEach((edge) => affectedEdges.add(edge)); + if (second < boardCellCount) + incidentEdges[second].forEach((edge) => affectedEdges.add(edge)); + let previousContribution = 0; + affectedEdges.forEach((edge) => { + previousContribution += edgeScore(candidate, edge); + }); + [candidate[first], candidate[second]] = [ + candidate[second], + candidate[first], + ]; + let nextContribution = 0; + affectedEdges.forEach((edge) => { + nextContribution += edgeScore(candidate, edge); + }); + const nextScore = + candidateScore - previousContribution + nextContribution; + const temperature = Math.max(0.18, 9 * (1 - iteration / iterations)); + if ( + nextScore >= candidateScore || + random() < Math.exp((nextScore - candidateScore) / temperature) + ) { + candidateScore = nextScore; + } else { + [candidate[first], candidate[second]] = [ + candidate[second], + candidate[first], + ]; + } + if (candidateScore > bestScore) { + bestScore = candidateScore; + best = candidate.slice(0, boardCellCount); + if (bestScore === ceiling) break; + } + } + if (bestScore === ceiling) break; + } + return { + board: best, + score: bestScore, + ceiling, + provenOptimal: bestScore === ceiling, + }; +}; + +const DiceFace = ({ value, animate }: { value: number; animate: boolean }) => { + const Icon = DIE_ICONS[value - 1]; + return ( +
+
+ ); +}; + +const AkkamBakkamGame = () => { + const classroom = useClassroomDice(); + const { + joinRoom, + roomId, + status: classroomStatus, + updateProgress, + } = classroom; + const [settings, setSettings] = useState( + DEFAULT_LEVEL_SETTINGS, + ); + const [draftSettings, setDraftSettings] = useState( + DEFAULT_LEVEL_SETTINGS, + ); + const [levelDialogOpen, setLevelDialogOpen] = useState(false); + const [board, setBoard] = useState>(() => + makeEmptyBoard(DEFAULT_LEVEL_SETTINGS.gridSize), + ); + const [bank, setBank] = useState>(() => + makeEmptyBank(DEFAULT_LEVEL_SETTINGS.gridSize), + ); + const [localHistory, setLocalHistory] = useState([]); + const [usedRolls, setUsedRolls] = useState(0); + const [undoStack, setUndoStack] = useState([]); + const [undosUsed, setUndosUsed] = useState(0); + const [localRerollsUsed, setLocalRerollsUsed] = useState(0); + const [joinCode, setJoinCode] = useState(""); + const [copied, setCopied] = useState(false); + const [qrDialogOpen, setQrDialogOpen] = useState(false); + const [qrDataUrl, setQrDataUrl] = useState(""); + const [qrError, setQrError] = useState(null); + const [activePower, setActivePower] = useState(null); + const [selectedBankIndex, setSelectedBankIndex] = useState( + null, + ); + const [moveFrom, setMoveFrom] = useState(null); + const [addedCell, setAddedCell] = useState(null); + const [unlockedPowers, setUnlockedPowers] = + useState(EMPTY_POWERS); + const [usedPowers, setUsedPowers] = useState(EMPTY_POWERS); + const [benchmark, setBenchmark] = useState(null); + const [isBenchmarking, setIsBenchmarking] = useState(false); + const benchmarkAttemptRef = useRef(0); + const roundRef = useRef(null); + const initialRoomAttemptedRef = useRef(false); + const classroomSettingsKeyRef = useRef(""); + + const inClassroom = Boolean(classroom.roomId); + const activeHistory = inClassroom ? classroom.history : localHistory; + const boardFull = board.every((value) => value !== null); + const currentRoll = boardFull ? null : activeHistory[usedRolls] || null; + const queuedRolls = boardFull + ? 0 + : Math.max(0, activeHistory.length - usedRolls - (currentRoll ? 1 : 0)); + const bankFull = bank.every((value) => value !== null); + const endgame = boardFull; + const rerollsUsed = inClassroom ? classroom.rerollsUsed : localRerollsUsed; + const undosRemaining = Math.max(0, settings.undoLimit - undosUsed); + const rerollsRemaining = Math.max(0, settings.rerollLimit - rerollsUsed); + const levelLocked = + activeHistory.length > 0 || + usedRolls > 0 || + board.some((value) => value !== null) || + bank.some((value) => value !== null); + const canRoll = inClassroom + ? classroom.isHost && + classroom.status === "connected" && + !currentRoll && + !boardFull + : !currentRoll && !boardFull; + const canBank = + Boolean(currentRoll) && !bankFull && !boardFull && !activePower; + const canUndo = undoStack.length > 0 && undosRemaining > 0 && !activePower; + const canReroll = + Boolean(currentRoll) && + rerollsRemaining > 0 && + !activePower && + (!inClassroom || + (classroom.isHost && + classroom.status === "connected" && + usedRolls === activeHistory.length - 1)); + const { score, linkedCells, links } = useMemo( + () => calculateScore(board, settings.gridSize, addedCell), + [board, settings.gridSize, addedCell], + ); + const squareScore = useMemo( + () => calculateScore(board, settings.gridSize).score, + [board, settings.gridSize], + ); + const benchmarkScoring = useMemo( + () => + benchmark ? calculateScore(benchmark.board, settings.gridSize) : null, + [benchmark, settings.gridSize], + ); + const benchmarkRollKey = endgame + ? activeHistory + .slice(0, usedRolls) + .map((roll) => `${roll.id}:${roll.sum}:${roll.rolledAt}`) + .join("|") + : ""; + const getInviteUrl = useCallback(() => { + const inviteUrl = new URL(window.location.href); + inviteUrl.search = ""; + inviteUrl.hash = ""; + inviteUrl.searchParams.set("room", classroom.roomId); + return inviteUrl.toString(); + }, [classroom.roomId]); + + const resetPrivateBoard = useCallback((nextSettings: LevelSettings) => { + setBoard(makeEmptyBoard(nextSettings.gridSize)); + setBank(makeEmptyBank(nextSettings.gridSize)); + setUsedRolls(0); + setUndoStack([]); + setUndosUsed(0); + setLocalRerollsUsed(0); + setActivePower(null); + setSelectedBankIndex(null); + setMoveFrom(null); + setAddedCell(null); + setUnlockedPowers(EMPTY_POWERS); + setUsedPowers(EMPTY_POWERS); + setBenchmark(null); + setIsBenchmarking(false); + }, []); + + useEffect(() => { + if (!inClassroom || classroomStatus !== "connected") return; + const nextSettings = classroom.settings; + const key = `${nextSettings.gridSize}-${nextSettings.undoLimit}-${nextSettings.rerollLimit}`; + if (classroomSettingsKeyRef.current === key) return; + classroomSettingsKeyRef.current = key; + setSettings(nextSettings); + setDraftSettings(nextSettings); + resetPrivateBoard(nextSettings); + }, [classroom.settings, classroomStatus, inClassroom, resetPrivateBoard]); + + useEffect(() => { + if (!classroom.roundId || classroom.roundId === roundRef.current) return; + roundRef.current = classroom.roundId; + resetPrivateBoard(classroom.settings); + }, [classroom.roundId, classroom.settings, resetPrivateBoard]); + + useEffect(() => { + if (initialRoomAttemptedRef.current) return; + initialRoomAttemptedRef.current = true; + const roomId = new URLSearchParams(window.location.search).get("room"); + if (roomId) { + setJoinCode(roomId.toUpperCase()); + joinRoom(roomId); + } else { + setLevelDialogOpen(true); + } + }, [joinRoom]); + + useEffect(() => { + const url = new URL(window.location.href); + if (roomId) url.searchParams.set("room", roomId); + else url.searchParams.delete("room"); + window.history.replaceState({}, "", url); + }, [roomId]); + + useEffect(() => { + if (!qrDialogOpen || !classroom.roomId) return; + let cancelled = false; + setQrDataUrl(""); + setQrError(null); + QRCode.toDataURL(getInviteUrl(), { + width: 720, + margin: 4, + errorCorrectionLevel: "M", + color: { dark: "#111827", light: "#ffffff" }, + }) + .then((dataUrl) => { + if (!cancelled) setQrDataUrl(dataUrl); + }) + .catch(() => { + if (!cancelled) + setQrError("The QR code could not be generated on this device."); + }); + return () => { + cancelled = true; + }; + }, [classroom.roomId, getInviteUrl, qrDialogOpen]); + + useEffect(() => { + if (!inClassroom || classroomStatus !== "connected") return; + const timer = window.setTimeout( + () => updateProgress(score, usedRolls, endgame), + 0, + ); + return () => window.clearTimeout(timer); + }, [classroomStatus, endgame, inClassroom, score, updateProgress, usedRolls]); + + const unlockPowers = (nextBoard: Array) => { + if (!nextBoard.every((value) => value !== null)) return; + const banked = bank.filter((value) => value !== null).length; + setUnlockedPowers({ + replace: banked >= settings.gridSize - 2, + move: banked >= settings.gridSize - 1, + add: banked >= settings.gridSize, + }); + }; + + const consumeCurrentRoll = () => setUsedRolls((count) => count + 1); + + const pushUndoSnapshot = () => { + setUndoStack((current) => [ + ...current, + { + board: [...board], + bank: [...bank], + usedRolls, + addedCell: addedCell ? { ...addedCell } : null, + unlockedPowers: { ...unlockedPowers }, + usedPowers: { ...usedPowers }, + }, + ]); + }; + + const placeOnBoard = (index: number) => { + if ( + activePower === "replace" && + selectedBankIndex !== null && + bank[selectedBankIndex] !== null + ) { + pushUndoSnapshot(); + const value = bank[selectedBankIndex]; + setBoard((current) => + current.map((cell, cellIndex) => (cellIndex === index ? value : cell)), + ); + setBank((current) => + current.map((cell, cellIndex) => + cellIndex === selectedBankIndex ? null : cell, + ), + ); + setUsedPowers((current) => ({ ...current, replace: true })); + setActivePower(null); + setSelectedBankIndex(null); + return; + } + if (activePower === "move") { + if (moveFrom === null) { + setMoveFrom(index); + } else if (moveFrom === index) { + setMoveFrom(null); + } else { + pushUndoSnapshot(); + setBoard((current) => { + const next = [...current]; + [next[moveFrom], next[index]] = [next[index], next[moveFrom]]; + return next; + }); + setUsedPowers((current) => ({ ...current, move: true })); + setActivePower(null); + setMoveFrom(null); + } + return; + } + if (!currentRoll || board[index] !== null || boardFull) return; + pushUndoSnapshot(); + const nextBoard = board.map((cell, cellIndex) => + cellIndex === index ? currentRoll.sum : cell, + ); + setBoard(nextBoard); + consumeCurrentRoll(); + unlockPowers(nextBoard); + }; + + const bankCurrentRoll = () => { + if (!currentRoll || bankFull || boardFull) return; + pushUndoSnapshot(); + const openIndex = bank.findIndex((value) => value === null); + setBank((current) => + current.map((value, index) => + index === openIndex ? currentRoll.sum : value, + ), + ); + consumeCurrentRoll(); + }; + + const placeAddedCell = (edge: Edge, index: number) => { + if ( + activePower !== "add" || + selectedBankIndex === null || + bank[selectedBankIndex] === null || + addedCell + ) + return; + pushUndoSnapshot(); + setAddedCell({ edge, index, value: bank[selectedBankIndex] as number }); + setBank((current) => + current.map((cell, cellIndex) => + cellIndex === selectedBankIndex ? null : cell, + ), + ); + setUsedPowers((current) => ({ ...current, add: true })); + setActivePower(null); + setSelectedBankIndex(null); + }; + + const requestRoll = () => { + if (inClassroom) { + if (canRoll) classroom.roll(); + return; + } + if (!canRoll) return; + const dice: [number, number] = [randomDie(), randomDie()]; + setLocalHistory((history) => [ + ...history, + { + id: history.length + 1, + dice, + sum: dice[0] + dice[1], + rolledAt: Date.now(), + }, + ]); + }; + + const undoLastMove = () => { + if (!canUndo) return; + const snapshot = undoStack[undoStack.length - 1]; + setBoard([...snapshot.board]); + setBank([...snapshot.bank]); + setUsedRolls(snapshot.usedRolls); + setAddedCell(snapshot.addedCell ? { ...snapshot.addedCell } : null); + setUnlockedPowers({ ...snapshot.unlockedPowers }); + setUsedPowers({ ...snapshot.usedPowers }); + setUndoStack((current) => current.slice(0, -1)); + setUndosUsed((count) => count + 1); + setActivePower(null); + setSelectedBankIndex(null); + setMoveFrom(null); + }; + + const rerollCurrentRoll = () => { + if (!canReroll || !currentRoll) return; + if (inClassroom) { + classroom.reroll(); + return; + } + const dice: [number, number] = [randomDie(), randomDie()]; + setLocalHistory((history) => + history.map((roll, index) => + index === usedRolls + ? { + ...roll, + dice, + sum: dice[0] + dice[1], + rolledAt: Date.now(), + } + : roll, + ), + ); + setLocalRerollsUsed((count) => count + 1); + }; + + const openLevelDialog = () => { + setDraftSettings(settings); + setLevelDialogOpen(true); + }; + + const applyLevel = () => { + if (inClassroom && (!classroom.isHost || levelLocked)) return; + const nextSettings = { + gridSize: Math.max(5, Math.min(9, Math.round(draftSettings.gridSize))), + undoLimit: Math.max(0, Math.min(9, Math.round(draftSettings.undoLimit))), + rerollLimit: Math.max( + 0, + Math.min(9, Math.round(draftSettings.rerollLimit)), + ), + }; + setSettings(nextSettings); + setDraftSettings(nextSettings); + setLocalHistory([]); + resetPrivateBoard(nextSettings); + if (inClassroom) classroom.configure(nextSettings); + setLevelDialogOpen(false); + }; + + const handleReset = () => { + if (inClassroom) classroom.reset(); + else { + setLocalHistory([]); + resetPrivateBoard(settings); + } + }; + + const leaveClassroom = () => { + setQrDialogOpen(false); + classroom.leaveRoom(); + roundRef.current = null; + classroomSettingsKeyRef.current = ""; + setLocalHistory([]); + resetPrivateBoard(settings); + }; + + const copyInvite = async () => { + try { + await navigator.clipboard.writeText(getInviteUrl()); + setCopied(true); + window.setTimeout(() => setCopied(false), 1600); + } catch { + setCopied(false); + } + }; + + const runBenchmark = () => { + benchmarkAttemptRef.current += 1; + const searchPass = benchmarkAttemptRef.current; + const completedRolls = activeHistory.slice(0, usedRolls); + setIsBenchmarking(true); + window.setTimeout(() => { + const result = findBenchmark( + completedRolls, + settings.gridSize, + searchPass, + ); + setBenchmark((current) => + !current || result.score > current.score ? result : current, + ); + setIsBenchmarking(false); + }, 40); + }; + + useEffect(() => { + if (!endgame || !benchmarkRollKey) return; + benchmarkAttemptRef.current = 0; + const completedRolls = activeHistory.slice(0, usedRolls); + setIsBenchmarking(true); + const timer = window.setTimeout(() => { + setBenchmark(findBenchmark(completedRolls, settings.gridSize)); + setIsBenchmarking(false); + }, 40); + return () => window.clearTimeout(timer); + }, [benchmarkRollKey, endgame, settings.gridSize]); + + const startPower = (power: Power) => { + setActivePower((current) => (current === power ? null : power)); + setSelectedBankIndex(null); + setMoveFrom(null); + }; + + const rollButtonText = () => { + if (inClassroom && !classroom.isHost) return "Waiting for the facilitator"; + if (classroom.status === "reconnecting") return "Reconnecting…"; + if (inClassroom) + return currentRoll + ? `Place ${currentRoll.sum} first` + : "Roll for everyone"; + if (boardFull) return "Board complete"; + if (currentRoll) return `Place ${currentRoll.sum} first`; + return "Roll the dice"; + }; + + const actionRefs = useRef({ + roll: { enabled: false, run: () => {} }, + bank: { enabled: false, run: () => {} }, + undo: { enabled: false, run: () => {} }, + reroll: { enabled: false, run: () => {} }, + }); + actionRefs.current = { + roll: { enabled: canRoll, run: requestRoll }, + bank: { enabled: canBank, run: bankCurrentRoll }, + undo: { enabled: canUndo, run: undoLastMove }, + reroll: { enabled: canReroll, run: rerollCurrentRoll }, + }; + + useEffect(() => { + const handleShortcut = (event: KeyboardEvent) => { + if (event.repeat || event.metaKey || event.ctrlKey || event.altKey) + return; + const target = event.target as HTMLElement | null; + if ( + target?.closest("input, textarea, select, [contenteditable='true']") || + document.querySelector( + "[data-slot='dialog-content'][data-state='open'], [data-slot='alert-dialog-content'][data-state='open']", + ) + ) + return; + + const key = event.key.toLowerCase(); + const action = + key === "r" && event.shiftKey + ? actionRefs.current.reroll + : key === "r" + ? actionRefs.current.roll + : key === "b" + ? actionRefs.current.bank + : key === "u" + ? actionRefs.current.undo + : null; + if (!action?.enabled) return; + event.preventDefault(); + action.run(); + }; + window.addEventListener("keydown", handleShortcut); + return () => window.removeEventListener("keydown", handleShortcut); + }, []); + + const edgeButton = (edge: Edge, index: number) => { + const isAddedHere = addedCell?.edge === edge && addedCell.index === index; + if (isAddedHere) { + return ( +
+ {addedCell.value} +
+ ); + } + const enabled = + activePower === "add" && selectedBankIndex !== null && !addedCell; + return ( + + ); + }; + + return ( + <> + + +
{ + event.preventDefault(); + applyLevel(); + }} + className="contents" + > + + Set your level + + Choose the board and your safety nets before the first roll. + Classroom facilitators set these for everyone. + + + +
+ Grid size +
+ {Array.from({ length: 5 }, (_, index) => index + 5).map( + (size) => ( + + ), + )} +
+
+ +
+
+
+ + + {draftSettings.undoLimit} + +
+ + setDraftSettings((current) => ({ + ...current, + undoLimit, + })) + } + aria-label="Undo tokens" + aria-valuetext={`${draftSettings.undoLimit} undo tokens`} + className="min-h-11" + /> +
+ +
+
+ + + {draftSettings.rerollLimit} + +
+ + setDraftSettings((current) => ({ + ...current, + rerollLimit, + })) + } + aria-label="Reroll tokens" + aria-valuetext={`${draftSettings.rerollLimit} reroll tokens`} + className="min-h-11" + /> +
+
+ + + + +
+
+
+ + + + + Join classroom {classroom.roomId} + + Put this on the projector. Scanning the code opens this game and + joins the synchronized dice stream directly. + + +
+
+ {qrDataUrl ? ( + {`QR + ) : qrError ? ( +

+ {qrError} +

+ ) : ( + + )} +
+
+
Game ID
+
+ {classroom.roomId} +
+
+
+ + + +
+
+ +
+
+
+
+

+ Akkam Bakkam +

+ அக்கம் பக்கம் +
+

+ Place each dice sum carefully. Equal neighbors make links, and + every link scores its number. +

+
+ + {settings.gridSize}×{settings.gridSize} + + + {settings.undoLimit}{" "} + {settings.undoLimit === 1 ? "undo" : "undos"} + + + {settings.rerollLimit}{" "} + {settings.rerollLimit === 1 ? "reroll" : "rerolls"} + + + {levelLocked && ( + + Start a new round to change the level. + + )} +
+
+ + + +
+
+ + Classroom sync + + + One facilitator rolls; everyone receives the same sequence + and plays a private board. + +
+ {inClassroom && ( + + + {classroom.status === "connected" ? "Live" : "Reconnecting"} + + )} +
+
+ + {!inClassroom ? ( +
+ +
+ +
+ + setJoinCode( + event.target.value + .toUpperCase() + .replace(/[^A-Z0-9]/g, "") + .slice(0, 6), + ) + } + onKeyDown={(event) => + event.key === "Enter" && + joinCode.length === 6 && + classroom.joinRoom(joinCode) + } + placeholder="ABC234" + autoComplete="off" + className="h-11 font-mono uppercase tracking-widest" + /> + +
+
+
+ ) : ( +
+
+ + Game ID + + + {classroom.roomId} + +
+ + {classroom.isHost && } + {classroom.playerName} + {classroom.isHost ? " · Facilitator" : ""} + + + {classroom.participants} connected + +
+ + + +
+
+ )} + {classroom.error && ( +

+ {classroom.error} +

+ )} +
+
+ +
+ + +
+
+ Akkam · scoring grid + + Tap an empty square to place the current sum. + +
+
+
+
+ Score +
+
+ {score} +
+
+
+
+ Rolls +
+
+ {activeHistory.length} +
+
+
+
+
+ +
+
+ {currentRoll ? ( + <> + + + + = {currentRoll.sum} + + + ) : ( + + No sum waiting to be placed + + )} +
+ +
+
+ + +
+ {inClassroom && ( +

+ Undo only changes your private board. The facilitator can + reroll the shared current sum after the class agrees. +

+ )} + {queuedRolls > 0 && ( +

+ {queuedRolls} more synchronized{" "} + {queuedRolls === 1 ? "roll is" : "rolls are"} waiting. +

+ )} + + {activePower && ( +
+ {activePower === "replace" && + (selectedBankIndex === null + ? "Choose a number from Pakkam." + : "Now choose the Akkam cell to overwrite.")} + {activePower === "move" && + (moveFrom === null + ? "Choose the first Akkam cell." + : "Choose another cell to swap with it.")} + {activePower === "add" && + (selectedBankIndex === null + ? "Choose a number from Pakkam." + : "Choose one of the dotted edge spaces.")} +
+ )} + +
= 8 ? "gap-1" : "gap-1.5 sm:gap-2", + )} + style={{ + gridTemplateColumns: `repeat(${settings.gridSize + 2}, minmax(0, 1fr))`, + width: + settings.gridSize >= 7 + ? "min(100%, max(19rem, calc(100dvh - 22rem)))" + : "min(100%, 36rem)", + }} + > + {scoreLines(links, settings.gridSize, true)} +
+ {Array.from({ length: settings.gridSize }, (_, index) => ( +
{edgeButton("top", index)}
+ ))} +
+ {Array.from({ length: settings.gridSize }, (_, row) => ( +
+
{edgeButton("left", row)}
+ {Array.from( + { length: settings.gridSize }, + (_, column) => { + const index = row * settings.gridSize + column; + const value = board[index]; + const canPlace = + Boolean(currentRoll) && + value === null && + !boardFull && + !activePower; + const canUsePower = + activePower === "move" || + (activePower === "replace" && + selectedBankIndex !== null); + return ( + + ); + }, + )} +
{edgeButton("right", row)}
+
+ ))} +
+ {Array.from({ length: settings.gridSize }, (_, index) => ( +
+ {edgeButton("bottom", index)} +
+ ))} +
+
+ + {endgame && ( +
+
+

+ Endgame powers +

+

+ Each unlocked power can be used once. Replace and Add + spend a banked number. +

+
+
+ + + +
+
+ )} + + + +
+ + + Pakkam · bank + + Fills bottom to top. The top three slots always unlock R, M, + and A. + + + +
= 7 + ? "grid grid-cols-3 sm:flex" + : "flex", + )} + > + {Array.from( + { length: settings.gridSize }, + (_, index) => settings.gridSize - 1 - index, + ).map((bankIndex) => { + const selectable = + endgame && + (activePower === "replace" || activePower === "add") && + bank[bankIndex] !== null; + return ( + + ); + })} +
+ +
+
+ + {inClassroom && ( + + + + Leaderboard + + + Scores and progress only; boards stay private. + + + +
    + {classroom.leaderboard.map((player, index) => ( +
  1. + + {index + 1} + + + {player.name} + {player.id === classroom.playerId ? " (you)" : ""} + + + + {player.placed} placed + + {player.score} + {player.finished && ( + + )} + +
  2. + ))} +
+
+
+ )} + + {endgame && ( + + + + Round solution + + + The strongest square arrangement found from the sums you + used this round. The outside Add cell is a bonus and is + not included. + + + + {!benchmark && isBenchmarking && ( +
+ + Arranging this round’s sums… +
+ )} + {benchmark && benchmarkScoring && ( + <> +
+
+
+ Your grid +
+ + {squareScore} + +
+
+
+ {benchmark.provenOptimal + ? "Maximum" + : "Best found"} +
+ + {benchmark.score} + +
+
+
+ + {benchmark.score > 0 + ? Math.round( + (100 * squareScore) / benchmark.score, + ) + : 100} + % + +
+ of{" "} + {benchmark.provenOptimal ? "maximum" : "best found"} +
+
+
+ {scoreLines( + benchmarkScoring.links, + settings.gridSize, + )} + {benchmark.board.map((value, index) => ( +
+ {value} +
+ ))} +
+ {benchmark.provenOptimal ? ( +

+ Proven theoretical maximum: {benchmark.ceiling} +

+ ) : ( +
+

+ Rigorous theoretical ceiling: {benchmark.ceiling}. + The displayed solution is{" "} + {benchmark.ceiling - benchmark.score} points below + it, so it is not claimed as the exact maximum. +

+ +
+ )} + + )} +
+
+ )} +
+
+ +
+ + + + + + + Start a new round? + + {inClassroom + ? "This clears every player’s private board and the classroom leaderboard." + : "This clears your board, bank, and roll history."} + + + + Keep playing + + Start new round + + + + + + Shortcuts: R roll, B bank, U{" "} + undo, ⇧R reroll + +
+ + + + How to play + + +
+

+ Place every roll.{" "} + Roll two dice, add them, then put the sum in any empty Akkam + cell or in the lowest open Pakkam slot. You cannot skip a sum. +

+

+ Make links. Equal + values touching horizontally, vertically, or diagonally score + that value. A cluster scores once for every adjacent pair. +

+
+
+

+ Unlock powers.{" "} + The lower Pakkam slots only hold numbers. Its top three slots + unlock Replace, Move, and Add, each usable once after the{" "} + {settings.gridSize}×{settings.gridSize} grid is full. +

+

+ + Finish creatively. + {" "} + Replace overwrites a cell using a banked number; Move swaps + two grid cells for free; Add places one banked number in a new + cell along an edge. +

+
+
+
+ + +
+
+ + ); +}; + +export default AkkamBakkamGame; diff --git a/src/hooks/useClassroomDice.ts b/src/hooks/useClassroomDice.ts new file mode 100644 index 0000000..a968a2c --- /dev/null +++ b/src/hooks/useClassroomDice.ts @@ -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(initialState); + const socketRef = useRef(null); + const reconnectTimerRef = useRef(null); + const manualCloseRef = useRef(false); + const desiredRoomRef = useRef(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, + }; +}; diff --git a/src/lib/interactive-components.ts b/src/lib/interactive-components.ts index b6e924a..ef2d5c0 100644 --- a/src/lib/interactive-components.ts +++ b/src/lib/interactive-components.ts @@ -3,6 +3,7 @@ import type { ComponentType } from "react"; type LazyImport = () => Promise<{ default: ComponentType }>; export const componentMap: Record = { + "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"), diff --git a/src/lib/interactives.ts b/src/lib/interactives.ts index f1f2ebe..e8e718b 100644 --- a/src/lib/interactives.ts +++ b/src/lib/interactives.ts @@ -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", diff --git a/src/styles/global.css b/src/styles/global.css index a54b7b1..2e9821e 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -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 */ @@ -407,4 +421,4 @@ body { position: relative; overflow-x: hidden; -} \ No newline at end of file +}