155 lines
8.6 KiB
JavaScript
155 lines
8.6 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("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: 128, height: 64, cells: [[8191, 15]], objects: []}), true);
|
||
assert.equal(player.snapshotIsValid({width: 129, height: 64, 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(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, 128, 64), .96);
|
||
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):/);
|
||
});
|