"use strict"; const test = require("node:test"); const assert = require("node:assert/strict"); const fs = require("node:fs"); const path = require("node:path"); const project = path.resolve(__dirname, "../.."); const player = require(path.join(project, "web/player.js")); test("keyboard and swipe controls produce equivalent directions", () => { const cases = [ ["ArrowUp", [50, 80, 50, 20], "north"], ["KeyD", [20, 50, 80, 50], "east"], ["ArrowDown", [50, 20, 50, 80], "south"], ["KeyA", [80, 50, 20, 50], "west"] ]; for (const [key, swipe, expected] of cases) { assert.equal(player.directionFromKey(key), expected); assert.equal(player.directionFromSwipe(...swipe), expected); } assert.equal(player.directionFromSwipe(0, 0, 12, 4), null); }); test("command envelopes contain intentions rather than client-owned state", () => { const move = player.createEnvelope("move", 7, "move-7", {direction: "west"}); assert.deepEqual(move, {v: 1, type: "move", seq: 7, requestId: "move-7", payload: {direction: "west"}}); assert.equal("position" in move.payload, false); assert.deepEqual(player.createEnvelope("drop_key", 8, "drop_key-8", {}), {v: 1, type: "drop_key", seq: 8, requestId: "drop_key-8", payload: {}}); }); test("chunk assembly replaces projection cells without explored-map memory", () => { const assembler = new player.SnapshotAssembler(); const second = {v: 1, type: "view", seq: 20, payload: {role: "player1", width: 8, height: 8, chunk: 1, chunks: 2, cells: [[10, 15]], objects: []}}; const first = {v: 1, type: "view", seq: 20, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 9, inventory: null, visionMoves: 0, hint: "none", chunk: 0, chunks: 2, cells: [[9, 15]], objects: [["key", 0, 0, 10]]}}; assert.equal(assembler.accept(second), null); const complete = assembler.accept(first); assert.deepEqual(player.projectedCellIndices(complete), [9, 10]); const replacement = assembler.accept({v: 1, type: "view", seq: 21, payload: {role: "player1", phase: "active", result: "none", width: 8, height: 8, self: 18, inventory: 0, visionMoves: 9, hint: "ne", chunk: 0, chunks: 1, cells: [[18, 15]], objects: []}}); assert.deepEqual(player.projectedCellIndices(replacement), [18]); assert.equal(replacement.cells.some(cell => cell[0] === 9 || cell[0] === 10), false); assert.equal(assembler.accept({v: 1, type: "view", seq: 22, payload: {chunk: 0, chunks: player.MAX_VIEW_CHUNKS + 1, cells: [], objects: []}}), null); assert.equal(player.snapshotIsValid({width: 128, height: 64, cells: [[8191, 15]], objects: []}), true); assert.equal(player.snapshotIsValid({width: 129, height: 64, cells: [], objects: []}), false); }); test("refresh contract restores only the token and waits for server projection", () => { const restore = player.createEnvelope("restore", 1, "restore-1", {token: "0123456789abcdef0123456789abcdef"}); assert.deepEqual(Object.keys(restore.payload), ["token"]); assert.equal("position" in restore.payload, false); assert.equal("inventory" in restore.payload, false); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); assert.equal((script.match(/localStorage\.setItem/g) || []).length, 1); assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/); }); test("player assets are local, Russian, accessible, and responsive", () => { const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8"); const all = `${html}\n${css}\n${script}\n${icons}`; assert.doesNotMatch(all, /(?:src|href)=["']https?:|url\(["']?https?:/i); assert.doesNotMatch(all, /@import|googleapis|analytics/i); assert.match(html, /lang="ru"/); assert.match(html, /aria-label="Сбросить ключ"/); assert.match(html, /Кусты уже|Сбор заблудившихся/); assert.match(css, /@media \(max-width: 720px\)/); assert.match(css, /@media \(min-width: 721px\) and \(max-width: 980px\)/); assert.match(css, /height: calc\(100dvh - 78px\)/); assert.match(css, /\.dpad \{ display: none; \}/); assert.match(css, /\(hover: none\) and \(pointer: coarse\) and \(max-height: 720px\)/); assert.match(icons, /id="key"/); assert.match(icons, /id="maze"/); }); test("observer view fits large screens and bounds zoom", () => { assert.equal(player.observerFitScale(1024, 600, 128, 64), .96); assert.equal(player.clampObserverZoom(.01), .25); assert.equal(player.clampObserverZoom(20), 6); const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); assert.match(html, /Режим наблюдателя · только чтение/); assert.match(html, /Нужен экран побольше/); assert.match(html, /Зрительный зал открывается от 1024 × 768/); assert.match(css, /@media \(max-width: 1023px\), \(max-height: 767px\)/); assert.match(css, /\.observer-stage \{[^}]*overflow: hidden/); assert.match(script, /snapshot\.role === "observer"/); assert.match(script, /state\.session\.role === "observer" \? "observer" : "match"/); assert.match(script, /!player\.connected \? "нет связи"/); assert.match(script, /addEventListener\("wheel"/); assert.match(script, /addEventListener\("pointermove"/); }); test("observer protocol exposes no mutation control path", () => { const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const observerSection = html.match(/
/)[0]; assert.doesNotMatch(observerSection, /data-direction|drop-key|start-match/); const observer = {v: 1, type: "view", seq: 30, payload: {role: "observer", phase: "active", result: "none", width: 2, height: 2, players: [{cell: 0, key: null, exited: 0, connected: 1}, {cell: 3, key: 1, exited: 0, connected: 1}], chunk: 0, chunks: 1, cells: [[0, 6], [1, 12], [2, 3], [3, 9]], objects: []}}; const snapshot = new player.SnapshotAssembler().accept(observer); assert.equal(snapshot.role, "observer"); assert.deepEqual(snapshot.players.map(item => item.cell), [0, 3]); assert.equal("self" in snapshot, false); }); test("finished matches expose only an owner restart intention", () => { const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); assert.match(script, /send\("restart", \{\}, byId\("modal-action"\)\)/); assert.match(script, /message\.type === "reset"/); assert.doesNotMatch(script, /restart[^\n]*(position|inventory|result):/); });