510 lines
29 KiB
JavaScript
510 lines
29 KiB
JavaScript
"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("connection quality uses one correlated heartbeat and bounded local smoothing", () => {
|
||
let clock = 0;
|
||
const quality = new player.ConnectionQuality(() => clock);
|
||
assert.equal(quality.begin("heartbeat-1"), true);
|
||
assert.equal(quality.begin("heartbeat-2"), false);
|
||
clock = 85;
|
||
assert.equal(quality.acknowledge("wrong", 2), false);
|
||
assert.equal(quality.acknowledge("heartbeat-1", 2), true);
|
||
assert.deepEqual(quality.latest, {totalMs: 85, communicationMs: 83, handlingMs: 2, received: 85});
|
||
for (let index = 0; index < player.QUALITY_SAMPLE_LIMIT + 2; index++) {
|
||
clock += 10;
|
||
assert.equal(quality.begin(`heartbeat-${index + 2}`), true);
|
||
clock += 20;
|
||
assert.equal(quality.acknowledge(`heartbeat-${index + 2}`, 1), true);
|
||
}
|
||
assert.equal(quality.samples.length, player.QUALITY_SAMPLE_LIMIT);
|
||
assert.equal(quality.acknowledge("heartbeat-99", 1001), false);
|
||
clock += player.HEARTBEAT_STALE_MS + 1;
|
||
assert.equal(quality.status(), "stale");
|
||
quality.reset();
|
||
assert.equal(quality.status(), "unknown");
|
||
});
|
||
|
||
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><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, 2);
|
||
assert.match(script, /localStorage\.setItem\(TOKEN_KEY, message\.payload\.token\)/);
|
||
assert.match(script, /localStorage\.setItem\(NAME_KEY, byId\("player-name"\)\.value\.slice\(0, 32\)\)/);
|
||
});
|
||
|
||
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="settings-mute"/);
|
||
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("one compact settings popover replaces standalone secondary controls", () => {
|
||
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, /id="settings-toggle"[^>]*aria-controls="settings-menu"[^>]*aria-expanded="false"[^>]*aria-label="Открыть настройки"/);
|
||
for (const identifier of ["settings-menu", "details-text", "settings-mute", "settings-motion", "settings-help", "settings-wifi"]) {
|
||
assert.match(html, new RegExp(`id="${identifier}"`));
|
||
}
|
||
assert.doesNotMatch(html, /id="details-toggle"|id="details-panel"|id="mute-toggle"|id="motion-toggle"|id="onboarding-replay"/);
|
||
assert.doesNotMatch(html, /access-controls|access-button/);
|
||
assert.match(css, /\.settings-menu \{[^}]*max-height: calc\(100dvh/);
|
||
assert.match(css, /\.settings-item\[aria-pressed="true"\]/);
|
||
assert.match(script, /function openSettings\(\)/);
|
||
assert.match(script, /function closeSettings\(restoreFocus\)/);
|
||
assert.match(script, /event\.key === "Escape" && !byId\("settings-menu"\)\.hidden/);
|
||
assert.match(script, /!menu\.hidden && !menu\.contains\(event\.target\) && !trigger\.contains\(event\.target\)/);
|
||
assert.match(script, /\["settings-mute", "settings-motion", "settings-help"\]\.forEach\(id => \{ byId\(id\)\.hidden = name !== "match"; \}\)/);
|
||
assert.match(script, /button\.textContent = muted \? "Звук: выкл" : "Звук: вкл"/);
|
||
assert.match(script, /button\.textContent = reduced \? "Движение: выкл" : "Движение: вкл"/);
|
||
});
|
||
|
||
test("connection quality stays compact and exposes an honest response split", () => {
|
||
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, /id="quality"[^>]*aria-label="Отклик неизвестен"/);
|
||
assert.match(html, /id="quality-details"/);
|
||
assert.match(css, /\.quality-pill \{[^}]*min-width: 48px/);
|
||
assert.match(script, /send\("heartbeat", \{\}, null\)/);
|
||
assert.match(script, /communicationMs: Math\.max\(0, smoothedMs - handlingMs\)/);
|
||
assert.match(script, /message\.payload\.command === "heartbeat"/);
|
||
assert.match(script, /documentObject\.hidden/);
|
||
});
|
||
|
||
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):/);
|
||
});
|
||
|
||
test("finished-player exit returns remaining browsers to the waiting lobby", () => {
|
||
const html = fs.readFileSync(path.join(project, "web/index.html"), "utf8");
|
||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||
const gateway = fs.readFileSync(path.join(project, "components/maze32_web/maze32_gateway.c"), "utf8");
|
||
const leaveHandler = gateway.match(/if \(message->type == MAZE32_MESSAGE_LEAVE\) \{[\s\S]*?if \(message->type == MAZE32_MESSAGE_START\)/)[0];
|
||
|
||
assert.match(html, /id="settings-leave"/);
|
||
assert.match(html, /id="leave-confirmation"/);
|
||
assert.match(script, /\["countdown", "active", "finish_pending", "paused", "finished"\]\.includes\(phase\)/);
|
||
assert.match(script, /message\.type === "reset"[\s\S]*?hideModal\(\); setScreen\("lobby"\)/);
|
||
assert.match(script, /message\.payload\.command === "leave" && message\.requestId === state\.leaveRequest/);
|
||
assert.match(script, /windowObject\.localStorage\.removeItem\(TOKEN_KEY\)/);
|
||
assert.match(script, /setScreen\("welcome"\)/);
|
||
assert.match(leaveHandler, /leaving_role == MAZE32_ROLE_PLAYER &&\s*gateway->lobby\.phase != MAZE32_LOBBY_WAITING;/);
|
||
assert.doesNotMatch(leaveHandler, /gateway->game\.phase != MAZE32_GAME_PHASE_FINISHED/);
|
||
});
|