"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("avatar drag produces only cardinal, bounded movement intentions", () => { assert.equal(player.avatarDirection(10, 10, null), null); assert.equal(player.avatarDirection(44, 8, null), "east"); assert.equal(player.avatarDirection(-8, -44, null), "north"); assert.equal(player.avatarDirection(42, 38, "east"), "east"); assert.equal(player.avatarDirection(38, 42, "north"), "south"); const vector = player.avatarVector(300, 0, 48, null); assert.equal(vector.direction, "east"); assert.equal(vector.x, player.AVATAR_MAX_DEFLECTION); assert.equal(vector.y, 0); assert.ok(vector.distance <= player.AVATAR_MAX_DEFLECTION); }); test("avatar cadence is monotonic and remains slower than cooldown", () => { assert.equal(player.avatarRepeatMs(player.AVATAR_DEAD_ZONE, 48), null); const near = player.avatarRepeatMs(18, 48); const far = player.avatarRepeatMs(48, 48); assert.ok(near > far); assert.ok(far >= player.AVATAR_FAST_REPEAT_MS); assert.ok(player.AVATAR_FAST_REPEAT_MS > 150); }); test("latest movement intent coalesces while one command is in flight", () => { const dispatched = []; const flow = new player.LatestIntentFlow(direction => { const requestId = `move-${dispatched.length + 1}`; dispatched.push({direction, requestId}); return requestId; }); assert.equal(flow.request("east"), "move-1"); assert.equal(flow.request("north"), null); assert.equal(flow.request("west"), null); assert.deepEqual(dispatched.map(item => item.direction), ["east"]); flow.complete("move-1"); assert.deepEqual(dispatched.map(item => item.direction), ["east", "west"]); flow.release(); flow.complete("move-2"); assert.equal(flow.inFlight, null); assert.equal(flow.latest, null); }); test("directional pad repeat remains slower than the authoritative cooldown", () => { assert.ok(player.DIRECTIONAL_PAD_REPEAT_MS > 150); const dispatched = []; const flow = new player.LatestIntentFlow((direction, element) => { dispatched.push({direction, element}); return "move-1"; }); const button = {id: "north"}; flow.request("north", button); flow.request("east", {id: "east"}); assert.deepEqual(flow.latest, {direction: "east", element: {id: "east"}}); flow.release(); assert.equal(flow.latest, null); assert.deepEqual(dispatched, [{direction: "north", element: button}]); }); test("latency correlation is bounded and only completes matching authoritative views", () => { let clock = 0; const tracker = new player.LatencyTracker(() => clock); tracker.sent("move-1"); clock = 12; tracker.acknowledged("move-1", 41); clock = 16; tracker.painted(40); assert.equal(tracker.snapshot().length, 0); clock = 24; tracker.painted(41); assert.deepEqual(tracker.snapshot(), [{requestId: "move-1", commandToAckMs: 12, commandToPaintMs: 24, ackToPaintMs: 12}]); for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) { tracker.sent(`move-${index + 2}`); } assert.ok(tracker.pending.size <= player.LATENCY_SAMPLE_LIMIT); for (let index = 0; index < player.LATENCY_SAMPLE_LIMIT + 4; index++) { clock++; tracker.pointerPainted(0); } assert.equal(tracker.pointerSnapshot().length, player.LATENCY_SAMPLE_LIMIT); }); 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("authoritative self cell remains below the centered avatar at map edges", () => { assert.deepEqual(player.playerMapOffset(0, 7, 0, 0, 7, 7, 46), {x: 138, y: 138}); assert.deepEqual(player.playerMapOffset(48, 7, 0, 0, 7, 7, 46), {x: -138, y: -138}); assert.deepEqual(player.playerMapOffset(24, 7, 1, 1, 5, 5, 46), {x: 0, y: 0}); assert.deepEqual(player.playerMapOffset(-1, 7, 0, 0, 7, 7, 46), {x: 0, y: 0}); }); test("responsive player focal point stays above phone controls and composes with map offsets", () => { assert.deepEqual(player.playerFocalPoint(400, 800), {x: 200, y: 240}); assert.deepEqual(player.playerFocalPoint(900, 400), {x: 378, y: 180}); assert.deepEqual(player.playerFocalMapOffset(24, 7, 1, 1, 5, 5, 46, 400, 800), {x: 0, y: -160}); assert.deepEqual(player.playerFocalMapOffset(0, 7, 0, 0, 7, 7, 46, 400, 800), {x: 138, y: -22}); }); 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("countdown completion leaves help closed until the player requests it", () => { const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8"); const activeView = script.match(/function renderSnapshot\(snapshot\) \{([\s\S]*?)\n \}/)[1]; assert.match(activeView, /setScreen\("match"\)/); assert.match(activeView, /setDirectionalPadEnabled\(snapshot\.phase === "active"/); assert.match(activeView, /else hideModal\(\);/); assert.doesNotMatch(activeView, /showOnboarding\(0\)/); assert.match(script, /byId\("settings-help"\)\.addEventListener\("click", \(\) => \{ closeSettings\(false\); showOnboarding\(0\); \}\)/); assert.match(script, /byId\("onboarding-skip"\)\.addEventListener\("click", hideOnboarding\)/); assert.match(script, /if \(next == null\) hideOnboarding\(\)/); }); test("avatar control resets safely and retains 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", stopAvatarControl\)/); assert.match(script, /addEventListener\("pointercancel", stopAvatarControl\)/); assert.match(script, /addEventListener\("lostpointercapture", stopAvatarControl\)/); assert.match(script, /addEventListener\("blur", resetPlayerControls\)/); assert.match(script, /connectionState === "offline"\) \{ resetPlayerControls\(\);/); assert.match(script, /snapshot\.phase === "paused"\) \{ resetPlayerControls\(\)/); assert.match(script, /state\.avatar\.startX = event\.clientX/); assert.match(script, /event\.clientX - state\.avatar\.startX/); assert.match(script, /windowObject\.addEventListener\("resize", refreshPlayerFocalPoint\)/); assert.match(script, /windowObject\.addEventListener\("orientationchange", refreshPlayerFocalPoint\)/); assert.match(script, /setMuted\(!state\.muted\)/); assert.match(script, /setReducedMotion\(!state\.reducedMotion\)/); assert.match(html, /id="avatar-control"/); assert.match(html, /aria-label="Тянуть героя для движения"/); assert.match(script, /settings-help/); assert.match(html, /id="onboarding-dots"[\s\S]*?<\/span><\/span><\/span><\/span><\/span><\/span><\/span><\/span>/); }); test("playful feedback uses bounded maze-only effects and a top message rail", () => { 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, /
\s*