feat(game): add persistent bush-wall breakthroughs

This commit is contained in:
2026-09-10 23:12:42 +03:00
parent bb5bd73ebd
commit e10ad3acb8
12 changed files with 354 additions and 39 deletions
+7 -6
View File
@@ -219,7 +219,8 @@ conceptual records include:
- `MazeConfig`: width, height, movement cooldown, object-density settings, and
balance thresholds;
- `Maze`: compact cell topology plus landmark identifiers;
- `Maze`: compact generated cell topology plus landmark identifiers;
- `MatchTopology`: fixed-size server-opened passage overlay for one match;
- `PlayerState`: slot, display name, session token reference, position,
inventory, vision effect, connection state, and last accepted move time;
- `KeyState`: owner and either a ground position or carrier;
@@ -335,11 +336,11 @@ at least `1024 x 768` CSS pixels and presents a large-screen requirement message
below that threshold. Responsive layout changes presentation only; they never
change projection content, command validation, or server authority.
The browser may correlate a server rejection only with its matching outstanding
movement intention for presentation feedback. A `blocked` rejection shakes the
already visible projection in that attempted direction, while `rate_limited`
briefly darkens and blurs the maze only. Neither effect changes or derives game
state, and both use bounded, resettable local timers.
The ESP stores each player's bounded perseverance streak and a fixed-size
opened-passage overlay. It alone expires streaks at 3,000 ms, opens both passage
bits, and projects changed passages only in cells already visible to that role.
The browser may correlate a matching server-approved impact stage with an
already visible bush wall; it never derives or opens topology locally.
Player projections are translated only within their received cells so the
authoritative `self` cell aligns with the responsive local focal point. That
+4 -2
View File
@@ -109,8 +109,10 @@ approximately five minutes. There is no match time limit.
simultaneous turns.
- A small server-enforced minimum interval exists between accepted moves. Its
value is configurable for playtesting.
- Rejected movement into a wall does not change position and is not a
successful move.
- A server-accepted impact against the same closed interior wall from the same
cell and direction is tracked per player. The first four impacts do not move
the player; the fifth within three seconds of each previous impact opens the
passage in both cells and completes that move. Perimeter walls never open.
- Players do not block each other and may occupy the same cell.
- A meeting occurs when both players occupy the same cell.
- There are no attacks, health, damage, or other combat mechanics in MVP.
+147 -1
View File
@@ -2271,7 +2271,7 @@ The following planning-time observation motivated the milestone:
## Milestone 022 — Persistent bush-wall breakthrough
**Status:** `NOT_STARTED`
**Status:** `COMPLETE`
### Objective
@@ -2426,3 +2426,149 @@ The following planning-time decisions define this milestone:
always available to both players.
- Implementation, automated verification, physical-device checks, and owner
acceptance are deferred until all preceding milestones are `COMPLETE`.
- 2026-09-10 implementation evidence: `bash scripts/run_host_tests.sh` passed,
including `game persistent bush-wall breakthrough: PASS (five impacts, shared
passage)`. `node --test test/web/test_player_app.js` passed all 23 browser
tests and confirmed the bounded impact and trampled-passage presentation.
## Milestone 023 — Top-down living hedge SVG sample library
**Status:** `NOT_STARTED`
### Objective
Design and obtain project-owner approval for a lightweight SVG sample library
that makes maze walls read as varied living hedges viewed from above, with
recognizable leaves, twigs, depth, and matching damaged and permanently
breached variants for perseverance mode, without integrating the artwork into
the running game in this milestone.
### Scope
- Do not start this milestone until every preceding milestone is `COMPLETE`.
- Treat this as a visual exploration and asset-approval milestone only. Do not
replace the current maze renderer, change gameplay, modify the protocol, or
ship the samples in browser or firmware assets during this milestone.
- Before drawing, add a concise hedge-art contract to `docs/UI_STYLE.md` that
defines top-down perspective, scale, palette, outline weight, contrast,
organic variation, tiling rules, and the relationship between intact,
impacted, and permanently breached bushes.
- Create editable, hand-reviewable SVG source artwork with semantic groups and
stable symbol identifiers. Do not embed raster images, external fonts,
remote resources, scripts, generated metadata, or tool-specific binary data.
- Explore at least six visibly different intact straight-hedge samples. Vary
leaf clusters, branch paths, gaps, highlights, and silhouette rhythm while
keeping every sample unmistakably part of one visual family.
- Include representative joins and boundary situations needed by a cell-based
maze: continuous straight runs, perpendicular corners, crossings of adjacent
wall segments, exposed ends, and shared edges. Samples must rotate or mirror
cleanly for all four cardinal directions without directional lighting errors.
- Make individual leaves and small branch forks distinguishable at the actual
supported maze scales of 34, 40, and 46 CSS pixels per cell. Avoid both flat
green bars and noisy texture that turns into visual static on a phone.
- Use at least three vegetation tones plus restrained branch and shadow tones
to create depth. Maintain an identifiable hedge silhouette without relying
on green hue alone and keep players, keys, doors, passages, and interactive
feedback visually dominant.
- Draw a coherent perseverance sequence for the same representative wall:
intact, four increasing impact stages, the moment of breakthrough, and the
final permanent trampled opening. Branch bending, displaced leaves, broken
twigs, compressed foliage, and the cleared center must evolve consistently
rather than appearing as unrelated pictures.
- Provide at least three final breached-hedge samples with different organic
edge shapes. Each must clearly show a traversable gap from a top-down view,
retain torn or flattened bushes on both sides, and remain recognizable when
rotated or mirrored.
- Keep openings visually aligned to the authoritative cell passage and wide
enough for the player icon without implying a larger traversable area. The
samples must not encode coordinates, hidden passages, objects, ownership, or
other game state.
- Present all concepts together on one neutral comparison sheet and also as
isolated SVG symbols on representative dark maze cells. Include previews at
1x and high-density mobile rendering scales, with no smoothing assumptions
that are unavailable in ordinary browsers.
- Record approximate raw and compressed byte costs for each candidate family
so later integration can select variety without threatening the 1.5 MiB web
asset budget or LittleFS partition. Prefer reusable leaf, twig, shadow, and
damage components over repeated paths where that improves size and clarity.
- Check color-vision robustness, grayscale silhouette, normal contrast, and
reduced-motion suitability. This milestone defines static frames only;
animation timing and runtime particle behavior remain governed by Milestone
022 and are outside this milestone's implementation scope.
- End with an explicit project-owner selection of the preferred intact hedge
family, damage progression, breached variants, density, and palette. Runtime
integration requires a separately planned later milestone after approval.
### Deliverables
- Updated hedge-art and sample-review contract in `docs/UI_STYLE.md`.
- Editable SVG symbol sheet containing intact straight segments, joins, ends,
four impact stages, breakthrough frame, and permanent breached variants.
- Neutral comparison sheet plus representative dark-cell previews at 34, 40,
and 46 CSS pixel cell sizes and normal and high-density mobile scales.
- Brief asset manifest describing symbol identifiers, reuse rules, rotation and
mirroring constraints, accessibility checks, and raw and compressed sizes.
- Recorded project-owner selection and any requested revisions; no browser or
firmware integration changes.
### Acceptance criteria
1. The sample library contains at least six intact straight-hedge variants,
representative continuous runs, corners, shared-edge joins, and exposed
ends, all drawn as editable vector artwork with stable semantic identifiers.
2. Every intact sample resembles a living hedge viewed directly from above and
contains clearly distinguishable leaf clusters and branching structure. No
candidate is merely a solid, gradient, or textured green bar.
3. The candidates form one coherent visual family while showing enough
silhouette, foliage, branch, highlight, and gap variation to prevent obvious
repetition across a representative maze preview.
4. Leaves and primary branch forks remain recognizable at 34, 40, and 46 CSS
pixel cell sizes at 1x rendering. At high density they gain clarity without
changing geometry, producing seams, or becoming disproportionately detailed.
5. Straight segments, corners, joins, and ends connect without visible gaps,
doubled borders, accidental knots, or discontinuous lighting when rotated or
mirrored into every cardinal orientation.
6. At least three vegetation tones plus restrained branch and shadow tones
provide top-down depth. Grayscale and representative color-vision checks
preserve the hedge silhouette and do not reduce the legibility of players,
keys, doors, passages, or feedback.
7. One representative wall has a visually continuous set of intact, impact
stages one through four, breakthrough, and permanent-open frames. Each stage
shows plausible cumulative bending or damage and is distinguishable from
adjacent stages without explanatory text.
8. At least three permanent breached variants show an unambiguous traversable
center with torn, bent, or flattened vegetation on both sides. They remain
understandable from either side after rotation or mirroring and do not look
like an intact or merely shadowed wall.
9. Every breached opening aligns to one authoritative wall passage, is visually
wide enough for the existing player icon, and does not suggest access to an
adjacent closed cell or reveal any hidden maze state.
10. SVG files contain no raster payloads, external dependencies, scripts,
unnecessary editor metadata, or unbounded duplication. The manifest records
raw and compressed size for each candidate family and identifies reusable
components.
11. The comparison sheet shows all candidates on a neutral background and the
preferred candidates on representative dark maze cells at all target sizes.
Review does not require running or modifying the game.
12. No runtime HTML, CSS, JavaScript, firmware, protocol, generated web bundle,
or LittleFS image changes are part of this milestone. Automated checks
confirm SVG validity, local-only dependencies, stable identifiers, and
deterministic optimization output.
13. The project owner selects or explicitly rejects the intact hedge family,
impact sequence, breached variants, density, and palette after reviewing the
sample sheet at representative mobile scale. Any requested revisions are
recorded before the milestone can become `COMPLETE`.
### Evidence
The following planning-time request defines this milestone:
- On 2026-09-10, the project owner requested a future sample-drawing milestone
because maze walls should look like varied living hedges from above rather
than simple green sticks. The owner specifically required visible leaves and
branches plus matching broken-bush samples for permanent breakthroughs in
perseverance mode, and requested planning only with no immediate artwork or
runtime implementation.
- Artwork creation, review previews, validation, owner approval, and any later
runtime integration are deferred. Milestone 022 remains the sole active
milestone at planning time.
@@ -10,6 +10,8 @@ extern "C" {
#endif
#define MAZE32_GAME_PLAYER_COUNT 2U
#define MAZE32_GAME_BREAKTHROUGH_HITS 5U
#define MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS 3000U
#define MAZE32_GAME_COMMAND_CAPACITY 16U
#define MAZE32_GAME_KEY_COUNT 2U
#define MAZE32_GAME_NO_KEY UINT8_MAX
@@ -101,6 +103,10 @@ typedef struct {
uint8_t exited;
uint8_t connected;
uint64_t reconnect_deadline_ms;
maze32_cell_t perseverance_cell;
maze32_game_direction_t perseverance_direction;
uint64_t perseverance_last_impact_ms;
uint8_t perseverance_hits;
} maze32_game_player_t;
typedef enum {
@@ -139,6 +145,8 @@ typedef struct {
uint8_t activated_artifact;
uint8_t opened_exit;
uint8_t outward_exit;
uint8_t breakthrough_hits;
uint8_t breakthrough_opened;
uint8_t finished;
uint8_t key;
uint8_t artifact;
@@ -154,6 +162,7 @@ typedef struct {
maze32_game_key_t keys[MAZE32_GAME_KEY_COUNT];
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];
maze32_game_command_t commands[MAZE32_GAME_COMMAND_CAPACITY];
uint8_t command_head;
uint8_t command_count;
@@ -179,6 +188,7 @@ typedef enum {
typedef struct {
uint16_t cell_index;
uint8_t passages;
uint8_t opened_passages;
} maze32_view_cell_t;
typedef struct {
+85 -4
View File
@@ -20,6 +20,19 @@ static maze32_passage_t direction_passage(maze32_game_direction_t direction) {
return PASSAGES[direction];
}
static maze32_passage_t opposite_passage(maze32_passage_t passage) {
if (passage == MAZE32_PASSAGE_NORTH) {
return MAZE32_PASSAGE_SOUTH;
}
if (passage == MAZE32_PASSAGE_EAST) {
return MAZE32_PASSAGE_WEST;
}
if (passage == MAZE32_PASSAGE_SOUTH) {
return MAZE32_PASSAGE_NORTH;
}
return MAZE32_PASSAGE_EAST;
}
static maze32_cell_t moved_cell(maze32_cell_t cell, maze32_game_direction_t direction) {
if (direction == MAZE32_GAME_DIRECTION_NORTH) {
cell.y--;
@@ -45,6 +58,43 @@ static int cells_equal(maze32_cell_t first, maze32_cell_t second) {
return first.x == second.x && first.y == second.y;
}
static uint8_t game_passages(const maze32_game_t* game, maze32_cell_t cell) {
return (uint8_t)(game->maze->passages[cell_index(game->maze, cell)] |
game->opened_passages[cell_index(game->maze, cell)]);
}
static int game_has_passage(const maze32_game_t* game, maze32_cell_t cell,
maze32_passage_t passage) {
return (game_passages(game, cell) & passage) != 0U;
}
static void reset_perseverance(maze32_game_player_t* player) {
player->perseverance_hits = 0U;
player->perseverance_last_impact_ms = 0U;
}
static uint8_t record_wall_impact(maze32_game_player_t* player, maze32_cell_t cell,
maze32_game_direction_t direction, uint64_t submitted_ms) {
if (player->perseverance_hits == 0U || !cells_equal(player->perseverance_cell, cell) ||
player->perseverance_direction != direction ||
submitted_ms < player->perseverance_last_impact_ms ||
submitted_ms - player->perseverance_last_impact_ms >= MAZE32_GAME_BREAKTHROUGH_TIMEOUT_MS) {
player->perseverance_cell = cell;
player->perseverance_direction = direction;
player->perseverance_hits = 1U;
} else {
player->perseverance_hits++;
}
player->perseverance_last_impact_ms = submitted_ms;
return player->perseverance_hits;
}
static void reset_all_perseverance(maze32_game_t* game) {
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
reset_perseverance(&game->players[player]);
}
}
static int is_visible(maze32_cell_t center, maze32_cell_t cell, uint8_t radius) {
uint16_t dx = center.x > cell.x ? center.x - cell.x : cell.x - center.x;
uint16_t dy = center.y > cell.y ? center.y - cell.y : cell.y - center.y;
@@ -95,6 +145,9 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
void maze32_game_set_phase(maze32_game_t* game, maze32_game_phase_t phase) {
if (game != NULL && phase >= MAZE32_GAME_PHASE_COUNTDOWN &&
phase <= MAZE32_GAME_PHASE_FINISHED) {
if (phase != MAZE32_GAME_PHASE_ACTIVE) {
reset_all_perseverance(game);
}
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
(!game->players[0].connected || !game->players[1].connected)) {
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
@@ -159,6 +212,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
game->paused_started_ms = 0U;
game->command_head = 0U;
game->command_count = 0U;
reset_all_perseverance(game);
game->state_sequence++;
return 1;
}
@@ -176,6 +230,7 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
game->phase = MAZE32_GAME_PHASE_FINISHED;
game->final_result = game->first_exited_player == 0U ? MAZE32_GAME_FINAL_PLAYER_ONE
: MAZE32_GAME_FINAL_PLAYER_TWO;
reset_all_perseverance(game);
game->state_sequence++;
return 1;
}
@@ -219,6 +274,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
}
player->connected = 0U;
reset_perseverance(player);
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
? UINT64_MAX
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
@@ -229,6 +285,7 @@ maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8
game->paused_started_ms = now_ms;
game->command_head = 0U;
game->command_count = 0U;
reset_all_perseverance(game);
}
game->state_sequence++;
return MAZE32_GAME_RESULT_ACCEPTED;
@@ -373,6 +430,7 @@ static void finish_outward_move(maze32_game_t* game, uint8_t player_index, uint6
} else {
game->phase = MAZE32_GAME_PHASE_FINISHED;
game->final_result = MAZE32_GAME_FINAL_DRAW;
reset_all_perseverance(game);
processed->finished = 1U;
}
}
@@ -452,6 +510,7 @@ maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game) {
return processed;
}
if (direction_leaves_map(game->maze, player->position, command.direction)) {
reset_perseverance(player);
if (!cells_equal(player->position, game->maze->exits[player_index]) ||
!game->exits[player_index].open) {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
@@ -460,7 +519,27 @@ 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_passage_t passage = direction_passage(command.direction);
if (!maze32_maze_has_passage(game->maze, player->position, passage)) {
if (!game_has_passage(game, player->position, passage)) {
processed.breakthrough_hits = record_wall_impact(
player, player->position, command.direction, command.submitted_ms);
if (processed.breakthrough_hits < MAZE32_GAME_BREAKTHROUGH_HITS) {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
maze32_cell_t destination = moved_cell(player->position, command.direction);
if (!valid_cell(game->maze, destination)) {
reset_perseverance(player);
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
game->opened_passages[cell_index(game->maze, player->position)] |= passage;
game->opened_passages[cell_index(game->maze, destination)] |= opposite_passage(passage);
processed.breakthrough_opened = 1U;
reset_perseverance(player);
}
if (game_has_passage(game, player->position, passage)) {
reset_perseverance(player);
} else {
processed.result = MAZE32_GAME_RESULT_BLOCKED;
return processed;
}
@@ -589,7 +668,8 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
if (reveal_all || is_visible(center, cell, radius)) {
uint16_t index = cell_index(game->maze, cell);
view->cells[view->cell_count].cell_index = index;
view->cells[view->cell_count].passages = game->maze->passages[index];
view->cells[view->cell_count].passages = game_passages(game, cell);
view->cells[view->cell_count].opened_passages = game->opened_passages[index];
view->cell_count++;
}
}
@@ -781,8 +861,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]", index == first ? "" : ",",
cell->cell_index, cell->passages)) {
if (!append_text(output, output_size, &offset, "%s[%u,%u,%u]",
index == first ? "" : ",", cell->cell_index, cell->passages,
cell->opened_passages)) {
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
}
}
+19
View File
@@ -722,6 +722,20 @@ static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char
send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message);
}
static void send_breakthrough_impact(maze32_gateway_t* gateway, int socket_fd,
const char* request_id, uint8_t hits) {
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
int written = snprintf(response, sizeof(response),
"{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32
",\"requestId\":\"%s\",\"payload\":{\"code\":\"blocked\","
"\"message\":\"Там стена. Очень убедительная\","
"\"breakthroughHits\":%u}}",
next_server_sequence(gateway), request_id, (unsigned int)hits);
if (written >= 0 && (size_t)written < sizeof(response)) {
enqueue(gateway, socket_fd, response);
}
}
static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_t* session,
const maze32_client_message_t* message) {
if (session->role == MAZE32_ROLE_OBSERVER) {
@@ -753,7 +767,12 @@ static void handle_game_command(maze32_gateway_t* gateway, const maze32_session_
if (finished_now) {
complete_match(gateway);
}
if (result == MAZE32_GAME_RESULT_BLOCKED && processed.breakthrough_hits > 0U) {
send_breakthrough_impact(gateway, session->connection_id, message->request_id,
processed.breakthrough_hits);
} else {
send_game_error(gateway, session->connection_id, message->request_id, result);
}
return;
}
char acknowledgement[160] = "";
+6 -7
View File
@@ -56,13 +56,12 @@ a maze boundary, and is recalculated on resize and rotation.
## Feedback and message rail
The browser stores the direction for each outstanding move request. Only after
a matching server `blocked` rejection does it shake the already rendered maze
in the attempted direction for at most 340 ms; it shows no blocked-move text or spike
notice. A matching `rate_limited` rejection darkens and blurs only the maze for
at most 520 ms. Each effect has one restartable timer and is cleared on pause,
result, reset, disconnect, and control reset. Reduced-motion mode uses a static
maze border cue for blocked movement instead of a shake.
The browser stores the direction for each outstanding move request. A matching
server `blocked` rejection may include only that player's approved breakthrough
stage, which bends the already rendered bush wall for at most 340 ms without
blocked-move text. Opened-passage bits in an ordinary cell projection render as
a permanent trampled bush gap. Effects have one restartable timer and clear on
pause, result, reset, disconnect, and control reset. Reduced motion is static.
All remaining Russian system messages use one `role=status` live message rail
above the application top bar. One connection-loss message has highest priority
+6 -6
View File
@@ -127,12 +127,12 @@ are not MVP requirements.
- Player zoom and pan controls are not part of the MVP.
- Use different colors plus labels, outlines, shapes, or icons to distinguish
player ownership and object types.
- A server `blocked` rejection shakes the visible maze opposite the attempted
cardinal movement for at most 340 ms, without text or spike notice. A
`rate_limited` rejection darkens and blurs only the maze for at most 520 ms,
also without text. Repeated feedback restarts one bounded effect of each type.
- Reduced-motion mode replaces the blocked shake with one static directional
border cue; rate-limit feedback remains immediate and non-animated.
- Server-approved breakthrough impacts one through four bend the visible bush
wall with four increasing restrained stages and no text or spike notice. A
fifth accepted move is reflected by a permanent textured trampled-bush gap.
The browser uses one restartable impact timer and no generated particles.
`rate_limited` still darkens and blurs only the maze for at most 520 ms.
- Reduced-motion mode uses static deformation and the same trampled-gap state.
## 8. Responsive behavior
+32
View File
@@ -346,6 +346,37 @@ static void test_victory_and_draw_window(void) {
printf("game outward result window: PASS (200 ms draw boundary, 201 ms winner)\n");
}
static void test_persistent_bush_wall_breakthrough(void) {
maze32_maze_t maze;
open_test_grid(&maze);
maze.passages[index_of(2U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_EAST;
maze.passages[index_of(3U, 2U)] &= (uint8_t)~MAZE32_PASSAGE_WEST;
maze32_game_t game = active_game(&maze);
game.players[0].position = (maze32_cell_t){2U, 2U};
for (uint8_t hit = 1U; hit < MAZE32_GAME_BREAKTHROUGH_HITS; ++hit) {
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, hit * 500U);
assert(result.result == MAZE32_GAME_RESULT_BLOCKED);
assert(result.breakthrough_hits == hit);
assert(!result.breakthrough_opened);
assert(game.players[0].position.x == 2U);
}
maze32_game_command_result_t result =
move_player(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_EAST, 2500U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED && result.breakthrough_opened);
assert(result.breakthrough_hits == MAZE32_GAME_BREAKTHROUGH_HITS);
assert(game.players[0].position.x == 3U);
assert(game.opened_passages[index_of(2U, 2U)] & MAZE32_PASSAGE_EAST);
assert(game.opened_passages[index_of(3U, 2U)] & MAZE32_PASSAGE_WEST);
game.players[1].position = (maze32_cell_t){3U, 2U};
result = move_player(&game, MAZE32_GAME_ROLE_PLAYER_TWO, MAZE32_GAME_DIRECTION_WEST, 2600U);
assert(result.result == MAZE32_GAME_RESULT_ACCEPTED);
assert(game.players[1].position.x == 2U);
printf("game persistent bush-wall breakthrough: PASS (five impacts, shared passage)\n");
}
int main(void) {
test_inventory_drop_and_sabotage();
test_atomic_meeting_return();
@@ -353,6 +384,7 @@ int main(void) {
test_vision_artifact_duration();
test_compass_and_role_projections();
test_victory_and_draw_window();
test_persistent_bush_wall_breakthrough();
puts("game mechanics tests: PASS");
return 0;
}
+8 -6
View File
@@ -165,7 +165,9 @@ test("playful feedback uses bounded maze-only effects and a top message rail", (
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\); feedback\(false\); return; \}/);
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, /\.cell\.trampled-n::before/);
assert.match(script, /if \(message\.payload\.code === "rate_limited"\) \{ showRateLimit\(\); feedback\(false\); return; \}/);
assert.doesNotMatch(script, /Там стена\. Очень убедительная\.|Слишком быстро! Сервер просит немного меньше турбо\./);
});
@@ -243,17 +245,17 @@ test("command envelopes contain intentions rather than client-owned state", () =
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]]}};
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]], objects: []}});
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]], objects: []}), true);
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);
});
@@ -344,7 +346,7 @@ 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 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]);
+10
View File
@@ -118,6 +118,15 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
.cell.wall-s { border-bottom-color: var(--plant); }
.cell.wall-w { border-left-color: var(--plant); }
.cell::after { content: ""; position: absolute; inset: 7px; background: #1c1b27; opacity: .55; }
.cell.trampled-n::before, .cell.trampled-e::before, .cell.trampled-s::before, .cell.trampled-w::before { content: ""; position: absolute; z-index: 2; background: repeating-linear-gradient(90deg, #254d2d 0 4px, #6f8d43 4px 7px); opacity: .9; }
.cell.trampled-n::before { left: 4px; right: 4px; top: -3px; height: 6px; }
.cell.trampled-e::before { top: 4px; right: -3px; bottom: 4px; width: 6px; }
.cell.trampled-s::before { right: 4px; bottom: -3px; left: 4px; height: 6px; }
.cell.trampled-w::before { top: 4px; bottom: 4px; left: -3px; width: 6px; }
.cell.bush-impact { z-index: 5; animation: bush-resist .34s steps(3, end); }
.cell.bush-stage-2 { filter: saturate(1.25); }
.cell.bush-stage-3 { filter: brightness(1.2) saturate(1.4); }
.cell.bush-stage-4 { filter: brightness(1.35) saturate(1.65); }
.object, .pawn { position: absolute; z-index: 3; inset: 16%; display: grid; place-items: center; }
.object svg, .pawn svg { width: 100%; height: 100%; }
.object-key { color: var(--p1); }
@@ -306,6 +315,7 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
@keyframes artifact { 50% { transform: scale(.75); box-shadow: 0 0 18px var(--warning); } }
@keyframes exit-open { 50% { outline: 3px solid var(--success); } }
@keyframes pawn-in { from { transform: scale(.6); } }
@keyframes bush-resist { 50% { transform: translateX(3px) rotate(1deg); } }
@keyframes impact-north { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) - 10px)); } }
@keyframes impact-east { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px) + 10px), calc(-50% + var(--map-offset-y, 0px))); } }
@keyframes impact-south { 50% { transform: translate(calc(-50% + var(--map-offset-x, 0px)), calc(-50% + var(--map-offset-y, 0px) + 10px)); } }
+19 -6
View File
@@ -258,7 +258,7 @@
return false;
}
const cellLimit = snapshot.width * snapshot.height;
return snapshot.cells.every(cell => Array.isArray(cell) && cell.length === 2 && Number.isInteger(cell[0]) && cell[0] >= 0 && cell[0] < cellLimit && Number.isInteger(cell[1]) && cell[1] >= 0 && cell[1] <= 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 && 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);
}
class SnapshotAssembler {
@@ -336,9 +336,10 @@
state.effects.rateTimer = null;
byId("maze").classList.remove("impact-north", "impact-east", "impact-south", "impact-west", "rate-limited");
byId("wall-bump").classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west");
documentObject.querySelectorAll(".cell.bush-impact").forEach(cell => cell.classList.remove("bush-impact", "bush-stage-1", "bush-stage-2", "bush-stage-3", "bush-stage-4"));
}
function showImpact(direction) {
function showImpact(direction, hits) {
if (!direction) return;
const maze = byId("maze");
const bump = byId("wall-bump");
@@ -349,7 +350,10 @@
bump.classList.remove("impact-north", "impact-east", "impact-south", "impact-west");
bump.classList.add(`impact-${direction}`);
bump.classList.add("show");
state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); }, 340);
const stage = Math.min(4, Math.max(1, Number.isInteger(hits) ? hits : 1));
const target = state.snapshot && documentObject.querySelector(`.cell[data-cell-index="${state.snapshot.self}"]`);
if (target && Number.isInteger(hits) && hits > 0) target.classList.add("bush-impact", `bush-stage-${stage}`);
state.effects.impactTimer = windowObject.setTimeout(() => { maze.classList.remove("impact-north", "impact-east", "impact-south", "impact-west"); bump.classList.remove("show", "impact-north", "impact-east", "impact-south", "impact-west"); if (target) target.classList.remove("bush-impact", `bush-stage-${stage}`); }, 340);
}
function showRateLimit() {
@@ -500,7 +504,7 @@
byId("map-placeholder").hidden = false;
return;
}
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1]}));
const coordinates = snapshot.cells.map(cell => ({index: cell[0], x: cell[0] % snapshot.width, y: Math.floor(cell[0] / snapshot.width), passages: cell[1], opened: cell[2]}));
const minX = Math.min(...coordinates.map(cell => cell.x)); const maxX = Math.max(...coordinates.map(cell => cell.x));
const minY = Math.min(...coordinates.map(cell => cell.y)); const maxY = Math.max(...coordinates.map(cell => cell.y));
const columns = maxX - minX + 1;
@@ -521,11 +525,16 @@
coordinates.forEach(data => {
const cell = documentObject.createElement("span");
cell.className = "cell";
cell.dataset.cellIndex = String(data.index);
cell.style.gridColumn = String(data.x - minX + 1); cell.style.gridRow = String(data.y - minY + 1);
if (!(data.passages & 1)) cell.classList.add("wall-n");
if (!(data.passages & 2)) cell.classList.add("wall-e");
if (!(data.passages & 4)) cell.classList.add("wall-s");
if (!(data.passages & 8)) cell.classList.add("wall-w");
if (data.opened & 1) cell.classList.add("trampled-n");
if (data.opened & 2) cell.classList.add("trampled-e");
if (data.opened & 4) cell.classList.add("trampled-s");
if (data.opened & 8) cell.classList.add("trampled-w");
(objects.get(data.index) || []).forEach(object => addObject(cell, object));
const slot = state.session ? state.session.slot : 1;
if (data.index === snapshot.opponent) addPawn(cell, slot === 1 ? 2 : 1, true);
@@ -598,7 +607,7 @@
snapshot.objects.forEach(object => { const list = objects.get(object[3]) || []; list.push(object); objects.set(object[3], list); });
const players = Array.isArray(snapshot.players) ? snapshot.players : [];
snapshot.cells.forEach(data => {
const index = data[0]; const passages = data[1];
const index = data[0]; const passages = data[1]; const opened = data[2];
const cell = documentObject.createElement("span");
cell.className = "cell";
cell.style.gridColumn = String(index % snapshot.width + 1);
@@ -607,6 +616,10 @@
if (!(passages & 2)) cell.classList.add("wall-e");
if (!(passages & 4)) cell.classList.add("wall-s");
if (!(passages & 8)) cell.classList.add("wall-w");
if (opened & 1) cell.classList.add("trampled-n");
if (opened & 2) cell.classList.add("trampled-e");
if (opened & 4) cell.classList.add("trampled-s");
if (opened & 8) cell.classList.add("trampled-w");
(objects.get(index) || []).forEach(object => addObject(cell, object));
players.forEach((player, playerIndex) => {
if (player.cell === index && !player.exited) {
@@ -708,7 +721,7 @@
state.moveDirections.delete(message.requestId);
state.moveFlow.complete(message.requestId);
if (message.payload.code === "invalid_token") { windowObject.localStorage.removeItem(TOKEN_KEY); state.session = null; setScreen("welcome"); }
if (message.payload.code === "blocked") { showImpact(direction); feedback(false); return; }
if (message.payload.code === "blocked") { showImpact(direction, message.payload.breakthroughHits); feedback(false); return; }
if (message.payload.code === "rate_limited") { showRateLimit(); feedback(false); return; }
showNotice(localError(message.payload.code), "error"); feedback(false);
}