"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("joystick produces only cardinal, bounded movement intentions", () => { assert.equal(player.joystickDirection(10, 10, null), null); assert.equal(player.joystickDirection(44, 8, null), "east"); assert.equal(player.joystickDirection(-8, -44, null), "north"); assert.equal(player.joystickDirection(42, 38, "east"), "east"); assert.equal(player.joystickDirection(38, 42, "north"), "south"); const vector = player.joystickVector(300, 0, 57, null); assert.equal(vector.direction, "east"); assert.equal(vector.x, 57); assert.equal(vector.y, 0); }); test("joystick repeat interval is monotonic and remains slower than cooldown", () => { assert.equal(player.joystickRepeatMs(player.JOYSTICK_DEAD_ZONE, 60), null); const near = player.joystickRepeatMs(25, 60); const far = player.joystickRepeatMs(60, 60); assert.ok(near > far); assert.ok(far >= player.JOYSTICK_FAST_REPEAT_MS); assert.ok(player.JOYSTICK_FAST_REPEAT_MS > 150); }); test("player map cells enlarge small projections without exceeding a readable bound", () => { const sevenBySeven = player.playerCellSize(396, 730, 7, 7); const fourByFour = player.playerCellSize(396, 730, 4, 4); assert.ok(sevenBySeven >= 46); assert.ok(fourByFour > sevenBySeven); assert.ok(fourByFour <= 72); }); test("onboarding has bounded, replayable steps", () => { assert.equal(player.ONBOARDING_STEPS.length, 8); assert.equal(player.nextOnboardingStep(0), 1); assert.equal(player.nextOnboardingStep(6), 7); assert.equal(player.nextOnboardingStep(7), null); }); test("child controls reset safely and retain accessible alternatives", () => { const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8"); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); assert.match(script, /addEventListener\("pointerup", stopJoystick\)/); assert.match(script, /addEventListener\("pointercancel", stopJoystick\)/); assert.match(script, /addEventListener\("blur", resetJoystick\)/); assert.match(script, /connectionState !== "online"\) resetJoystick\(\)/); assert.match(script, /snapshot\.phase === "paused"\) \{ resetJoystick\(\)/); assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(script, /onboarding-replay/); assert.match(html, /id="onboarding-dots"[\s\S]*?<\/span><\/span><\/span><\/span><\/span><\/span><\/span><\/span>/); }); test("frontend name suggestions randomize both words and remain bounded", () => { const picks = [3, 4]; const suggestion = player.randomSuggestedName([], () => picks.shift()); assert.equal(suggestion, "Смелый-Кактус"); assert.match(suggestion, /^[^-]+-[^-]+$/u); assert.ok(Buffer.byteLength(suggestion, "utf8") <= 32); assert.equal(player.NAME_COMBINATION_COUNT, 144); const occupied = "Бодрый-Барсук"; const skipped = player.randomSuggestedName([occupied], () => 0); assert.notEqual(skipped, occupied); assert.equal(skipped, "Бодрый-Крот"); const allNames = []; for (let adjective = 0; adjective < 12; adjective++) { for (let noun = 0; noun < 12; noun++) { const pair = [adjective, noun]; allNames.push(player.randomSuggestedName([], () => pair.shift())); } } assert.equal(player.randomSuggestedName(allNames, () => 0), null); assert.equal(player.randomIndex(12, () => 99), 0); const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); assert.doesNotMatch(script, /suggest_name|name_suggestion/); }); test("login headline selection is randomizable, Russian, and has a fallback", () => { assert.ok(player.LOGIN_HEADLINES.length >= 8); const selected = player.randomLoginHeadline(() => 5); assert.deepEqual(selected, {first: "Крот сказал", highlight: "что это срез"}); assert.equal(player.randomLoginHeadline(() => -1), player.LOGIN_HEADLINES[0]); for (const headline of player.LOGIN_HEADLINES) { assert.match(headline.first, /[А-Яа-яЁё]/u); assert.match(headline.highlight, /[А-Яа-яЁё]/u); } const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); assert.match(script, /renderWelcomeHeadline\(randomLoginHeadline\(\)\)/); assert.match(script, /title\.replaceChildren/); }); 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: 32, height: 32, cells: [[1023, 15]], objects: []}), true); assert.equal(player.snapshotIsValid({width: 33, height: 32, cells: [], objects: []}), false); assert.equal(player.snapshotIsValid({width: 32, height: 33, 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(html, /id="joystick"/); assert.match(html, /id="onboarding"/); assert.match(html, /id="mute-toggle"/); assert.match(css, /\.joystick \{[^}]*width: 180px; height: 180px/); assert.match(script, /resetJoystick/); assert.match(script, /playerCellSize/); 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, 32, 32), 2.25); 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):/); });