feat(trails): render authoritative player route trails
This commit is contained in:
@@ -201,6 +201,11 @@ waiting lobby. This is distinct from an unexpected disconnect, which preserves
|
||||
the player's reconnect reservation for 60 seconds; observer leave and player
|
||||
leave from `Lobby` revoke only the departing session.
|
||||
|
||||
The game stores a fixed two-bit owner mask for each canonical traversable
|
||||
edge. View construction exposes those masks only for authorized visible edges,
|
||||
and the browser replaces its bounded trail drawing layer from each complete
|
||||
projection.
|
||||
|
||||
## 6. Authoritative command processing
|
||||
|
||||
Every gameplay command follows the same pipeline:
|
||||
|
||||
@@ -221,6 +221,14 @@ Browser controls must support:
|
||||
remaining participants to a fresh waiting lobby. Observer exits and player
|
||||
exits from an already waiting lobby affect only the departing session.
|
||||
|
||||
### Player trails
|
||||
|
||||
- Every successful in-maze player move is recorded authoritatively as a
|
||||
persistent passage trail for the current match. Players receive only trail
|
||||
segments inside their current fog-of-war projection; observers receive both
|
||||
complete trails. Trails clear with the match and never come from browser
|
||||
history.
|
||||
|
||||
## 11. Browser presentation
|
||||
|
||||
- The browser UI uses one dark, humorous retro arcade visual language across
|
||||
|
||||
@@ -3436,7 +3436,7 @@ resources.
|
||||
|
||||
## Milestone 030 — Explicit session exit and slot release
|
||||
|
||||
**Status:** `IN_PROGRESS`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -3644,3 +3644,207 @@ observer departure or a lobby departure must not reset gameplay.
|
||||
flow in the browser and confirmed that the issue was fixed: a remaining
|
||||
participant is returned from the result modal to a usable waiting lobby when
|
||||
the other player explicitly leaves.
|
||||
|
||||
## Milestone 031 — Authoritative persistent player trails
|
||||
|
||||
**Status:** `IN_PROGRESS`
|
||||
|
||||
### Objective
|
||||
|
||||
Show a persistent dotted record of every successful route used by each player
|
||||
during the current match, using the established yellow and blue ownership
|
||||
colors and a mixed two-color treatment for shared passages. Each player may see
|
||||
trail data only inside the current server-approved visibility projection, while
|
||||
observers may see both complete trails. Trails must survive refresh and
|
||||
reconnection without adding client requests or materially increasing ESP,
|
||||
network, or rendering load.
|
||||
|
||||
### Scope
|
||||
|
||||
- Do not start implementation until every preceding milestone is `COMPLETE`.
|
||||
Milestone 030 is `IN_PROGRESS` when this plan is created, so this milestone
|
||||
must remain `NOT_STARTED` until Milestone 030 is complete.
|
||||
- Keep trail ownership authoritative on the ESP. A browser-only history cannot
|
||||
provide consistent trails to another player and observers after refresh or
|
||||
reconnection, and must not be used as the source of truth.
|
||||
- Record only accepted authoritative player transitions between cells. Mark
|
||||
ordinary successful moves and the successful fifth-impact movement through a
|
||||
newly opened hedge. Do not mark blocked, rejected, rate-limited, stale,
|
||||
queued-but-superseded, or otherwise unsuccessful movement attempts.
|
||||
- Represent an undirected traversed cell edge with a compact two-bit ownership
|
||||
mask: Player 1, Player 2, both, or neither. Use a fixed-size per-match layout
|
||||
derived from the bounded maze dimensions; do not allocate per step, store an
|
||||
ordered movement history, or create a collection that grows with match
|
||||
duration.
|
||||
- Preserve the trail for the complete current match, including countdown-to-
|
||||
active lifecycle, pause, normal reconnect, and page refresh. Clear it exactly
|
||||
when authoritative match state is reset, cancelled, replaced by a new match,
|
||||
or cleared by device reboot.
|
||||
- Piggyback compact trail ownership on the existing role-appropriate view
|
||||
projection and sequencing path. Do not add polling, a new browser request,
|
||||
an extra message per movement, or a second trail-specific broadcast.
|
||||
- Send player clients only the trail edges whose endpoints and passage are
|
||||
inside their current authorized projection. Never serialize trail
|
||||
coordinates outside the current visibility radius, and do not let the
|
||||
browser retain or reconstruct hidden maze geometry from past projections.
|
||||
- Send observers the complete authoritative trail together with their existing
|
||||
full-map projection. Preserve observer read-only behavior and existing
|
||||
snapshot chunk, message-size, queue, and backpressure bounds.
|
||||
- Reconstruct the visible trail entirely in the frontend from each complete
|
||||
server-approved projection. Prefer a fixed small number of batched SVG paths
|
||||
or an equivalently bounded drawing layer rather than one DOM element per dot,
|
||||
step, or historical event.
|
||||
- Render a dotted yellow route for Player 1 and a dotted blue route for Player
|
||||
2. When both players used the same passage, render an unmistakable mixed
|
||||
yellow-and-blue dotted pattern on the same centerline rather than overwriting
|
||||
one owner or blending them into an unrelated third color.
|
||||
- Draw trails along traversable passage centers below players, keys, artifacts,
|
||||
exits, doors, feedback, and controls but above the quiet floor. Trails must
|
||||
not resemble walls, obscure objects, imply that a closed hedge is passable,
|
||||
or expose a breakthrough until its opened edge is present in the same
|
||||
authoritative projection.
|
||||
- Keep current-player and other-player ownership understandable without color
|
||||
alone by using a documented dot rhythm, outline, alternation, or equivalent
|
||||
secondary cue consistent with the established player icon system. Shared
|
||||
edges must remain recognizable in grayscale and at supported cell sizes.
|
||||
- Preserve server-authoritative movement, fog of war, match results, explicit
|
||||
session exit, reconnect behavior, compact controls, connection-quality
|
||||
sampling, reduced-motion behavior, offline operation, Russian player-facing
|
||||
copy, and firmware and LittleFS budgets.
|
||||
|
||||
### Deliverables
|
||||
|
||||
- Updated trail-state, visibility, protocol, lifecycle, ownership, layering,
|
||||
and responsive-rendering contracts in `MVP.md`, `ARCHITECTURE.md`,
|
||||
`docs/PROTOCOL.md`, `docs/UI_STYLE.md`, and `docs/PLAYER_UI.md`.
|
||||
- A fixed-size authoritative two-player traversed-edge representation with
|
||||
deterministic initialization, successful-move updates, reconnect retention,
|
||||
and match-reset cleanup.
|
||||
- A compact backward-safe view encoding that carries only role-authorized
|
||||
trail ownership inside existing view chunks and broadcasts.
|
||||
- A bounded player and observer trail renderer with yellow, blue, and mixed
|
||||
two-color dotted treatments and no per-move accumulation of DOM nodes,
|
||||
timers, animations, or client history.
|
||||
- Deterministic game, protocol, visibility, serialization, browser, rendering,
|
||||
lifecycle, reconnect, backpressure, size, and regression tests.
|
||||
- Before-and-after maximum-session traffic, queue, heap, render-time,
|
||||
compressed-web-asset, firmware, LittleFS, and representative physical-device
|
||||
visual-verification evidence.
|
||||
|
||||
### Acceptance criteria
|
||||
|
||||
1. Every accepted authoritative in-maze move marks exactly one undirected edge
|
||||
for the moving player's ownership. Ordinary passage moves and a successful
|
||||
hedge breakthrough are marked; blocked, rejected, stale, duplicate,
|
||||
rate-limited, and superseded attempts leave the trail unchanged.
|
||||
2. Trail storage has a documented compile-time maximum derived from the bounded
|
||||
maze cell count and consumes a fixed number of bits per possible canonical
|
||||
edge and player. Repeated traversal, loops, backtracking, and arbitrarily long
|
||||
matches cannot grow heap use or trail state.
|
||||
3. Player 1 traversal produces the established yellow dotted treatment, Player
|
||||
2 traversal produces the established blue dotted treatment, and traversal
|
||||
of the same edge by both players produces one clearly mixed yellow-and-blue
|
||||
pattern that preserves both ownership meanings without a third blended
|
||||
color.
|
||||
4. A player projection contains and renders trail ownership only for passages
|
||||
fully inside that player's current server-approved visibility. It contains
|
||||
no coordinates, ownership bits, inferred continuation, or retained drawing
|
||||
outside the current projection, including edges previously visible to that
|
||||
browser.
|
||||
5. Each player sees both players' trails when those used passages are inside the
|
||||
current permitted projection. The opponent trail cannot reveal the
|
||||
opponent's current or historical route outside that projection.
|
||||
6. An observer receives and renders the complete authoritative trails of both
|
||||
players across the full maze, including shared edges, without gaining any
|
||||
command authority or changing player projections.
|
||||
7. Refreshing or reconnecting a player or observer during the same match
|
||||
reconstructs the correct role-authorized trail from the fresh authoritative
|
||||
view without browser-persisted trail history. Pause and resume do not add,
|
||||
remove, duplicate, or reorder trail ownership.
|
||||
8. A normal match reset, explicit player-leave cancellation, completed-match
|
||||
restart, new maze, or device reboot clears all trail ownership before the
|
||||
next match view. Session restoration alone never clears it.
|
||||
9. Trail data is carried only in the existing view sequencing and chunking
|
||||
flow. The implementation introduces no trail polling, new periodic request,
|
||||
per-move trail message, additional broadcast pass, or independent retry
|
||||
queue, and incomplete or stale view chunks never partially update a trail.
|
||||
10. Every encoded view remains within the existing 512-byte WebSocket message
|
||||
limit and published chunk-count and queue bounds. Deterministic worst-case
|
||||
player and observer projections with every edge owned by both players
|
||||
serialize successfully without hidden-state disclosure.
|
||||
11. The frontend uses a documented fixed upper bound of drawing layers and DOM
|
||||
nodes independent of move count. Projection replacement removes obsolete
|
||||
geometry, and refresh, reconnect, reset, leave, result, and screen changes
|
||||
leave no stale paths, timers, or retained hidden trail data.
|
||||
12. Trails follow the center of actual open or server-opened passages and sit
|
||||
below all players, objects, exits, hedge art, breakthrough effects, and
|
||||
controls. They do not create overflow, cover interaction targets, resemble
|
||||
walls, or make an intact hedge appear open.
|
||||
13. Yellow, blue, and shared trail states remain distinguishable at 34, 40, and
|
||||
46 CSS-pixel cells, in normal color and grayscale, and on supported narrow
|
||||
portrait, compact-landscape, tablet, and observer layouts without relying
|
||||
on sound, vibration, animation, or color alone.
|
||||
14. Under the maximum supported two-player/four-observer load and a worst-case
|
||||
fully marked maze, accepted movement latency, outgoing queue high-water,
|
||||
dropped player messages, heap minima, observer responsiveness, and browser
|
||||
rendering remain within the established Milestone 010 and Milestone 017
|
||||
budgets with no material regression attributable to trail processing.
|
||||
15. Automated game, protocol, browser, and host tests cover both owners, shared
|
||||
edges, repeated traversal, loops, breakthroughs, rejected movement,
|
||||
visibility clipping, observer completeness, chunk boundaries, worst-case
|
||||
size, refresh, reconnect, pause, reset, explicit leave, stale views,
|
||||
layering, cleanup, accessibility, and responsive layouts.
|
||||
16. All host and browser tests pass; compressed-web-asset validation passes;
|
||||
the payload remains within the 1.5 MiB web budget and LittleFS partition;
|
||||
and firmware remains within the factory partition.
|
||||
17. Physical-device testing confirms correct trail continuity during a complete
|
||||
two-player match, refresh and reconnect reconstruction, mixed shared
|
||||
passages, observer completeness, reset cleanup, and stable gameplay with no
|
||||
perceptible input delay, rendering stutter, disconnect, or accumulated
|
||||
resource use.
|
||||
18. The project owner confirms on representative player and observer displays
|
||||
that the dotted trails make used routes easy to follow, shared passages are
|
||||
clearly attributed to both players, fog of war remains intact, and the map
|
||||
stays readable and responsive, or explicitly approves a documented evidence
|
||||
deviation.
|
||||
|
||||
### Evidence
|
||||
|
||||
- On 2026-09-13, the project owner required Player 1 and Player 2 routes to
|
||||
remain visible until the end of the current match, survive refresh and
|
||||
reconnect, and include only successful authoritative movement.
|
||||
- The project owner required each player to see trail data only within the
|
||||
current permitted visibility projection, while observers see both complete
|
||||
trails, and selected a mixed two-color dotted treatment when both players use
|
||||
the same passage.
|
||||
- Planning analysis found that frontend-only state cannot consistently satisfy
|
||||
cross-client, observer, refresh, and reconnect requirements without violating
|
||||
fog of war. The milestone therefore limits ESP state to fixed-size per-edge
|
||||
ownership bits, piggybacks authorized data on existing projections, and
|
||||
delegates all trail drawing to a bounded frontend layer without additional
|
||||
requests or broadcasts.
|
||||
- Implementation and verification have not started, as requested by the
|
||||
project owner.
|
||||
- On 2026-09-13, implementation added a fixed 512-byte packed two-bit owner
|
||||
mask for the at-most 2,048 canonical maze edges. Accepted in-maze movement,
|
||||
including a completed hedge breakthrough, marks its canonical edge; rejected
|
||||
and outward moves do not. Each existing view cell carries only authorized
|
||||
east and south ownership masks, so no trail request, broadcast, or chunk type
|
||||
was added. The browser replaces one non-interactive SVG layer per projection
|
||||
with yellow, blue, and dual-rhythm shared dotted paths.
|
||||
- On 2026-09-13, `bash scripts/run_host_tests.sh` passed all suites, including
|
||||
authoritative shared-edge ownership and visibility coverage. The worst-case
|
||||
observer serialization remained bounded at 88 chunks and 501 / 512 bytes.
|
||||
`node --test test/web/test_player_app.js` passed 32 tests, including trail
|
||||
mask validation and bounded rendering treatment. `python3
|
||||
scripts/verify_web_assets.py` passed with deterministic payload
|
||||
`245f402b654fdec986974d62d7f7cd3190fd79293a36c7c1e289200127d98f73` at
|
||||
33,635 / 1,572,864 bytes. `/home/sasa/.platformio/penv/bin/pio run` passed
|
||||
with firmware at 1,030,784 / 2,097,152 bytes, and `/home/sasa/.platformio/
|
||||
penv/bin/pio run --target buildfs` passed at 2,031,616 / 2,031,616 bytes.
|
||||
- On 2026-09-13, trail presentation was softened without changing protocol or
|
||||
authority: stroke width is 0.065 cells, opacity is 58%, and both ownership
|
||||
rhythms use shorter, more widely spaced dots. `node --test
|
||||
test/web/test_player_app.js`, LittleFS build, and web-asset validation passed
|
||||
with payload `5546c680b4d527815381735ec1e9016e02f0002efa865cb09e45b84e8f697cec`
|
||||
at 33,638 / 1,572,864 bytes.
|
||||
|
||||
@@ -22,9 +22,11 @@ extern "C" {
|
||||
#define MAZE32_GAME_MAX_OBJECTS \
|
||||
(6U + 2U * MAZE32_MAZE_MAX_LANDMARK_PAIRS + 2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
||||
#define MAZE32_GAME_VIEW_CELLS_PER_CHUNK 12U
|
||||
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 8U
|
||||
#define MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS 5U
|
||||
#define MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK 6U
|
||||
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
|
||||
#define MAZE32_GAME_MAX_TRAIL_EDGES (2U * MAZE32_MAZE_MAX_CELLS)
|
||||
#define MAZE32_GAME_TRAIL_BYTES ((MAZE32_GAME_MAX_TRAIL_EDGES + 3U) / 4U)
|
||||
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
|
||||
#define MAZE32_GAME_RUNTIME_BUDGET_MS 50U
|
||||
|
||||
@@ -163,6 +165,7 @@ typedef struct {
|
||||
maze32_game_exit_t exits[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t consumed_artifacts[MAZE32_GAME_MAX_ARTIFACTS];
|
||||
uint8_t opened_passages[MAZE32_MAZE_MAX_CELLS];
|
||||
uint8_t trail_owners[MAZE32_GAME_TRAIL_BYTES];
|
||||
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
|
||||
uint8_t command_head;
|
||||
uint8_t command_count;
|
||||
@@ -189,6 +192,8 @@ typedef struct {
|
||||
uint16_t cell_index;
|
||||
uint8_t passages;
|
||||
uint8_t opened_passages;
|
||||
uint8_t trail_east;
|
||||
uint8_t trail_south;
|
||||
} maze32_view_cell_t;
|
||||
|
||||
typedef struct {
|
||||
|
||||
@@ -68,6 +68,36 @@ static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
|
||||
return (game_passages(game, cell) & passage) != 0U;
|
||||
}
|
||||
|
||||
static uint16_t trail_edge_index(const maze32_maze_t* maze, maze32_cell_t cell,
|
||||
maze32_passage_t passage) {
|
||||
uint16_t horizontal_count = (uint16_t)(maze->config.height * (maze->config.width - 1U));
|
||||
if (passage == MAZE32_PASSAGE_EAST) {
|
||||
return (uint16_t)(cell.y * (maze->config.width - 1U) + cell.x);
|
||||
}
|
||||
return (uint16_t)(horizontal_count + cell.y * maze->config.width + cell.x);
|
||||
}
|
||||
|
||||
static uint8_t trail_owner_mask(const maze32_game_t* game, uint16_t edge_index) {
|
||||
uint8_t byte = game->trail_owners[edge_index / 4U];
|
||||
return (uint8_t)((byte >> ((edge_index % 4U) * 2U)) & 0x03U);
|
||||
}
|
||||
|
||||
static void mark_trail_edge(maze32_game_t* game, uint8_t player_index, maze32_cell_t origin,
|
||||
maze32_game_direction_t direction) {
|
||||
maze32_cell_t canonical = origin;
|
||||
maze32_passage_t passage = direction_passage(direction);
|
||||
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
|
||||
canonical.y--;
|
||||
passage = MAZE32_PASSAGE_SOUTH;
|
||||
} else if (direction == MAZE32_GAME_DIRECTION_WEST) {
|
||||
canonical.x--;
|
||||
passage = MAZE32_PASSAGE_EAST;
|
||||
}
|
||||
uint16_t edge_index = trail_edge_index(game->maze, canonical, passage);
|
||||
uint8_t shift = (uint8_t)((edge_index % 4U) * 2U);
|
||||
game->trail_owners[edge_index / 4U] |= (uint8_t)((1U << player_index) << shift);
|
||||
}
|
||||
|
||||
static void reset_perseverance(maze32_game_player_t* player) {
|
||||
player->perseverance_hits = 0U;
|
||||
player->perseverance_last_impact_ms = 0U;
|
||||
@@ -518,6 +548,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
|
||||
}
|
||||
finish_outward_move(game, player_index, command.submitted_ms, &processed);
|
||||
} else {
|
||||
maze32_cell_t origin = player->position;
|
||||
maze32_passage_t passage = direction_passage(command.direction);
|
||||
if (!game_has_passage(game, player->position, passage)) {
|
||||
processed.breakthrough_hits = record_wall_impact(
|
||||
@@ -544,6 +575,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
|
||||
return processed;
|
||||
}
|
||||
player->position = moved_cell(player->position, command.direction);
|
||||
mark_trail_edge(game, player_index, origin, command.direction);
|
||||
accept_player_move(player, command.submitted_ms);
|
||||
processed.moved = 1U;
|
||||
resolve_cell_entry(game, player_index, &processed);
|
||||
@@ -670,6 +702,20 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
|
||||
view->cells[view->cell_count].cell_index = index;
|
||||
view->cells[view->cell_count].passages = game_passages(game, cell);
|
||||
view->cells[view->cell_count].opened_passages = game->opened_passages[index];
|
||||
if (x + 1U < game->maze->config.width) {
|
||||
maze32_cell_t east = {x + 1U, y};
|
||||
if (reveal_all || is_visible(center, east, radius)) {
|
||||
view->cells[view->cell_count].trail_east = trail_owner_mask(
|
||||
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_EAST));
|
||||
}
|
||||
}
|
||||
if (y + 1U < game->maze->config.height) {
|
||||
maze32_cell_t south = {x, y + 1U};
|
||||
if (reveal_all || is_visible(center, south, radius)) {
|
||||
view->cells[view->cell_count].trail_south = trail_owner_mask(
|
||||
game, trail_edge_index(game->maze, cell, MAZE32_PASSAGE_SOUTH));
|
||||
}
|
||||
}
|
||||
view->cell_count++;
|
||||
}
|
||||
}
|
||||
@@ -861,9 +907,9 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
||||
}
|
||||
for (size_t index = first; index < end; ++index) {
|
||||
const maze32_view_cell_t* cell = &view->cells[index];
|
||||
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u]",
|
||||
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u,%u,%u]",
|
||||
index == first ? "" : ",", cell->cell_index, cell->passages,
|
||||
cell->opened_passages)) {
|
||||
cell->opened_passages, cell->trail_east, cell->trail_south)) {
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -94,6 +94,10 @@ received cells. It hashes each visible edge deterministically to choose the
|
||||
approved local SVG variant, so the browser neither retains unseen geometry nor
|
||||
decides topology. The layer is decorative and cannot receive input.
|
||||
|
||||
Each complete projection also replaces one bounded SVG trail layer. It draws
|
||||
only server-authorized visible east/south passage masks: yellow and blue use
|
||||
different dotted rhythms, and a shared passage retains both patterns.
|
||||
|
||||
All remaining Russian system messages use one `role=status` live message rail
|
||||
above the application top bar. One connection-loss message has highest priority
|
||||
and remains until a successful recovery; other messages deduplicate, replace
|
||||
|
||||
+3
-1
@@ -56,7 +56,9 @@ is only echoed for correlation; it grants no authority.
|
||||
- `active`: the exact authoritative activation instant.
|
||||
- `view`: a bounded chunk of a role-specific authoritative game projection.
|
||||
Observer chunks contain the complete maze and both player records, including
|
||||
their connection state; player chunks retain the fog-of-war boundary.
|
||||
their connection state; player chunks retain the fog-of-war boundary. Each
|
||||
projected cell carries compact Player 1 and Player 2 trail ownership masks
|
||||
for its east and south edges only when both edge endpoints are authorized.
|
||||
- `pause`: identifies the disconnected player slot and its monotonic reconnect
|
||||
deadline.
|
||||
- `resume`: reports that both players are attached and authoritative gameplay
|
||||
|
||||
@@ -110,6 +110,9 @@ are not MVP requirements.
|
||||
It opens a confirmation modal; a player match, including a finished result,
|
||||
warns that it returns everyone to the waiting lobby, while observer and
|
||||
waiting-lobby departures explain that only this browser's place is released.
|
||||
- Trails are dotted passage-center lines beneath maze objects and hedges: Player
|
||||
1 uses the established yellow dot rhythm, Player 2 uses a distinct blue
|
||||
rhythm, and shared passages draw both rhythms on one centerline.
|
||||
- A compact response indicator between the connection state and gear shows a
|
||||
rounded total response time or an explicit unknown, stale, or offline state.
|
||||
Its accessible description and settings details expose `Отклик`, `Связь`,
|
||||
|
||||
@@ -324,12 +324,48 @@ static void test_default_map_chunk_bounds(void) {
|
||||
(unsigned int)chunks, (unsigned int)largest_chunk);
|
||||
}
|
||||
|
||||
static void test_authoritative_trails_and_visibility(void) {
|
||||
maze32_maze_t maze;
|
||||
open_test_grid(&maze);
|
||||
maze.starts[1] = (maze32_cell_t){2U, 1U};
|
||||
maze32_game_t game = initialized_game(&maze);
|
||||
maze32_game_set_phase(&game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
uint32_t sequence = 0U;
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST,
|
||||
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST,
|
||||
200U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
|
||||
maze32_view_t observer;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &observer) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
uint16_t origin = index_of(1U, 1U);
|
||||
int found = 0;
|
||||
for (uint16_t index = 0U; index < observer.cell_count; ++index) {
|
||||
if (observer.cells[index].cell_index == origin) {
|
||||
assert(observer.cells[index].trail_east == 3U);
|
||||
found = 1;
|
||||
}
|
||||
}
|
||||
assert(found);
|
||||
maze32_view_t player;
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_PLAYER_ONE, &player) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
for (uint16_t index = 0U; index < player.cell_count; ++index) {
|
||||
assert(player.cells[index].trail_east <= 3U && player.cells[index].trail_south <= 3U);
|
||||
}
|
||||
puts("authoritative trail ownership and visibility: PASS");
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_movement_cooldown_and_ordering();
|
||||
test_bounded_queue();
|
||||
test_shared_cell_meeting();
|
||||
test_visibility_and_serialization();
|
||||
test_default_map_chunk_bounds();
|
||||
test_authoritative_trails_and_visibility();
|
||||
puts("game core tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -507,3 +507,17 @@ test("finished-player exit returns remaining browsers to the waiting lobby", ()
|
||||
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/);
|
||||
});
|
||||
|
||||
test("authoritative trail masks render bounded yellow, blue, and shared paths", () => {
|
||||
const script = fs.readFileSync(path.join(project, "web/player.js"), "utf8");
|
||||
const css = fs.readFileSync(path.join(project, "web/player.css"), "utf8");
|
||||
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 3, 0]], objects: []}), true);
|
||||
assert.equal(player.snapshotIsValid({width: 2, height: 2, cells: [[0, 6, 0, 4, 0]], objects: []}), false);
|
||||
assert.match(script, /function renderTrails\(/);
|
||||
assert.match(script, /if \(owners & 1\) paths\[0\]\.push/);
|
||||
assert.match(script, /if \(owners & 2\) paths\[1\]\.push/);
|
||||
assert.match(script, /renderTrails\(maze, snapshot\.cells/);
|
||||
assert.match(css, /\.trail-layer \{[^}]*pointer-events: none/);
|
||||
assert.match(css, /\.trail-player-one \{ stroke: var\(--p1\); stroke-dasharray/);
|
||||
assert.match(css, /\.trail-player-two \{ stroke: var\(--p2\); stroke-dasharray/);
|
||||
});
|
||||
|
||||
@@ -123,6 +123,10 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.maze { position: absolute; left: 50%; top: 50%; display: grid; transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px))); }
|
||||
.cell { position: relative; width: var(--cell); height: var(--cell); background: #11131a; }
|
||||
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
|
||||
.trail-layer { position: absolute; z-index: 0; inset: 0; width: 100%; height: 100%; overflow: visible; pointer-events: none; }
|
||||
.trail-path { fill: none; stroke-width: .065; stroke-linecap: round; opacity: .58; }
|
||||
.trail-player-one { stroke: var(--p1); stroke-dasharray: .06 .20; }
|
||||
.trail-player-two { stroke: var(--p2); stroke-dasharray: .03 .16; stroke-dashoffset: .08; }
|
||||
.hedge-layer { position: absolute; z-index: 1; inset: 0; pointer-events: none; overflow: visible; }
|
||||
.hedge-edge { position: absolute; width: var(--cell); height: calc(var(--cell) * .2); left: calc(var(--hedge-x) * var(--cell)); top: calc(var(--hedge-y) * var(--cell) - var(--cell) * .1); transform-origin: 50% 50%; pointer-events: none; }
|
||||
.hedge-edge svg { display: block; width: 100%; height: 100%; overflow: visible; }
|
||||
|
||||
+31
-1
@@ -306,7 +306,7 @@
|
||||
return false;
|
||||
}
|
||||
const cellLimit = snapshot.width * snapshot.height;
|
||||
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 3 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
|
||||
return snapshot.cells.every(cell => Array.isArray(cell) && (cell.length === 3 || cell.length === 5) && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 15 && Number.isInteger(cell[2]) && cell[2] >= 0 && cell[2] <= 15 && (cell.length === 3 || (Number.isInteger(cell[3]) && cell[3] >= 0 && cell[3] <= 3 && Number.isInteger(cell[4]) && cell[4] >= 0 && cell[4] <= 3))) && snapshot.objects.every(object => Array.isArray(object) && object.length === 4 && Number.isInteger(object[3]) && object[3] >= 0 && object[3] < cellLimit);
|
||||
}
|
||||
|
||||
class SnapshotAssembler {
|
||||
@@ -781,6 +781,34 @@
|
||||
return slot === 2 ? {name: "Синий герой", keyName: "синего игрока", owner: "two"} : {name: "Жёлтый герой", keyName: "жёлтого игрока", owner: "one"};
|
||||
}
|
||||
|
||||
function renderTrails(maze, cells, width, minX, minY, columns, rows) {
|
||||
const layer = documentObject.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
layer.classList.add("trail-layer");
|
||||
layer.setAttribute("viewBox", `0 0 ${columns} ${rows}`);
|
||||
layer.setAttribute("preserveAspectRatio", "none");
|
||||
layer.setAttribute("aria-hidden", "true");
|
||||
const paths = [[], []];
|
||||
cells.forEach(cell => {
|
||||
if (!Array.isArray(cell) || cell.length !== 5) return;
|
||||
const x = cell[0] % width - minX;
|
||||
const y = Math.floor(cell[0] / width) - minY;
|
||||
[[cell[3], x + 1, y], [cell[4], x, y + 1]].forEach(([owners, endX, endY]) => {
|
||||
if (!owners) return;
|
||||
const segment = `M ${x + .5} ${y + .5} L ${endX + .5} ${endY + .5}`;
|
||||
if (owners & 1) paths[0].push(segment);
|
||||
if (owners & 2) paths[1].push(segment);
|
||||
});
|
||||
});
|
||||
paths.forEach((segments, owner) => {
|
||||
if (!segments.length) return;
|
||||
const path = documentObject.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
path.classList.add("trail-path", owner === 0 ? "trail-player-one" : "trail-player-two");
|
||||
path.setAttribute("d", segments.join(" "));
|
||||
layer.appendChild(path);
|
||||
});
|
||||
maze.appendChild(layer);
|
||||
}
|
||||
|
||||
function renderMap(snapshot) {
|
||||
const maze = byId("maze");
|
||||
maze.replaceChildren();
|
||||
@@ -816,6 +844,7 @@
|
||||
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
renderTrails(maze, snapshot.cells, snapshot.width, minX, minY, columns, rows);
|
||||
renderHedgeEdges(maze, snapshot.cells, snapshot.width, minX, minY);
|
||||
byId("map-placeholder").hidden = true;
|
||||
}
|
||||
@@ -900,6 +929,7 @@
|
||||
});
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
renderTrails(maze, snapshot.cells, snapshot.width, 0, 0, snapshot.width, snapshot.height);
|
||||
renderHedgeEdges(maze, snapshot.cells, snapshot.width, 0, 0);
|
||||
players.forEach((player, index) => {
|
||||
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||
|
||||
Reference in New Issue
Block a user