feat(ui): implement responsive player browser experience

This commit is contained in:
2026-09-04 21:57:17 +03:00
parent 9339578dc9
commit d042c3ce90
17 changed files with 926 additions and 152 deletions
+1
View File
@@ -200,6 +200,7 @@ static void serialize_and_inspect(const maze32_view_t* view, int expect_hidden)
assert(payload[written] == '\0');
assert(strstr(payload, "{\"v\":1,\"type\":\"view\",\"seq\":") == payload);
assert(strstr(payload, "\"phase\":\"countdown\"") != NULL);
assert(strstr(payload, "\"width\":8,\"height\":8") != NULL);
saw_last_cell |= strstr(payload, "[63,") != NULL;
saw_hidden_key |= strstr(payload, "[\"key\",1,0,63]") != NULL;
saw_players |= strstr(payload, "\"players\":[") != NULL;
+76
View File
@@ -0,0 +1,76 @@
"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("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"/);
});