308 lines
16 KiB
JavaScript
308 lines
16 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("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("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("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 !== "online"\) 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, /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("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("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 \{[^}]*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(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("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], [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):/);
|
||
});
|