Files
maze32/test/web/test_player_app.js
T

434 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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\("onboarding-replay"\)\.addEventListener\("click", \(\) => 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, /onboarding-replay/);
assert.match(html, /id="onboarding-dots"[\s\S]*?<span><\/span><span><\/span><span><\/span><span><\/span><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, /<main id="app">\s*<div class="message-rail" id="notice" role="status" aria-live="polite" aria-atomic="true" hidden><\/div>\s*<header/);
assert.doesNotMatch(html, /wall-bump-text/);
assert.match(css, /\.maze\.impact-north/);
assert.match(css, /\.maze\.rate-limited \{ filter: brightness\(\.48\) blur\(2px\)/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\[class\*="impact-"\] \{ animation: none;/);
assert.match(css, /\[data-reduced-motion="true"\] \.maze\.rate-limited \{ transition: none;/);
assert.match(css, /\.message-rail \{ position: fixed; top:/);
assert.match(script, /moveDirections: new Map\(\)/);
assert.match(script, /if \(message\.payload\.code === "blocked"\) \{ showImpact\(direction, message\.payload\.breakthroughHits\); feedback\(false\); return; \}/);
assert.match(css, /\.cell\.bush-impact/);
assert.match(css, /\.hedge-layer/);
assert.match(css, /\.hedge-edge \{[^}]*height: calc\(var\(--cell\) \* \.2\)/);
assert.match(css, /\.hedge-edge\.hedge-vertical \{[^}]*left: calc\(var\(--hedge-x\) \* var\(--cell\) - var\(--cell\) \* \.5\);[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.4\)/);
assert.match(css, /\.observer-maze \.hedge-edge\.hedge-vertical \{[^}]*top: calc\(var\(--hedge-y\) \* var\(--cell\) \+ var\(--cell\) \* \.5 - 1px\)/);
assert.match(css, /\.cell::after \{[^}]*background: #1c1b27;[^}]*opacity: \.55/);
assert.match(script, /projectedHedgeEdges/);
assert.match(script, /svg\.setAttribute\("viewBox", "0 0 160 40"\)/);
assert.match(script, /svg\.setAttribute\("preserveAspectRatio", "none"\)/);
assert.match(script, /\/hedges\.svg#/);
assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
});
test("mobile directional pad is explicit, bounded, and resettable", () => {
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");
for (const label of ["Двигаться вверх", "Двигаться вниз", "Двигаться влево", "Двигаться вправо"]) {
assert.match(html, new RegExp(`aria-label="${label}"`));
}
assert.match(html, /class="dpad-center" aria-hidden="true"/);
assert.match(html, /data-direction="north"[^>]*disabled/);
assert.match(css, /\.dpad \{[^}]*grid-template-rows: repeat\(3, 58px\)/);
assert.match(css, /\.dpad button:disabled/);
assert.match(css, /\.dpad button\.active/);
assert.match(css, /\.dpad button\.pending/);
assert.match(css, /\.dpad \{[^}]*touch-action: none/);
assert.match(css, /\.dpad button \{[^}]*touch-action: none/);
assert.match(css, /grid-template-columns: repeat\(3, 62px\)/);
assert.match(script, /function resetDirectionalPad\(\)/);
assert.match(script, /addEventListener\("pointercancel", stopDirectionalPad\)/);
assert.match(script, /addEventListener\("lostpointercapture", stopDirectionalPad\)/);
assert.match(script, /windowObject\.addEventListener\("blur", resetPlayerControls\)/);
assert.match(script, /state\.moveFlow\.request\(direction, element\)/);
});
test("mobile welcome and lobby screens scroll while an active match remains fixed", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const mobileRules = css.slice(
css.indexOf("@media (max-width: 720px)"),
css.indexOf("@media (min-width: 721px)")
);
assert.doesNotMatch(mobileRules, /html, body \{[^}]*overflow: hidden/);
assert.doesNotMatch(mobileRules, /^ #app \{[^}]*height: 100dvh[^}]*overflow: hidden/m);
assert.match(mobileRules, /body\.match-active \{ height: 100%; overflow: hidden; \}/);
assert.match(mobileRules, /body\.match-active #app \{ height: 100dvh; min-height: 0; overflow: hidden; \}/);
assert.match(mobileRules, /#app \{ padding: max\(8px, env\(safe-area-inset-top\)\).*max\(8px, env\(safe-area-inset-bottom\)\)/);
assert.match(css, /\.match-screen \{[^}]*overflow: hidden/);
assert.match(css, /\.map-shell \{[^}]*touch-action: none/);
assert.match(script, /documentObject\.body\.classList\.toggle\("match-active", name === "match"\)/);
for (const viewport of [[412, 915], [412, 916], [320, 568], [568, 320]]) {
assert.ok(viewport[0] > 0 && viewport[1] > 0);
}
});
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, 0]], 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, 2]], 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, 0]], 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, 0]], 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("hedge edges are canonical, bounded, and deterministic", () => {
const cells = [[0, 0, 0], [1, 0, 0], [2, 0, 0], [3, 0, 0]];
const first = player.projectedHedgeEdges(cells, 2);
const second = player.projectedHedgeEdges(cells, 2);
assert.deepEqual(first, second);
assert.equal(new Set(first.map(edge => edge.key)).size, first.length);
assert.ok(first.length <= cells.length * 2 + 2 * 2);
assert.equal(player.hedgeVariant(first[0], 6), player.hedgeVariant(first[0], 6));
const opened = player.projectedHedgeEdges([[0, 0, 2], [1, 0, 8]], 2);
assert.equal(opened.filter(edge => edge.opened).length, 1);
assert.equal(player.hedgeKeyForMove(0, 2, "east"), "v:1:0");
});
test("breakthrough travel is bounded and starts only from an opened authoritative edge", () => {
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
assert.match(script, /function breakthroughDirection\(previous, snapshot\)/);
assert.match(script, /projectedHedgeEdges\(snapshot\.cells, snapshot\.width\)\.some\(edge => edge\.key === key && edge\.opened\)/);
assert.match(script, /if \(direction\) showBreakthroughTravel\(direction\);\n else stopBreakthroughTravel\(\);/);
assert.match(script, /for \(let index = 0; index < 4; index\+\+\)/);
assert.match(script, /setTimeout\(stopBreakthroughTravel, 480\)/);
assert.match(script, /if \(reduced\) stopBreakthroughTravel\(\);/);
assert.match(script, /if \(name !== "match"\) stopBreakthroughTravel\(\);/);
assert.match(css, /\.breakthrough-runner \{[^}]*pointer-events: none[^}]*480ms/);
assert.match(css, /@keyframes breakthrough-run/);
assert.match(css, /\[data-reduced-motion="true"\] \.breakthrough-runner/);
});
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 \{[^}]*grid-template-columns: repeat\(3, 58px\)/);
assert.match(html, /id="avatar-control"/);
assert.match(html, /id="onboarding"/);
assert.match(html, /id="mute-toggle"/);
assert.match(css, /\.avatar-control \{[^}]*width: 96px; height: 96px/);
assert.match(css, /\.avatar-handle \{[^}]*transition: transform/);
assert.match(css, /\.avatar-handle \{[^}]*background: transparent/);
assert.match(script, /resetAvatarControl/);
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("reference-derived SVG gameplay symbols preserve local yellow and blue ownership", () => {
const icons = fs.readFileSync(path.join(project, "web/icons.svg"), "utf8");
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
const playerUi = fs.readFileSync(path.join(project, "docs/PLAYER_UI.md"), "utf8");
for (const iconId of ["player", "key", "exit-closed", "crystal", "exit-open", "spikes"]) {
assert.match(icons, new RegExp(`<symbol id="${iconId}" viewBox="0 0 64 64">`));
}
assert.doesNotMatch(icons, /<(?:image|foreignObject)\b/i);
assert.doesNotMatch(icons, /data:image\//i);
assert.match(script, /icon\(variant \? "exit-open" : "exit-closed", "object object-exit"\)/);
assert.match(script, /icon\("crystal", "object object-vision"\)/);
assert.match(script, /icon\("player", `pawn/);
assert.match(html, /\/icons\.svg#spikes/);
assert.match(css, /--p1: #ffd33d/);
assert.match(css, /--p2: #41b6ff/);
assert.match(css, /--warning: #ffad38/);
assert.match(css, /--pause: #9b8cff/);
assert.match(css, /\.pawn-owner-two \{ border-style: dashed; \}/);
assert.match(html, /Жёлтый/);
assert.match(html, /Синий/);
assert.match(playerUi, /`player`, `key`, `exit-closed`,\n`crystal`, `exit-open`, and `spikes`/);
});
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(/<section class="screen observer-screen"[\s\S]*?<\/section>/)[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, 0], [1, 12, 0], [2, 3, 0], [3, 9, 0]], 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):/);
});