feat(observer): add observer mode and disconnect recovery
This commit is contained in:
@@ -657,7 +657,7 @@ Complete the authoritative gameplay mechanics and terminal match outcomes.
|
||||
|
||||
## Milestone 008 — Player browser experience
|
||||
|
||||
**Status:** `IN_PROGRESS`
|
||||
**Status:** `COMPLETE`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -705,8 +705,7 @@ into the browser.
|
||||
|
||||
### Evidence
|
||||
|
||||
Implementation is complete, but the milestone remains `IN_PROGRESS` until the
|
||||
required physical-device and current-stable-browser checks can be run.
|
||||
Implementation and required verification are complete.
|
||||
|
||||
- `bash scripts/run_host_tests.sh` completed successfully. The five focused
|
||||
browser tests reported keyboard/swipe direction equivalence, intention-only
|
||||
@@ -750,16 +749,26 @@ required physical-device and current-stable-browser checks can be run.
|
||||
`606b98e11cbc479bf11bffa9a38d62ddfebce0689f0e3ebf148521190a032fd5`;
|
||||
`cmp` returned zero, proving byte-for-byte delivery without interrupting the
|
||||
host's internet connection.
|
||||
- Live browser validation is blocked. The browser-control runtime returned `No
|
||||
browser is available`, and its browser inventory was empty. Therefore the
|
||||
required visual and interaction passes at `412 x 916`, representative tablet
|
||||
and desktop viewports, and current stable Chrome, Safari, Firefox, and Edge
|
||||
cannot yet be recorded. Milestone 008 must not be marked `COMPLETE` until
|
||||
those browser checks pass. Milestone 009 remains `NOT_STARTED`.
|
||||
- Automated live browser validation was unavailable because the browser-control
|
||||
runtime returned `No browser is available` and its browser inventory was
|
||||
empty. The project owner subsequently supplied the required manual browser
|
||||
evidence recorded below.
|
||||
- On 2026-09-04, a fresh browser-control inventory check again returned an
|
||||
empty list. Milestone 008 was therefore changed from `IN_PROGRESS` to
|
||||
`BLOCKED`; no Milestone 009 implementation work was started.
|
||||
- On 2026-09-04, the project owner completed the required manual player-UI
|
||||
checks in Chrome 152, Firefox 155, Safari 26.6, and Edge 152.0.4191.53. The
|
||||
checks covered the `412 x 916` phone layout without page scrolling,
|
||||
representative tablet and desktop layouts, keyboard and swipe movement,
|
||||
the accessible drop-key control, and refresh/session restoration. Together
|
||||
with the automated browser-contract, projection-security, localization,
|
||||
offline-asset, payload-size, build, and physical-device evidence above, this
|
||||
satisfies every Milestone 008 acceptance criterion. Milestone 008 is
|
||||
`COMPLETE`; Milestone 009 remains `NOT_STARTED`.
|
||||
|
||||
## Milestone 009 — Observer mode and disconnect recovery
|
||||
|
||||
**Status:** `NOT_STARTED`
|
||||
**Status:** `IN_PROGRESS`
|
||||
|
||||
### Objective
|
||||
|
||||
@@ -794,9 +803,49 @@ and reconnect behavior.
|
||||
|
||||
### Evidence
|
||||
|
||||
Completion evidence is not yet available because this milestone has not
|
||||
started. Required evidence includes role-authorization tests, four-observer
|
||||
browser evidence, and timed reconnect/timeout scenarios.
|
||||
Implementation is complete, but the milestone remains `IN_PROGRESS` until the
|
||||
required physical-device and observer-browser checks can be recorded.
|
||||
|
||||
- On 2026-09-05, `bash scripts/run_host_tests.sh` completed successfully. The
|
||||
new deterministic recovery suite proved immediate authoritative pause,
|
||||
rejection of commands without state or effect progression while paused,
|
||||
restoration before the monotonic 60,000 ms deadline, timeout draw at the
|
||||
deadline, late-reconnect rejection, and preservation of the remaining 200 ms
|
||||
finish window across a pause. Observer-role command enqueue returned
|
||||
`read_only`, and observer connection changes left lobby phase unchanged.
|
||||
- The same host run reported `game default observer serialization: PASS (688
|
||||
chunks, maximum 494 bytes)`. The projection contains all 8,192 cells, both
|
||||
player records and connection states, and remains below the 512-byte message
|
||||
bound. Seven browser-contract tests passed, covering authoritative chunk
|
||||
assembly, observer role rendering, absence of observer mutation controls,
|
||||
bounded fit/zoom/pan behavior, and the Russian sub-`1024 x 768` notice. Web
|
||||
asset reproducibility passed with SHA-256
|
||||
`e8d097c7aa1a56cf84748078176b50cc8e2718082edfda35625b6d4436a1fd67`
|
||||
and 19,133 of 1,572,864 allowed compressed bytes.
|
||||
- Separate AddressSanitizer and UndefinedBehaviorSanitizer runs of the changed
|
||||
game-core and disconnect-recovery suites passed without findings; leak
|
||||
detection was disabled because the sandbox executes under `ptrace`.
|
||||
`node --check web/player.js`, compilation of
|
||||
`scripts/ws_protocol_client.py`, and `git diff --check` also passed.
|
||||
- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1` completed
|
||||
with `[SUCCESS]`, reporting 124,148 of 327,680 RAM bytes and 1,023,742 of
|
||||
2,097,152 application bytes. The independent partition check reported
|
||||
`firmware.bin uses 1024368/2097152 bytes in partition factory`. The
|
||||
corresponding `buildfs` command completed with `[SUCCESS]` and reported
|
||||
`littlefs.bin uses 2031616/2031616 bytes in partition littlefs`, with all five
|
||||
compressed assets present.
|
||||
- Physical verification remains outstanding. The prepared dependency-free
|
||||
command `python3 scripts/ws_protocol_client.py 192.168.1.50 --verify-m009`
|
||||
covers simultaneous full-state delivery to four observers, observer mutation
|
||||
rejection, observer-disconnect continuity, same-state player restoration,
|
||||
and the real 60-second timeout draw. At the end of this pass,
|
||||
`/dev/ttyACM0` was absent and `192.168.1.50` was unreachable, so the final
|
||||
`0.9.0` images could not be uploaded and this scenario could not be run.
|
||||
- Automated live observer validation also remains outstanding. The
|
||||
browser-control runtime returned `No browser is available`, and its browser
|
||||
inventory was empty. A live check at `1024 x 768` or larger plus a smaller
|
||||
viewport must still confirm full-map fit, updates, zoom, pan, read-only UI,
|
||||
player connection state, and the required Russian large-screen notice.
|
||||
|
||||
## Milestone 010 — On-device integration and resource hardening
|
||||
|
||||
|
||||
@@ -15,10 +15,12 @@ extern "C" {
|
||||
#define MAZE32_GAME_NO_KEY UINT8_MAX
|
||||
#define MAZE32_GAME_VISION_MOVES 10U
|
||||
#define MAZE32_GAME_DRAW_WINDOW_MS 200U
|
||||
#define MAZE32_GAME_RECONNECT_TIMEOUT_MS 60000U
|
||||
#define MAZE32_GAME_MAX_ARTIFACTS (2U * MAZE32_MAZE_MAX_ARTIFACT_PAIRS)
|
||||
#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_VIEW_OBJECTS_PER_CHUNK 6U
|
||||
#define MAZE32_GAME_VIEW_CHUNK_BYTES 512U
|
||||
#define MAZE32_GAME_RAM_BUDGET_BYTES (40U * 1024U)
|
||||
@@ -28,6 +30,7 @@ typedef enum {
|
||||
MAZE32_GAME_PHASE_COUNTDOWN = 0,
|
||||
MAZE32_GAME_PHASE_ACTIVE,
|
||||
MAZE32_GAME_PHASE_FINISH_PENDING,
|
||||
MAZE32_GAME_PHASE_PAUSED,
|
||||
MAZE32_GAME_PHASE_FINISHED,
|
||||
} maze32_game_phase_t;
|
||||
|
||||
@@ -96,6 +99,8 @@ typedef struct {
|
||||
maze32_game_compass_direction_t compass_hint;
|
||||
uint8_t has_moved;
|
||||
uint8_t exited;
|
||||
uint8_t connected;
|
||||
uint64_t reconnect_deadline_ms;
|
||||
} maze32_game_player_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -159,6 +164,8 @@ typedef struct {
|
||||
uint8_t first_exited_player;
|
||||
uint8_t has_exit_time[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint64_t exit_times_ms[MAZE32_GAME_PLAYER_COUNT];
|
||||
maze32_game_phase_t phase_before_pause;
|
||||
uint64_t paused_started_ms;
|
||||
} maze32_game_t;
|
||||
|
||||
typedef enum {
|
||||
@@ -203,6 +210,7 @@ typedef struct {
|
||||
uint8_t observer_vision_moves_remaining[MAZE32_GAME_PLAYER_COUNT];
|
||||
maze32_game_compass_direction_t observer_compass_hints[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t observer_exited[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t observer_connected[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t observer_exit_open[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint8_t observer_has_exit_time[MAZE32_GAME_PLAYER_COUNT];
|
||||
uint64_t observer_exit_times_ms[MAZE32_GAME_PLAYER_COUNT];
|
||||
@@ -224,6 +232,8 @@ maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_ga
|
||||
uint32_t* command_sequence);
|
||||
maze32_game_command_result_t maze32_game_process_next(maze32_game_t* game);
|
||||
int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms);
|
||||
maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index,
|
||||
int connected, uint64_t now_ms);
|
||||
uint8_t maze32_game_player_view_radius(const maze32_game_t* game, uint8_t player_index);
|
||||
maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_game_role_t role,
|
||||
maze32_view_t* view);
|
||||
|
||||
@@ -78,11 +78,13 @@ maze32_game_result_t maze32_game_init(maze32_game_t* game, const maze32_maze_t*
|
||||
game->maze = maze;
|
||||
game->config = *config;
|
||||
game->phase = MAZE32_GAME_PHASE_COUNTDOWN;
|
||||
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
||||
game->next_command_sequence = 1U;
|
||||
game->first_exited_player = MAZE32_GAME_NO_OWNER;
|
||||
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||
game->players[player].position = maze->starts[player];
|
||||
game->players[player].carried_key = MAZE32_GAME_NO_KEY;
|
||||
game->players[player].connected = 1U;
|
||||
game->keys[player].location = MAZE32_GAME_KEY_GROUND;
|
||||
game->keys[player].ground_position = maze->keys[player];
|
||||
game->keys[player].carrier = MAZE32_GAME_NO_OWNER;
|
||||
@@ -93,7 +95,13 @@ 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) {
|
||||
game->phase = phase;
|
||||
if (phase == MAZE32_GAME_PHASE_ACTIVE &&
|
||||
(!game->players[0].connected || !game->players[1].connected)) {
|
||||
game->phase_before_pause = MAZE32_GAME_PHASE_ACTIVE;
|
||||
game->phase = MAZE32_GAME_PHASE_PAUSED;
|
||||
} else {
|
||||
game->phase = phase;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -139,7 +147,25 @@ maze32_game_result_t maze32_game_enqueue_drop_key(maze32_game_t* game, maze32_ga
|
||||
}
|
||||
|
||||
int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
|
||||
if (game == NULL || game->phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
|
||||
if (game == NULL) {
|
||||
return 0;
|
||||
}
|
||||
if (game->phase == MAZE32_GAME_PHASE_PAUSED) {
|
||||
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||
if (!game->players[player].connected &&
|
||||
now_ms >= game->players[player].reconnect_deadline_ms) {
|
||||
game->phase = MAZE32_GAME_PHASE_FINISHED;
|
||||
game->final_result = MAZE32_GAME_FINAL_DRAW;
|
||||
game->paused_started_ms = 0U;
|
||||
game->command_head = 0U;
|
||||
game->command_count = 0U;
|
||||
game->state_sequence++;
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
if (game->phase != MAZE32_GAME_PHASE_FINISH_PENDING ||
|
||||
game->first_exited_player >= MAZE32_GAME_PLAYER_COUNT) {
|
||||
return 0;
|
||||
}
|
||||
@@ -154,6 +180,60 @@ int maze32_game_advance_time(maze32_game_t* game, uint64_t now_ms) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
maze32_game_result_t maze32_game_set_player_connected(maze32_game_t* game, uint8_t player_index,
|
||||
int connected, uint64_t now_ms) {
|
||||
if (game == NULL || player_index >= MAZE32_GAME_PLAYER_COUNT) {
|
||||
return MAZE32_GAME_RESULT_INVALID_ARGUMENT;
|
||||
}
|
||||
maze32_game_player_t* player = &game->players[player_index];
|
||||
uint8_t normalized = connected ? 1U : 0U;
|
||||
if (player->connected == normalized) {
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
}
|
||||
if (game->phase == MAZE32_GAME_PHASE_FINISHED) {
|
||||
return MAZE32_GAME_RESULT_MATCH_FINISHED;
|
||||
}
|
||||
if (normalized) {
|
||||
if (game->phase == MAZE32_GAME_PHASE_PAUSED && now_ms >= player->reconnect_deadline_ms) {
|
||||
maze32_game_advance_time(game, now_ms);
|
||||
return MAZE32_GAME_RESULT_MATCH_FINISHED;
|
||||
}
|
||||
player->connected = 1U;
|
||||
player->reconnect_deadline_ms = 0U;
|
||||
if (game->phase == MAZE32_GAME_PHASE_PAUSED && game->players[0].connected &&
|
||||
game->players[1].connected) {
|
||||
if (game->phase_before_pause == MAZE32_GAME_PHASE_FINISH_PENDING &&
|
||||
game->first_exited_player < MAZE32_GAME_PLAYER_COUNT &&
|
||||
now_ms > game->paused_started_ms) {
|
||||
uint64_t pause_duration_ms = now_ms - game->paused_started_ms;
|
||||
uint64_t* exit_time_ms = &game->exit_times_ms[game->first_exited_player];
|
||||
*exit_time_ms = *exit_time_ms > UINT64_MAX - pause_duration_ms
|
||||
? UINT64_MAX
|
||||
: *exit_time_ms + pause_duration_ms;
|
||||
}
|
||||
game->phase = game->phase_before_pause;
|
||||
game->paused_started_ms = 0U;
|
||||
}
|
||||
game->state_sequence++;
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
}
|
||||
|
||||
player->connected = 0U;
|
||||
player->reconnect_deadline_ms = now_ms > UINT64_MAX - MAZE32_GAME_RECONNECT_TIMEOUT_MS
|
||||
? UINT64_MAX
|
||||
: now_ms + MAZE32_GAME_RECONNECT_TIMEOUT_MS;
|
||||
if (game->phase == MAZE32_GAME_PHASE_COUNTDOWN || game->phase == MAZE32_GAME_PHASE_ACTIVE ||
|
||||
game->phase == MAZE32_GAME_PHASE_FINISH_PENDING) {
|
||||
game->phase_before_pause = game->phase;
|
||||
game->phase = MAZE32_GAME_PHASE_PAUSED;
|
||||
game->paused_started_ms = now_ms;
|
||||
game->command_head = 0U;
|
||||
game->command_count = 0U;
|
||||
}
|
||||
game->state_sequence++;
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
}
|
||||
|
||||
static void accept_player_move(maze32_game_player_t* player, uint64_t submitted_ms) {
|
||||
player->last_move_ms = submitted_ms;
|
||||
player->accepted_moves++;
|
||||
@@ -496,6 +576,7 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
|
||||
game->players[player].vision_moves_remaining;
|
||||
view->observer_compass_hints[player] = game->players[player].compass_hint;
|
||||
view->observer_exited[player] = game->players[player].exited;
|
||||
view->observer_connected[player] = game->players[player].connected;
|
||||
view->observer_exit_open[player] = game->exits[player].open;
|
||||
view->observer_has_exit_time[player] = game->has_exit_time[player];
|
||||
view->observer_exit_times_ms[player] = game->exit_times_ms[player];
|
||||
@@ -517,12 +598,24 @@ maze32_game_result_t maze32_game_project_view(const maze32_game_t* game, maze32_
|
||||
return MAZE32_GAME_RESULT_ACCEPTED;
|
||||
}
|
||||
|
||||
static size_t cell_chunk_count(const maze32_view_t* view) {
|
||||
if (view->role != MAZE32_GAME_ROLE_OBSERVER || view->cell_count == 0U) {
|
||||
return (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
|
||||
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
}
|
||||
if (view->cell_count <= MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS) {
|
||||
return 1U;
|
||||
}
|
||||
size_t remaining = view->cell_count - MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
|
||||
return 1U +
|
||||
(remaining + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) / MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
}
|
||||
|
||||
size_t maze32_game_view_chunk_count(const maze32_view_t* view) {
|
||||
if (view == NULL) {
|
||||
return 0U;
|
||||
}
|
||||
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
|
||||
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
size_t cell_chunks = cell_chunk_count(view);
|
||||
size_t object_chunks = (view->object_count + MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK - 1U) /
|
||||
MAZE32_GAME_VIEW_OBJECTS_PER_CHUNK;
|
||||
return cell_chunks + object_chunks;
|
||||
@@ -556,7 +649,8 @@ static const char* object_name(maze32_view_object_kind_t kind) {
|
||||
}
|
||||
|
||||
static const char* phase_name(maze32_game_phase_t phase) {
|
||||
static const char* const NAMES[] = {"countdown", "active", "finish_pending", "finished"};
|
||||
static const char* const NAMES[] = {"countdown", "active", "finish_pending", "paused",
|
||||
"finished"};
|
||||
return NAMES[phase];
|
||||
}
|
||||
|
||||
@@ -605,11 +699,12 @@ static int append_observer_status(const maze32_view_t* view, char* output, size_
|
||||
}
|
||||
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||
if (!append_text(output, output_size, offset,
|
||||
"%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u",
|
||||
"%s{\"cell\":%u,\"visionMoves\":%u,\"hint\":\"%s\",\"exited\":%u,"
|
||||
"\"connected\":%u",
|
||||
player == 0U ? "" : ",", view->observer_player_cell_indices[player],
|
||||
view->observer_vision_moves_remaining[player],
|
||||
compass_name(view->observer_compass_hints[player]),
|
||||
view->observer_exited[player]) ||
|
||||
view->observer_exited[player], view->observer_connected[player]) ||
|
||||
!append_nullable_key(output, output_size, offset, "key",
|
||||
view->observer_carried_keys[player]) ||
|
||||
!append_text(output, output_size, offset, "}")) {
|
||||
@@ -669,11 +764,18 @@ maze32_game_result_t maze32_game_serialize_view_chunk(const maze32_view_t* view,
|
||||
return MAZE32_GAME_RESULT_BUFFER_TOO_SMALL;
|
||||
}
|
||||
|
||||
size_t cell_chunks = (view->cell_count + MAZE32_GAME_VIEW_CELLS_PER_CHUNK - 1U) /
|
||||
MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
size_t cell_chunks = cell_chunk_count(view);
|
||||
if (chunk_index < cell_chunks) {
|
||||
size_t first = chunk_index * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
if (view->role == MAZE32_GAME_ROLE_OBSERVER) {
|
||||
first = chunk_index == 0U ? 0U
|
||||
: MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS +
|
||||
(chunk_index - 1U) * MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
}
|
||||
size_t end = first + MAZE32_GAME_VIEW_CELLS_PER_CHUNK;
|
||||
if (view->role == MAZE32_GAME_ROLE_OBSERVER && chunk_index == 0U) {
|
||||
end = MAZE32_GAME_OBSERVER_FIRST_CHUNK_CELLS;
|
||||
}
|
||||
if (end > view->cell_count) {
|
||||
end = view->cell_count;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ typedef enum {
|
||||
MAZE32_MESSAGE_JOIN,
|
||||
MAZE32_MESSAGE_RESTORE,
|
||||
MAZE32_MESSAGE_START,
|
||||
MAZE32_MESSAGE_MOVE,
|
||||
MAZE32_MESSAGE_DROP_KEY,
|
||||
MAZE32_MESSAGE_HEARTBEAT,
|
||||
MAZE32_MESSAGE_SUGGEST_NAME,
|
||||
} maze32_message_type_t;
|
||||
@@ -39,6 +41,7 @@ typedef struct {
|
||||
maze32_role_t role;
|
||||
char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U];
|
||||
char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U];
|
||||
char direction[6U];
|
||||
} maze32_client_message_t;
|
||||
|
||||
maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length,
|
||||
|
||||
@@ -123,6 +123,15 @@ static int payload_field_allowed(maze32_message_type_t type, const char* key, ch
|
||||
*fields |= 1U;
|
||||
return 1;
|
||||
}
|
||||
if (type == MAZE32_MESSAGE_MOVE && strcmp(key, "direction") == 0 && !(*fields & 1U)) {
|
||||
if (strcmp(value, "north") != 0 && strcmp(value, "east") != 0 &&
|
||||
strcmp(value, "south") != 0 && strcmp(value, "west") != 0) {
|
||||
return 0;
|
||||
}
|
||||
memcpy(message->direction, value, strlen(value) + 1U);
|
||||
*fields |= 1U;
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -159,6 +168,9 @@ static int parse_payload(json_cursor_t* cursor, maze32_client_message_t* message
|
||||
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
||||
return fields == 1U;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_MOVE) {
|
||||
return fields == 1U;
|
||||
}
|
||||
return fields == 0U;
|
||||
}
|
||||
|
||||
@@ -172,6 +184,12 @@ static maze32_message_type_t message_type(const char* value) {
|
||||
if (strcmp(value, "start") == 0) {
|
||||
return MAZE32_MESSAGE_START;
|
||||
}
|
||||
if (strcmp(value, "move") == 0) {
|
||||
return MAZE32_MESSAGE_MOVE;
|
||||
}
|
||||
if (strcmp(value, "drop_key") == 0) {
|
||||
return MAZE32_MESSAGE_DROP_KEY;
|
||||
}
|
||||
if (strcmp(value, "heartbeat") == 0) {
|
||||
return MAZE32_MESSAGE_HEARTBEAT;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
idf_component_register(
|
||||
SRCS "maze32_web.c" "maze32_gateway.c"
|
||||
INCLUDE_DIRS "include"
|
||||
REQUIRES esp_http_server esp_timer heap maze32_lobby maze32_platform
|
||||
REQUIRES esp_http_server esp_timer heap maze32_game maze32_lobby maze32_maze maze32_platform
|
||||
)
|
||||
|
||||
@@ -7,7 +7,9 @@
|
||||
#include "esp_err.h"
|
||||
#include "esp_http_server.h"
|
||||
#include "esp_timer.h"
|
||||
#include "maze32_game.h"
|
||||
#include "maze32_lobby.h"
|
||||
#include "maze32_maze.h"
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
@@ -42,6 +44,11 @@ typedef struct {
|
||||
uint32_t server_sequence;
|
||||
unsigned int suggestion_index;
|
||||
esp_timer_handle_t countdown_timer;
|
||||
esp_timer_handle_t reconnect_timer;
|
||||
maze32_maze_t maze;
|
||||
maze32_maze_workspace_t maze_workspace;
|
||||
maze32_game_t game;
|
||||
maze32_view_t view;
|
||||
} maze32_gateway_t;
|
||||
|
||||
esp_err_t maze32_gateway_init(maze32_gateway_t* gateway);
|
||||
|
||||
@@ -148,6 +148,64 @@ static void drain_queue(maze32_gateway_t* gateway) {
|
||||
}
|
||||
}
|
||||
|
||||
static maze32_game_role_t game_role_for_session(const maze32_session_t* session) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
return MAZE32_GAME_ROLE_OBSERVER;
|
||||
}
|
||||
return session->player_slot == 1U ? MAZE32_GAME_ROLE_PLAYER_ONE : MAZE32_GAME_ROLE_PLAYER_TWO;
|
||||
}
|
||||
|
||||
static void send_view(maze32_gateway_t* gateway, const maze32_session_t* session,
|
||||
int full_observer_view) {
|
||||
if (session == NULL || !session->connected ||
|
||||
maze32_game_project_view(&gateway->game, game_role_for_session(session), &gateway->view) !=
|
||||
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return;
|
||||
}
|
||||
if (session->role == MAZE32_ROLE_OBSERVER && !full_observer_view) {
|
||||
gateway->view.cell_count = 0U;
|
||||
}
|
||||
size_t chunks = maze32_game_view_chunk_count(&gateway->view);
|
||||
for (size_t chunk = 0U; chunk < chunks; ++chunk) {
|
||||
char payload[MAZE32_GAME_VIEW_CHUNK_BYTES];
|
||||
size_t written = 0U;
|
||||
if (maze32_game_serialize_view_chunk(&gateway->view, chunk, payload, sizeof(payload),
|
||||
&written) != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
ESP_LOGE(TAG, "View serialization failed for fd=%d, chunk=%u", session->connection_id,
|
||||
(unsigned int)chunk);
|
||||
return;
|
||||
}
|
||||
(void)written;
|
||||
if (!enqueue(gateway, session->connection_id, payload)) {
|
||||
return;
|
||||
}
|
||||
drain_queue(gateway);
|
||||
}
|
||||
}
|
||||
|
||||
static void broadcast_views(maze32_gateway_t* gateway, int full_observer_view) {
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
send_view(gateway, &gateway->lobby.sessions[index], full_observer_view);
|
||||
}
|
||||
}
|
||||
|
||||
static void broadcast_match_event(maze32_gateway_t* gateway, const char* type,
|
||||
const char* payload) {
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"v\":1,\"type\":\"%s\",\"seq\":%" PRIu32 ",\"payload\":%s}", type,
|
||||
next_server_sequence(gateway), payload);
|
||||
if (written < 0 || (size_t)written >= sizeof(response)) {
|
||||
return;
|
||||
}
|
||||
for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) {
|
||||
const maze32_session_t* session = &gateway->lobby.sessions[index];
|
||||
if (session->role != MAZE32_ROLE_NONE && session->connected) {
|
||||
enqueue(gateway, session->connection_id, response);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void send_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
||||
const char* code, const char* message) {
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||
@@ -399,6 +457,7 @@ static void countdown_finished_work(void* context) {
|
||||
if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) {
|
||||
return;
|
||||
}
|
||||
maze32_game_set_phase(&gateway->game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT];
|
||||
int written = snprintf(response, sizeof(response),
|
||||
"{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32
|
||||
@@ -413,6 +472,7 @@ static void countdown_finished_work(void* context) {
|
||||
}
|
||||
}
|
||||
broadcast_snapshot(gateway);
|
||||
broadcast_views(gateway, 1);
|
||||
drain_queue(gateway);
|
||||
ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms",
|
||||
gateway->lobby.activation_ms);
|
||||
@@ -428,6 +488,53 @@ static void countdown_timer_callback(void* context) {
|
||||
}
|
||||
}
|
||||
|
||||
static void schedule_reconnect_deadline(maze32_gateway_t* gateway);
|
||||
|
||||
static void reconnect_deadline_work(void* context) {
|
||||
maze32_gateway_t* gateway = context;
|
||||
if (maze32_game_advance_time(&gateway->game, now_ms())) {
|
||||
broadcast_match_event(gateway, "match_result", "{\"result\":\"draw\"}");
|
||||
broadcast_views(gateway, 0);
|
||||
drain_queue(gateway);
|
||||
ESP_LOGI(TAG, "Player reconnect deadline expired; match ended in a draw");
|
||||
return;
|
||||
}
|
||||
schedule_reconnect_deadline(gateway);
|
||||
}
|
||||
|
||||
static void reconnect_timer_callback(void* context) {
|
||||
maze32_gateway_t* gateway = context;
|
||||
if (gateway->server != NULL) {
|
||||
esp_err_t result = httpd_queue_work(gateway->server, reconnect_deadline_work, gateway);
|
||||
if (result != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Could not queue reconnect deadline: %s", esp_err_to_name(result));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void schedule_reconnect_deadline(maze32_gateway_t* gateway) {
|
||||
esp_timer_stop(gateway->reconnect_timer);
|
||||
if (gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
|
||||
return;
|
||||
}
|
||||
uint64_t deadline = UINT64_MAX;
|
||||
for (uint8_t player = 0U; player < MAZE32_GAME_PLAYER_COUNT; ++player) {
|
||||
if (!gateway->game.players[player].connected &&
|
||||
gateway->game.players[player].reconnect_deadline_ms < deadline) {
|
||||
deadline = gateway->game.players[player].reconnect_deadline_ms;
|
||||
}
|
||||
}
|
||||
if (deadline == UINT64_MAX) {
|
||||
return;
|
||||
}
|
||||
uint64_t current_ms = now_ms();
|
||||
uint64_t delay_ms = deadline > current_ms ? deadline - current_ms : 1U;
|
||||
esp_err_t result = esp_timer_start_once(gateway->reconnect_timer, delay_ms * 1000U);
|
||||
if (result != ESP_OK) {
|
||||
ESP_LOGE(TAG, "Reconnect timer could not start: %s", esp_err_to_name(result));
|
||||
}
|
||||
}
|
||||
|
||||
static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t current_ms) {
|
||||
if (current_ms - connection->rate_window_started_ms >= 1000U) {
|
||||
connection->rate_window_started_ms = current_ms;
|
||||
@@ -437,6 +544,87 @@ static int rate_limit_allows(maze32_gateway_connection_t* connection, uint64_t c
|
||||
return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND;
|
||||
}
|
||||
|
||||
static maze32_game_direction_t parse_direction(const char* direction) {
|
||||
if (strcmp(direction, "north") == 0) {
|
||||
return MAZE32_GAME_DIRECTION_NORTH;
|
||||
}
|
||||
if (strcmp(direction, "east") == 0) {
|
||||
return MAZE32_GAME_DIRECTION_EAST;
|
||||
}
|
||||
if (strcmp(direction, "south") == 0) {
|
||||
return MAZE32_GAME_DIRECTION_SOUTH;
|
||||
}
|
||||
return MAZE32_GAME_DIRECTION_WEST;
|
||||
}
|
||||
|
||||
static void send_game_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id,
|
||||
maze32_game_result_t result) {
|
||||
const char* message = "Команда игры отклонена сервером";
|
||||
if (result == MAZE32_GAME_RESULT_READ_ONLY) {
|
||||
message = "Наблюдатели смотрят, но не двигают героев";
|
||||
} else if (result == MAZE32_GAME_RESULT_PREMATURE) {
|
||||
message = "Матч сейчас на паузе или ещё не начался";
|
||||
} else if (result == MAZE32_GAME_RESULT_RATE_LIMITED) {
|
||||
message = "Слишком быстро: лабиринт просит соблюдать интервал";
|
||||
} else if (result == MAZE32_GAME_RESULT_BLOCKED) {
|
||||
message = "Там стена. Очень убедительная";
|
||||
} else if (result == MAZE32_GAME_RESULT_NO_KEY) {
|
||||
message = "Карман пуст — бросать нечего";
|
||||
} else if (result == MAZE32_GAME_RESULT_MATCH_FINISHED) {
|
||||
message = "Гонка уже закончилась";
|
||||
}
|
||||
send_error(gateway, socket_fd, request_id, maze32_game_result_name(result), message);
|
||||
}
|
||||
|
||||
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) {
|
||||
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=%u", session->connection_id,
|
||||
(unsigned int)message->type);
|
||||
send_game_error(gateway, session->connection_id, message->request_id,
|
||||
MAZE32_GAME_RESULT_READ_ONLY);
|
||||
return;
|
||||
}
|
||||
if (gateway->lobby.phase != MAZE32_LOBBY_ACTIVE) {
|
||||
send_game_error(gateway, session->connection_id, message->request_id,
|
||||
MAZE32_GAME_RESULT_PREMATURE);
|
||||
return;
|
||||
}
|
||||
uint32_t command_sequence = 0U;
|
||||
maze32_game_role_t role = game_role_for_session(session);
|
||||
maze32_game_result_t result =
|
||||
message->type == MAZE32_MESSAGE_MOVE
|
||||
? maze32_game_enqueue_move(&gateway->game, role, parse_direction(message->direction),
|
||||
now_ms(), &command_sequence)
|
||||
: maze32_game_enqueue_drop_key(&gateway->game, role, now_ms(), &command_sequence);
|
||||
if (result == MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
maze32_game_command_result_t processed = maze32_game_process_next(&gateway->game);
|
||||
result = processed.result;
|
||||
}
|
||||
if (result != MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
send_game_error(gateway, session->connection_id, message->request_id, result);
|
||||
return;
|
||||
}
|
||||
char acknowledgement[160] = "";
|
||||
char prefix[96];
|
||||
size_t length = 0U;
|
||||
int written = snprintf(prefix, sizeof(prefix),
|
||||
"{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32 ",\"requestId\":",
|
||||
next_server_sequence(gateway));
|
||||
if (written >= 0 && (size_t)written < sizeof(prefix) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix) &&
|
||||
append_json_string(acknowledgement, sizeof(acknowledgement), &length,
|
||||
message->request_id) &&
|
||||
(written = snprintf(prefix, sizeof(prefix),
|
||||
",\"payload\":{\"commandSequence\":%" PRIu32 "}}", command_sequence)) >=
|
||||
0 &&
|
||||
(size_t)written < sizeof(prefix) &&
|
||||
append_text(acknowledgement, sizeof(acknowledgement), &length, prefix)) {
|
||||
enqueue(gateway, session->connection_id, acknowledgement);
|
||||
}
|
||||
broadcast_views(gateway, 0);
|
||||
}
|
||||
|
||||
static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_t* connection,
|
||||
const maze32_client_message_t* message) {
|
||||
int socket_fd = connection->socket_fd;
|
||||
@@ -464,6 +652,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
}
|
||||
send_session(gateway, socket_fd, session, message->request_id);
|
||||
broadcast_snapshot(gateway);
|
||||
if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) {
|
||||
send_view(gateway, session, 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_RESTORE) {
|
||||
@@ -480,7 +671,27 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
return;
|
||||
}
|
||||
send_session(gateway, socket_fd, session, message->request_id);
|
||||
if (session->role == MAZE32_ROLE_PLAYER && gateway->lobby.phase != MAZE32_LOBBY_WAITING) {
|
||||
maze32_game_phase_t paused_phase = gateway->game.phase;
|
||||
maze32_game_result_t reconnect_result = maze32_game_set_player_connected(
|
||||
&gateway->game, (uint8_t)(session->player_slot - 1U), 1, now_ms());
|
||||
if (reconnect_result == MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
schedule_reconnect_deadline(gateway);
|
||||
if (paused_phase == MAZE32_GAME_PHASE_PAUSED &&
|
||||
gateway->game.phase != MAZE32_GAME_PHASE_PAUSED) {
|
||||
broadcast_match_event(gateway, "resume", "{}");
|
||||
ESP_LOGI(TAG, "Player %u restored; match resumed", session->player_slot);
|
||||
}
|
||||
}
|
||||
}
|
||||
broadcast_snapshot(gateway);
|
||||
if (gateway->lobby.phase == MAZE32_LOBBY_ACTIVE) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
send_view(gateway, session, 1);
|
||||
} else {
|
||||
broadcast_views(gateway, 0);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -492,6 +703,9 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_START) {
|
||||
if (session->role == MAZE32_ROLE_OBSERVER) {
|
||||
ESP_LOGW(TAG, "Observer mutation rejected: fd=%d, type=start", socket_fd);
|
||||
}
|
||||
maze32_lobby_result_t result = maze32_lobby_start(&gateway->lobby, socket_fd, now_ms());
|
||||
if (result != MAZE32_LOBBY_OK) {
|
||||
send_lobby_error(gateway, socket_fd, message->request_id, result);
|
||||
@@ -506,6 +720,10 @@ static void handle_message(maze32_gateway_t* gateway, maze32_gateway_connection_
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_MOVE || message->type == MAZE32_MESSAGE_DROP_KEY) {
|
||||
handle_game_command(gateway, session, message);
|
||||
return;
|
||||
}
|
||||
if (message->type == MAZE32_MESSAGE_HEARTBEAT) {
|
||||
char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = "";
|
||||
size_t length = 0U;
|
||||
@@ -604,6 +822,19 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
|
||||
}
|
||||
memset(gateway, 0, sizeof(*gateway));
|
||||
maze32_lobby_init(&gateway->lobby);
|
||||
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||
maze32_maze_report_t maze_report;
|
||||
if (maze32_maze_generate(&gateway->maze, &gateway->maze_workspace, &maze_config, esp_random(),
|
||||
&maze_report) != MAZE32_MAZE_OK) {
|
||||
ESP_LOGE(TAG, "Initial maze generation failed: %s",
|
||||
maze32_maze_result_name(maze_report.result));
|
||||
return ESP_FAIL;
|
||||
}
|
||||
maze32_game_config_t game_config = maze32_game_default_config();
|
||||
if (maze32_game_init(&gateway->game, &gateway->maze, &game_config) !=
|
||||
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
return ESP_FAIL;
|
||||
}
|
||||
const esp_timer_create_args_t timer_arguments = {
|
||||
.callback = countdown_timer_callback,
|
||||
.arg = gateway,
|
||||
@@ -611,7 +842,22 @@ esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) {
|
||||
.name = "lobby_countdown",
|
||||
.skip_unhandled_events = true,
|
||||
};
|
||||
return esp_timer_create(&timer_arguments, &gateway->countdown_timer);
|
||||
esp_err_t result = esp_timer_create(&timer_arguments, &gateway->countdown_timer);
|
||||
if (result != ESP_OK) {
|
||||
return result;
|
||||
}
|
||||
const esp_timer_create_args_t reconnect_timer_arguments = {
|
||||
.callback = reconnect_timer_callback,
|
||||
.arg = gateway,
|
||||
.dispatch_method = ESP_TIMER_TASK,
|
||||
.name = "reconnect_deadline",
|
||||
.skip_unhandled_events = true,
|
||||
};
|
||||
result = esp_timer_create(&reconnect_timer_arguments, &gateway->reconnect_timer);
|
||||
if (result != ESP_OK) {
|
||||
esp_timer_delete(gateway->countdown_timer);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) {
|
||||
@@ -648,9 +894,35 @@ void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) {
|
||||
if (gateway != NULL) {
|
||||
maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd);
|
||||
if (connection != NULL) {
|
||||
const maze32_session_t* session =
|
||||
maze32_lobby_session_for_connection(&gateway->lobby, socket_fd);
|
||||
unsigned int disconnected_slot = 0U;
|
||||
if (session != NULL && session->role == MAZE32_ROLE_PLAYER &&
|
||||
gateway->lobby.phase != MAZE32_LOBBY_WAITING) {
|
||||
disconnected_slot = session->player_slot;
|
||||
uint64_t disconnected_at_ms = now_ms();
|
||||
if (maze32_game_set_player_connected(
|
||||
&gateway->game, (uint8_t)(disconnected_slot - 1U), 0, disconnected_at_ms) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED) {
|
||||
char payload[96];
|
||||
int written = snprintf(
|
||||
payload, sizeof(payload), "{\"slot\":%u,\"deadlineMs\":%" PRIu64 "}",
|
||||
disconnected_slot,
|
||||
gateway->game.players[disconnected_slot - 1U].reconnect_deadline_ms);
|
||||
if (written >= 0 && (size_t)written < sizeof(payload)) {
|
||||
broadcast_match_event(gateway, "pause", payload);
|
||||
}
|
||||
schedule_reconnect_deadline(gateway);
|
||||
ESP_LOGI(TAG, "Player %u disconnected; match paused for %u ms",
|
||||
disconnected_slot, (unsigned int)MAZE32_GAME_RECONNECT_TIMEOUT_MS);
|
||||
}
|
||||
}
|
||||
maze32_lobby_disconnect(&gateway->lobby, socket_fd);
|
||||
memset(connection, 0, sizeof(*connection));
|
||||
broadcast_snapshot(gateway);
|
||||
if (disconnected_slot != 0U) {
|
||||
broadcast_views(gateway, 0);
|
||||
}
|
||||
drain_queue(gateway);
|
||||
ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd);
|
||||
}
|
||||
|
||||
Binary file not shown.
+20
-4
@@ -1,9 +1,8 @@
|
||||
# Authoritative game core
|
||||
|
||||
Milestones 006 and 007 provide the portable movement engine, gameplay object
|
||||
systems, terminal results, and view projector. The ESP remains the only owner
|
||||
of mutable game state. Reconnect behavior and browser controls remain out of
|
||||
scope until their later milestones.
|
||||
Milestones 006, 007, and 009 provide the portable movement engine, gameplay
|
||||
object systems, terminal results, view projector, and disconnect recovery. The
|
||||
ESP remains the only owner of mutable game state.
|
||||
|
||||
## Command processing
|
||||
|
||||
@@ -87,3 +86,20 @@ compass hint, and own-exit status. An opponent's carried key is included only
|
||||
while that opponent is currently visible. Observer snapshots contain both
|
||||
inventories and effect counters, both exit states and authoritative exit
|
||||
timestamps, every remaining ground object, and the public match result.
|
||||
|
||||
## Disconnect recovery
|
||||
|
||||
Both players start connected. Disconnecting either player during `active` or
|
||||
`finish_pending` stores that exact phase, clears queued commands, enters
|
||||
`paused`, and assigns the disconnected player a monotonic deadline 60,000 ms
|
||||
after detection. Commands processed during the pause are rejected before any
|
||||
position, inventory, effect counter, or result can change.
|
||||
If the disconnect occurs during `finish_pending`, the remaining 200 ms result
|
||||
window is preserved and continues only after both players reconnect.
|
||||
|
||||
Restoring the reserved player session before the deadline reconnects the same
|
||||
player record. The stored phase resumes only when both player records are
|
||||
connected, so a second disconnected player cannot be bypassed. Reaching a
|
||||
disconnected player's deadline ends the match with the authoritative `draw`
|
||||
result and clears queued commands. Observer connection state never enters this
|
||||
state machine.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# Observer browser mode
|
||||
|
||||
Milestone 009 adds a password-free observer role to the same local browser
|
||||
application. The role is still authenticated by its temporary session token,
|
||||
but joining it does not request a player name or consume either player slot.
|
||||
At most four observer sessions are connected at once.
|
||||
|
||||
## Projection and authorization
|
||||
|
||||
An observer renders only the server's `observer` view projection. That
|
||||
projection contains all maze cells, remaining objects, both player positions,
|
||||
inventories, effects, exit state, phase, and result. The browser does not
|
||||
simulate movements or infer updates. Each player status also reflects the
|
||||
server-owned WebSocket connection state. A complete projection establishes the
|
||||
server-approved static geometry on join, restoration, and match start.
|
||||
Subsequent bounded projections replace all dynamic player and object state
|
||||
while reusing only that authoritative geometry, avoiding an 8,192-cell
|
||||
retransmission after every move.
|
||||
|
||||
The observer screen contains no movement, start, or drop-key control. This is
|
||||
only presentation-level defense: the gateway also rejects and logs observer
|
||||
`start`, `move`, and `drop_key` attempts before they reach authoritative
|
||||
mutation. Disconnecting an observer changes only lobby presence and never
|
||||
pauses the game.
|
||||
|
||||
## Viewport and navigation
|
||||
|
||||
At `1024 x 768` CSS pixels and above, the complete `128 x 64` default map is
|
||||
initially scaled to fit the available stage. The observer can zoom with the
|
||||
labelled buttons or mouse wheel and pan with pointer dragging. Zoom is bounded
|
||||
between 0.25 and 6 times the eight-pixel base cell size; the **Вся карта**
|
||||
control restores the calculated fit and centered position.
|
||||
|
||||
When either viewport dimension is below the supported minimum, the map and
|
||||
controls are hidden and the Russian message **Нужен экран побольше** explains
|
||||
the `1024 x 768` requirement. All assets remain local and use the shared
|
||||
retro-arcade visual language.
|
||||
@@ -57,3 +57,6 @@ Russian; protocol identifiers remain English.
|
||||
Sound and supported vibration are enabled after the first pointer or keyboard
|
||||
interaction. Both are progressive enhancements: unsupported devices continue
|
||||
without an error.
|
||||
|
||||
Observer presentation and its read-only interaction boundary are documented in
|
||||
`OBSERVER_UI.md`.
|
||||
|
||||
+19
-6
@@ -33,10 +33,12 @@ is only echoed for correlation; it grants no authority.
|
||||
- `suggest_name`: `{}`.
|
||||
- `start`: `{}`. Only connected Player 1 may use it, and both player slots must
|
||||
be connected.
|
||||
- `move`: `{"direction":"north|east|south|west"}`. Only a player in an active,
|
||||
unpaused match may submit movement intent.
|
||||
- `drop_key`: `{}`. Only a player in an active, unpaused match may submit the
|
||||
key-drop intent.
|
||||
- `heartbeat`: `{}` after a session has been joined or restored.
|
||||
|
||||
No Milestone 005 maze or movement messages are implemented by this milestone.
|
||||
|
||||
## Server messages
|
||||
|
||||
- `session`: the private role result containing `role`, `slot`, and temporary
|
||||
@@ -47,10 +49,21 @@ No Milestone 005 maze or movement messages are implemented by this milestone.
|
||||
- `countdown`: the single authoritative `[3,2,1]` event with common monotonic
|
||||
`startedAtMs` and `activationAtMs` values. It is sent to both players.
|
||||
- `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.
|
||||
- `pause`: identifies the disconnected player slot and its monotonic reconnect
|
||||
deadline.
|
||||
- `resume`: reports that both players are attached and authoritative gameplay
|
||||
has resumed.
|
||||
- `match_result`: reports the terminal winner or draw.
|
||||
- `ack`: successful heartbeat correlation.
|
||||
- `error`: a stable English `code` and Russian player-facing `message`.
|
||||
|
||||
An observer session can receive snapshots but cannot issue `start`. Player
|
||||
tokens reserve their original slot after disconnection so a refresh can restore
|
||||
it. A disconnected observer token may be restored until its bounded slot is
|
||||
reused by a new observer.
|
||||
An observer session can receive snapshots but cannot issue `start`, `move`, or
|
||||
`drop_key`; attempts are rejected with `read_only` where applicable and logged
|
||||
by the gateway. Player tokens reserve their original slot after disconnection
|
||||
so a refresh can restore it. A player disconnect pauses the game for at most 60
|
||||
seconds; restoration reattaches the same state, while expiry produces a draw.
|
||||
A disconnected observer token may be restored until its bounded slot is reused
|
||||
by a new observer, and observer disconnects never pause the match.
|
||||
|
||||
@@ -78,6 +78,16 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-o "$test_directory/test_game_mechanics"
|
||||
|
||||
"$test_directory/test_game_mechanics"
|
||||
|
||||
cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
||||
-I"$project_directory/components/maze32_maze/include" \
|
||||
-I"$project_directory/components/maze32_game/include" \
|
||||
"$project_directory/components/maze32_maze/maze32_maze.c" \
|
||||
"$project_directory/components/maze32_game/maze32_game.c" \
|
||||
"$project_directory/test/host/test_disconnect_recovery.c" \
|
||||
-o "$test_directory/test_disconnect_recovery"
|
||||
|
||||
"$test_directory/test_disconnect_recovery"
|
||||
node "$project_directory/test/web/test_player_app.js"
|
||||
python3 "$project_directory/scripts/generate_web_assets.py" \
|
||||
--source "$project_directory/web" \
|
||||
|
||||
@@ -10,6 +10,7 @@ import json
|
||||
import os
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
@@ -120,6 +121,131 @@ def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any
|
||||
return client.receive_type("session")["payload"]
|
||||
|
||||
|
||||
def receive_view(client: WebSocket, maximum_messages: int = 800) -> dict[str, Any]:
|
||||
parts: dict[int, dict[str, Any]] = {}
|
||||
sequence: int | None = None
|
||||
expected = 0
|
||||
for _ in range(maximum_messages):
|
||||
message = client.receive()
|
||||
if message.get("type") != "view":
|
||||
continue
|
||||
payload = message["payload"]
|
||||
if sequence != message["seq"]:
|
||||
sequence = message["seq"]
|
||||
expected = payload["chunks"]
|
||||
parts = {}
|
||||
parts[payload["chunk"]] = payload
|
||||
if len(parts) == expected:
|
||||
first = dict(parts[0])
|
||||
first["cells"] = [cell for index in range(expected) for cell in parts[index]["cells"]]
|
||||
first["objects"] = [
|
||||
item for index in range(expected) for item in parts[index]["objects"]
|
||||
]
|
||||
return first
|
||||
raise RuntimeError("did not receive a complete view")
|
||||
|
||||
|
||||
def open_direction(view: dict[str, Any]) -> str:
|
||||
passages = next(cell[1] for cell in view["cells"] if cell[0] == view["self"])
|
||||
for bit, direction in ((1, "north"), (2, "east"), (4, "south"), (8, "west")):
|
||||
if passages & bit:
|
||||
return direction
|
||||
raise AssertionError("player cell has no open passage")
|
||||
|
||||
|
||||
def verify_m009(host: str) -> None:
|
||||
clients: list[WebSocket] = []
|
||||
try:
|
||||
player_1 = WebSocket(host)
|
||||
player_2 = WebSocket(host)
|
||||
clients.extend((player_1, player_2))
|
||||
join(player_1, "player", "Наблюдаемый Один")
|
||||
session_2 = join(player_2, "player", "Наблюдаемый Два")
|
||||
observers = [WebSocket(host) for _ in range(4)]
|
||||
clients.extend(observers)
|
||||
for observer in observers:
|
||||
join(observer, "observer")
|
||||
observer.socket.settimeout(30.0)
|
||||
|
||||
observer_views: list[dict[str, Any] | None] = [None] * 4
|
||||
failures: list[BaseException] = []
|
||||
|
||||
def read_observer(index: int) -> None:
|
||||
try:
|
||||
observers[index].receive_type("active", attempts=40)
|
||||
observer_views[index] = receive_view(observers[index])
|
||||
except BaseException as error: # Preserve worker failure for the main thread.
|
||||
failures.append(error)
|
||||
|
||||
readers = [threading.Thread(target=read_observer, args=(index,)) for index in range(4)]
|
||||
for reader in readers:
|
||||
reader.start()
|
||||
player_1.send("start", {}, "m009-start")
|
||||
player_1.receive_type("countdown")
|
||||
player_2.receive_type("countdown")
|
||||
player_1.receive_type("active")
|
||||
player_2.receive_type("active")
|
||||
player_1_view = receive_view(player_1)
|
||||
player_2_view = receive_view(player_2)
|
||||
for reader in readers:
|
||||
reader.join(timeout=35.0)
|
||||
assert not failures and all(not reader.is_alive() for reader in readers)
|
||||
assert all(view is not None and len(view["cells"]) == 8192 for view in observer_views)
|
||||
print("four-observer full-map delivery: PASS (8192 cells each)")
|
||||
|
||||
observers[0].send("move", {"direction": "north"}, "observer-move")
|
||||
error = observers[0].receive_type("error")
|
||||
assert error["payload"]["code"] == "read_only"
|
||||
print("observer mutation authorization: PASS (read_only)")
|
||||
|
||||
direction = open_direction(player_1_view)
|
||||
player_1.send("move", {"direction": direction}, "before-observer-close")
|
||||
player_1.receive_type("ack")
|
||||
player_1_view = receive_view(player_1)
|
||||
observers[-1].close()
|
||||
clients.remove(observers[-1])
|
||||
observers.pop()
|
||||
time.sleep(0.2)
|
||||
player_1.send("move", {"direction": open_direction(player_1_view)},
|
||||
"after-observer-close")
|
||||
player_1.receive_type("ack")
|
||||
print("observer disconnect continuity: PASS")
|
||||
|
||||
preserved_cell = player_2_view["self"]
|
||||
preserved_inventory = player_2_view.get("inventory")
|
||||
token_2 = session_2["token"]
|
||||
player_2.close()
|
||||
clients.remove(player_2)
|
||||
pause = player_1.receive_type("pause", attempts=30)["payload"]
|
||||
assert pause["slot"] == 2
|
||||
player_1.send("move", {"direction": "north"}, "paused-move")
|
||||
error = player_1.receive_type("error", attempts=30)
|
||||
assert error["payload"]["code"] == "premature"
|
||||
|
||||
restored = WebSocket(host)
|
||||
clients.append(restored)
|
||||
restored.send("restore", {"token": token_2}, "restore-player-2")
|
||||
restored_session = restored.receive_type("session")["payload"]
|
||||
assert restored_session["slot"] == 2 and restored_session["token"] == token_2
|
||||
player_1.receive_type("resume", attempts=30)
|
||||
restored_view = receive_view(restored)
|
||||
assert restored_view["self"] == preserved_cell
|
||||
assert restored_view.get("inventory") == preserved_inventory
|
||||
print("player pause and reconnect: PASS (same position and inventory)")
|
||||
|
||||
restored.close()
|
||||
clients.remove(restored)
|
||||
player_1.socket.settimeout(70.0)
|
||||
started = time.monotonic()
|
||||
result = player_1.receive_type("match_result", attempts=60)["payload"]
|
||||
elapsed = time.monotonic() - started
|
||||
assert result["result"] == "draw" and elapsed >= 59.0
|
||||
print(f"reconnect timeout draw: PASS ({elapsed:.1f} seconds)")
|
||||
finally:
|
||||
for client in clients:
|
||||
client.close()
|
||||
|
||||
|
||||
def verify(host: str) -> None:
|
||||
clients: list[WebSocket] = []
|
||||
try:
|
||||
@@ -223,8 +349,11 @@ def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("host", help="maze32 IP address or hostname")
|
||||
parser.add_argument("--verify", action="store_true", help="run the Milestone 004 scenario")
|
||||
parser.add_argument("--verify-m009", action="store_true", help="run the Milestone 009 scenario")
|
||||
arguments = parser.parse_args()
|
||||
if arguments.verify:
|
||||
if arguments.verify_m009:
|
||||
verify_m009(arguments.host)
|
||||
elif arguments.verify:
|
||||
verify(arguments.host)
|
||||
else:
|
||||
client = WebSocket(arguments.host)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
#include <assert.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include "maze32_game.h"
|
||||
|
||||
static void initialize_game(maze32_maze_t* maze, maze32_maze_workspace_t* workspace,
|
||||
maze32_game_t* game) {
|
||||
maze32_maze_config_t maze_config = maze32_maze_default_config();
|
||||
maze32_maze_report_t report;
|
||||
assert(maze32_maze_generate(maze, workspace, &maze_config, UINT32_C(0x5a320009), &report) ==
|
||||
MAZE32_MAZE_OK);
|
||||
maze32_game_config_t game_config = maze32_game_default_config();
|
||||
game_config.movement_cooldown_ms = 0U;
|
||||
assert(maze32_game_init(game, maze, &game_config) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
maze32_game_set_phase(game, MAZE32_GAME_PHASE_ACTIVE);
|
||||
}
|
||||
|
||||
static void test_player_pause_and_reconnect(void) {
|
||||
static maze32_maze_t maze;
|
||||
static maze32_maze_workspace_t workspace;
|
||||
static maze32_game_t game;
|
||||
initialize_game(&maze, &workspace, &game);
|
||||
game.players[0].vision_moves_remaining = 7U;
|
||||
maze32_cell_t position = game.players[0].position;
|
||||
|
||||
assert(maze32_game_set_player_connected(&game, 1U, 0, 1000U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
|
||||
assert(game.players[1].reconnect_deadline_ms == 61000U);
|
||||
uint32_t sequence = 0U;
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_PLAYER_ONE, MAZE32_GAME_DIRECTION_NORTH,
|
||||
2000U, &sequence) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_process_next(&game).result == MAZE32_GAME_RESULT_PREMATURE);
|
||||
assert(game.players[0].position.x == position.x && game.players[0].position.y == position.y);
|
||||
assert(game.players[0].vision_moves_remaining == 7U);
|
||||
|
||||
assert(maze32_game_set_player_connected(&game, 1U, 1, 60999U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(game.phase == MAZE32_GAME_PHASE_ACTIVE);
|
||||
assert(game.players[1].reconnect_deadline_ms == 0U);
|
||||
assert(game.players[0].vision_moves_remaining == 7U);
|
||||
}
|
||||
|
||||
static void test_timeout_draw_and_observer_read_only(void) {
|
||||
static maze32_maze_t maze;
|
||||
static maze32_maze_workspace_t workspace;
|
||||
static maze32_game_t game;
|
||||
static maze32_view_t view;
|
||||
initialize_game(&maze, &workspace, &game);
|
||||
|
||||
uint32_t sequence = 0U;
|
||||
assert(maze32_game_enqueue_move(&game, MAZE32_GAME_ROLE_OBSERVER, MAZE32_GAME_DIRECTION_EAST,
|
||||
1U, &sequence) == MAZE32_GAME_RESULT_READ_ONLY);
|
||||
assert(game.state_sequence == 0U);
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(view.cell_count == maze.config.width * maze.config.height);
|
||||
assert(view.observer_connected[0] && view.observer_connected[1]);
|
||||
|
||||
assert(maze32_game_set_player_connected(&game, 0U, 0, 5000U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(maze32_game_project_view(&game, MAZE32_GAME_ROLE_OBSERVER, &view) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(!view.observer_connected[0] && view.observer_connected[1]);
|
||||
assert(!maze32_game_advance_time(&game, 64999U));
|
||||
assert(maze32_game_advance_time(&game, 65000U));
|
||||
assert(game.phase == MAZE32_GAME_PHASE_FINISHED);
|
||||
assert(game.final_result == MAZE32_GAME_FINAL_DRAW);
|
||||
assert(maze32_game_set_player_connected(&game, 0U, 1, 65000U) ==
|
||||
MAZE32_GAME_RESULT_MATCH_FINISHED);
|
||||
}
|
||||
|
||||
static void test_finish_window_is_frozen_while_paused(void) {
|
||||
static maze32_maze_t maze;
|
||||
static maze32_maze_workspace_t workspace;
|
||||
static maze32_game_t game;
|
||||
initialize_game(&maze, &workspace, &game);
|
||||
game.phase = MAZE32_GAME_PHASE_FINISH_PENDING;
|
||||
game.first_exited_player = 0U;
|
||||
game.has_exit_time[0] = 1U;
|
||||
game.exit_times_ms[0] = 1000U;
|
||||
|
||||
assert(maze32_game_set_player_connected(&game, 1U, 0, 1100U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(game.phase == MAZE32_GAME_PHASE_PAUSED);
|
||||
assert(maze32_game_set_player_connected(&game, 1U, 1, 60100U) == MAZE32_GAME_RESULT_ACCEPTED);
|
||||
assert(game.phase == MAZE32_GAME_PHASE_FINISH_PENDING);
|
||||
assert(game.exit_times_ms[0] == 60000U);
|
||||
assert(!maze32_game_advance_time(&game, 60200U));
|
||||
assert(maze32_game_advance_time(&game, 60201U));
|
||||
assert(game.final_result == MAZE32_GAME_FINAL_PLAYER_ONE);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
test_player_pause_and_reconnect();
|
||||
test_timeout_draw_and_observer_read_only();
|
||||
test_finish_window_is_frozen_while_paused();
|
||||
puts("disconnect recovery tests: PASS");
|
||||
return 0;
|
||||
}
|
||||
@@ -312,6 +312,9 @@ static void test_default_map_chunk_bounds(void) {
|
||||
size_t written = 0U;
|
||||
assert(maze32_game_serialize_view_chunk(&view, chunk, payload, sizeof(payload), &written) ==
|
||||
MAZE32_GAME_RESULT_ACCEPTED);
|
||||
if (chunk == 0U) {
|
||||
assert(strstr(payload, "\"connected\":1") != NULL);
|
||||
}
|
||||
if (written > largest_chunk) {
|
||||
largest_chunk = written;
|
||||
}
|
||||
|
||||
@@ -29,6 +29,18 @@ static void test_protocol_schema(void) {
|
||||
assert(message.type == MAZE32_MESSAGE_RESTORE);
|
||||
assert(strcmp(message.token, TOKEN_1) == 0);
|
||||
|
||||
const char* move = "{\"v\":1,\"type\":\"move\",\"seq\":3,\"payload\":{\"direction\":\"west\"}}";
|
||||
assert(maze32_protocol_parse(move, strlen(move), &message) == MAZE32_PROTOCOL_OK);
|
||||
assert(message.type == MAZE32_MESSAGE_MOVE);
|
||||
assert(strcmp(message.direction, "west") == 0);
|
||||
const char* drop_key = "{\"v\":1,\"type\":\"drop_key\",\"seq\":4,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(drop_key, strlen(drop_key), &message) == MAZE32_PROTOCOL_OK);
|
||||
assert(message.type == MAZE32_MESSAGE_DROP_KEY);
|
||||
const char* diagonal =
|
||||
"{\"v\":1,\"type\":\"move\",\"seq\":5,\"payload\":{\"direction\":\"northwest\"}}";
|
||||
assert(maze32_protocol_parse(diagonal, strlen(diagonal), &message) ==
|
||||
MAZE32_PROTOCOL_INVALID_PAYLOAD);
|
||||
|
||||
const char* bad_version = "{\"v\":2,\"type\":\"start\",\"seq\":1,\"payload\":{}}";
|
||||
assert(maze32_protocol_parse(bad_version, strlen(bad_version), &message) ==
|
||||
MAZE32_PROTOCOL_UNSUPPORTED_VERSION);
|
||||
@@ -52,6 +64,7 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
||||
maze32_lobby_init(&lobby);
|
||||
maze32_session_t* player_1 = NULL;
|
||||
maze32_session_t* player_2 = NULL;
|
||||
maze32_session_t* extra = NULL;
|
||||
assert(maze32_lobby_join_player(&lobby, 10, "Капитан Клавиша", TOKEN_1, &player_1) ==
|
||||
MAZE32_LOBBY_OK);
|
||||
assert(player_1->player_slot == 1U);
|
||||
@@ -71,7 +84,11 @@ static void test_lobby_limits_restoration_and_authority(void) {
|
||||
assert(observer->role == MAZE32_ROLE_OBSERVER);
|
||||
}
|
||||
assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT);
|
||||
maze32_session_t* extra = NULL;
|
||||
maze32_lobby_phase_t phase_before_observer_disconnect = lobby.phase;
|
||||
maze32_lobby_disconnect(&lobby, 20);
|
||||
assert(lobby.phase == phase_before_observer_disconnect);
|
||||
assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT - 1U);
|
||||
assert(maze32_lobby_restore(&lobby, 20, OBSERVER_TOKENS[0], &extra) == MAZE32_LOBBY_OK);
|
||||
revision = lobby.revision;
|
||||
assert(maze32_lobby_join_observer(&lobby, 30, OBSERVER_TOKENS[4], &extra) ==
|
||||
MAZE32_LOBBY_ROLE_FULL);
|
||||
|
||||
@@ -74,3 +74,33 @@ test("player assets are local, Russian, accessible, and responsive", () => {
|
||||
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);
|
||||
});
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
0.8.0
|
||||
0.9.0
|
||||
|
||||
@@ -57,6 +57,18 @@
|
||||
<button class="drop-key" id="drop-key" type="button" aria-label="Сбросить ключ" disabled><svg aria-hidden="true"><use href="/icons.svg#key"></use></svg><strong>OK</strong><span>бросить</span></button>
|
||||
<nav class="dpad" aria-label="Управление движением"><button type="button" data-direction="north" aria-label="Двигаться вверх">▲</button><button type="button" data-direction="west" aria-label="Двигаться влево">◀</button><span aria-hidden="true">●</span><button type="button" data-direction="east" aria-label="Двигаться вправо">▶</button><button type="button" data-direction="south" aria-label="Двигаться вниз">▼</button></nav>
|
||||
</section>
|
||||
|
||||
<section class="screen observer-screen" id="observer-screen" hidden aria-labelledby="observer-title">
|
||||
<div class="observer-toolbar">
|
||||
<div><p class="eyebrow">Режим наблюдателя · только чтение</p><h1 id="observer-title">Весь куст целиком</h1></div>
|
||||
<div class="observer-players" aria-live="polite"><span class="player-one">P1 <strong id="observer-p1">готов</strong></span><span class="player-two">P2 <strong id="observer-p2">готов</strong></span><span id="observer-phase">Ждём старта</span></div>
|
||||
<div class="zoom-controls" aria-label="Масштаб карты"><button id="zoom-out" type="button" aria-label="Уменьшить масштаб">−</button><button id="zoom-reset" type="button">Вся карта</button><button id="zoom-in" type="button" aria-label="Увеличить масштаб">+</button></div>
|
||||
</div>
|
||||
<div class="observer-requirement" id="observer-requirement" role="status"><svg aria-hidden="true"><use href="/icons.svg#eye"></use></svg><strong>Нужен экран побольше</strong><span>Зрительный зал открывается от 1024 × 768 пикселей.</span></div>
|
||||
<div class="observer-stage" id="observer-stage" tabindex="0" aria-label="Полная карта лабиринта. Масштабируйте колёсиком или кнопками, перетаскивайте мышью.">
|
||||
<div class="observer-maze" id="observer-maze" role="img" aria-label="Полная карта лабиринта"></div>
|
||||
</div>
|
||||
</section>
|
||||
<div class="notice" id="notice" role="status" aria-live="polite">Сервер решает всё. Мы отвечаем за драматичное мерцание.</div>
|
||||
</main>
|
||||
|
||||
|
||||
@@ -158,6 +158,39 @@ input:focus { border-color: var(--aqua); box-shadow: 0 0 0 3px #43ead229; }
|
||||
.modal-card h2 { font-size: clamp(1.5rem, 7vw, 2.7rem); }
|
||||
.countdown { color: var(--warning); font-size: clamp(5rem, 24vw, 10rem); font-weight: 1000; line-height: 1; text-shadow: 7px 7px 0 #000; animation: count .65s steps(2, end) infinite; }
|
||||
|
||||
.observer-screen { height: calc(100dvh - 78px); min-height: 678px; overflow: hidden; padding-top: 92px; }
|
||||
.observer-toolbar { position: absolute; z-index: 8; inset: 8px 0 auto; min-height: 76px; display: grid; grid-template-columns: 1fr auto auto; align-items: center; gap: 18px; padding: 9px 12px; border: 1px solid var(--edge); background: #171027eb; }
|
||||
.observer-toolbar h1 { font-size: clamp(1.15rem, 2.8vw, 2.1rem); }
|
||||
.observer-toolbar .eyebrow { margin-bottom: 3px; }
|
||||
.observer-players { display: flex; align-items: center; gap: 12px; color: var(--muted); font-size: .72rem; }
|
||||
.observer-players span { padding: 7px; border: 1px solid #51386e; }
|
||||
.observer-players .player-one { border-color: var(--p1); }
|
||||
.observer-players .player-two { border-color: var(--p2); }
|
||||
.zoom-controls { display: flex; gap: 5px; }
|
||||
.zoom-controls button { min-width: 42px; min-height: 38px; border: 1px solid var(--aqua); background: #102a30; color: var(--aqua); cursor: pointer; }
|
||||
.observer-stage { position: absolute; inset: 92px 0 0; overflow: hidden; border: 2px solid #2f2041; background: #050408; cursor: grab; touch-action: none; user-select: none; }
|
||||
.observer-stage.dragging { cursor: grabbing; }
|
||||
.observer-maze { position: absolute; left: 50%; top: 50%; display: grid; transform-origin: center; will-change: transform; }
|
||||
.observer-maze .cell { width: 8px; height: 8px; border-width: 1px; }
|
||||
.observer-maze .cell::after { inset: 2px; }
|
||||
.observer-maze .object { inset: 5%; }
|
||||
.observer-maze .object-key::after { display: none; }
|
||||
.observer-maze .object-landmark { font-size: 6px; }
|
||||
.observer-maze .pawn { inset: -30%; z-index: 5; font-size: 5px; border-width: 1px; box-shadow: 1px 1px 0 #000; }
|
||||
.observer-maze .pawn-player-one { color: var(--p1); }
|
||||
.observer-maze .pawn-player-two { color: var(--p2); transform: rotate(45deg); }
|
||||
.observer-maze .pawn-player-two span { transform: rotate(-45deg); }
|
||||
.observer-requirement { display: none; position: absolute; inset: 92px 0 0; place-content: center; justify-items: center; gap: 12px; padding: 24px; color: var(--muted); text-align: center; border: 2px solid var(--edge); background: var(--panel-deep); }
|
||||
.observer-requirement svg { width: 58px; height: 58px; color: var(--p2); }
|
||||
.observer-requirement strong { color: var(--ink); font-size: 1.2rem; text-transform: uppercase; }
|
||||
|
||||
@media (max-width: 1023px), (max-height: 767px) {
|
||||
.observer-toolbar { grid-template-columns: 1fr; }
|
||||
.observer-toolbar > :not(:first-child) { display: none; }
|
||||
.observer-stage { display: none; }
|
||||
.observer-requirement { display: grid; }
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
:root { --cell: 20px; }
|
||||
#app { padding-left: max(8px, env(safe-area-inset-left)); padding-right: max(8px, env(safe-area-inset-right)); }
|
||||
|
||||
+91
-6
@@ -5,6 +5,7 @@
|
||||
const MAX_VIEW_CHUNKS = 700;
|
||||
const MAX_VIEW_CELLS = 8192;
|
||||
const MAX_VIEW_OBJECTS = 30;
|
||||
const OBSERVER_CELL_SIZE = 8;
|
||||
const KEY_DIRECTIONS = Object.freeze({
|
||||
ArrowUp: "north", KeyW: "north",
|
||||
ArrowRight: "east", KeyD: "east",
|
||||
@@ -85,9 +86,18 @@
|
||||
return snapshot && Array.isArray(snapshot.cells) ? snapshot.cells.map(cell => cell[0]) : [];
|
||||
}
|
||||
|
||||
function clampObserverZoom(value) {
|
||||
return Math.min(6, Math.max(.25, value));
|
||||
}
|
||||
|
||||
function observerFitScale(stageWidth, stageHeight, columns, rows) {
|
||||
if (![stageWidth, stageHeight, columns, rows].every(value => Number.isFinite(value) && value > 0)) return 1;
|
||||
return clampObserverZoom(Math.min(stageWidth / (columns * OBSERVER_CELL_SIZE), stageHeight / (rows * OBSERVER_CELL_SIZE)) * .96);
|
||||
}
|
||||
|
||||
function boot(documentObject, windowObject) {
|
||||
const byId = id => documentObject.getElementById(id);
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false};
|
||||
const state = {socket: null, clientSequence: 0, serverSequence: 0, viewSequence: -1, session: null, lobby: null, snapshot: null, reconnectTimer: null, noticeTimer: null, touchStart: null, pending: new Map(), assembler: new SnapshotAssembler(), audio: null, feedbackReady: false, observer: {scale: 1, x: 0, y: 0, dragging: null}};
|
||||
|
||||
function showNotice(text, kind) {
|
||||
const element = byId("notice");
|
||||
@@ -101,6 +111,7 @@
|
||||
byId("welcome-screen").hidden = name !== "welcome";
|
||||
byId("lobby-screen").hidden = name !== "lobby";
|
||||
byId("match-screen").hidden = name !== "match";
|
||||
byId("observer-screen").hidden = name !== "observer";
|
||||
}
|
||||
|
||||
function setConnection(connectionState, text) {
|
||||
@@ -245,6 +256,10 @@
|
||||
}
|
||||
|
||||
function renderSnapshot(snapshot) {
|
||||
if (snapshot.role === "observer") {
|
||||
renderObserverSnapshot(snapshot);
|
||||
return;
|
||||
}
|
||||
state.snapshot = snapshot;
|
||||
setScreen("match");
|
||||
const slot = state.session && state.session.slot ? state.session.slot : 1;
|
||||
@@ -267,6 +282,66 @@
|
||||
else hideModal();
|
||||
}
|
||||
|
||||
function applyObserverTransform() {
|
||||
const transform = state.observer;
|
||||
byId("observer-maze").style.transform = `translate(-50%, -50%) translate(${transform.x}px, ${transform.y}px) scale(${transform.scale})`;
|
||||
}
|
||||
|
||||
function resetObserverView(snapshot) {
|
||||
const stage = byId("observer-stage");
|
||||
state.observer.scale = observerFitScale(stage.clientWidth, stage.clientHeight, snapshot.width, snapshot.height);
|
||||
state.observer.x = 0;
|
||||
state.observer.y = 0;
|
||||
applyObserverTransform();
|
||||
}
|
||||
|
||||
function renderObserverSnapshot(snapshot) {
|
||||
if (!snapshot.cells.length && state.snapshot && state.snapshot.role === "observer" && state.snapshot.width === snapshot.width && state.snapshot.height === snapshot.height) {
|
||||
snapshot.cells = state.snapshot.cells;
|
||||
}
|
||||
state.snapshot = snapshot;
|
||||
setScreen("observer");
|
||||
const maze = byId("observer-maze");
|
||||
maze.replaceChildren();
|
||||
maze.style.gridTemplateColumns = `repeat(${snapshot.width}, ${OBSERVER_CELL_SIZE}px)`;
|
||||
maze.style.gridTemplateRows = `repeat(${snapshot.height}, ${OBSERVER_CELL_SIZE}px)`;
|
||||
const objects = new Map();
|
||||
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 cell = documentObject.createElement("span");
|
||||
cell.className = "cell";
|
||||
cell.style.gridColumn = String(index % snapshot.width + 1);
|
||||
cell.style.gridRow = String(Math.floor(index / snapshot.width) + 1);
|
||||
if (!(passages & 1)) cell.classList.add("wall-n");
|
||||
if (!(passages & 2)) cell.classList.add("wall-e");
|
||||
if (!(passages & 4)) cell.classList.add("wall-s");
|
||||
if (!(passages & 8)) cell.classList.add("wall-w");
|
||||
(objects.get(index) || []).forEach(object => addObject(cell, object));
|
||||
players.forEach((player, playerIndex) => {
|
||||
if (player.cell === index && !player.exited) {
|
||||
const pawn = documentObject.createElement("span");
|
||||
pawn.className = `pawn pawn-player-${playerIndex === 0 ? "one" : "two"}`;
|
||||
const label = documentObject.createElement("span"); label.textContent = `P${playerIndex + 1}`;
|
||||
pawn.appendChild(label); cell.appendChild(pawn);
|
||||
}
|
||||
});
|
||||
maze.appendChild(cell);
|
||||
});
|
||||
players.forEach((player, index) => {
|
||||
const key = player.key == null ? "без ключа" : `ключ P${player.key + 1}`;
|
||||
byId(`observer-p${index + 1}`).textContent = !player.connected ? "нет связи" : player.exited ? "снаружи" : key;
|
||||
});
|
||||
byId("observer-phase").textContent = PHASES[snapshot.phase] || "Сервер размышляет";
|
||||
const dimensions = `${snapshot.width}x${snapshot.height}`;
|
||||
if (!state.observer.initialized || state.observer.dimensions !== dimensions) {
|
||||
resetObserverView(snapshot); state.observer.initialized = true; state.observer.dimensions = dimensions;
|
||||
} else applyObserverTransform();
|
||||
if (snapshot.phase === "finished" || snapshot.result && snapshot.result !== "none") renderResult(snapshot.result);
|
||||
else if (snapshot.phase !== "paused") hideModal();
|
||||
}
|
||||
|
||||
function showModal(title, text, kicker, countdown) {
|
||||
byId("modal-title").textContent = title; byId("modal-text").textContent = text; byId("modal-kicker").textContent = kicker;
|
||||
byId("countdown").hidden = !countdown; byId("modal").hidden = false;
|
||||
@@ -279,8 +354,8 @@
|
||||
let title = "Ничья!"; let text = "Фотофиниш пожал плечами: оба выбрались почти одновременно.";
|
||||
if (result === "player1" || result === "player2") {
|
||||
const winner = result === "player1" ? 1 : 2;
|
||||
title = winner === slot ? "Победа!" : "Победил соперник";
|
||||
text = winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.";
|
||||
title = slot ? (winner === slot ? "Победа!" : "Победил соперник") : `Победил Игрок ${winner}`;
|
||||
text = slot ? (winner === slot ? "Вы официально быстрее очень большого куста." : "Соперник нашёл выход. Лабиринт требует реванш.") : "Зрительный зал фиксирует героический побег без вмешательства в результат.";
|
||||
}
|
||||
showModal(title, text, "ГОНКА ЗАВЕРШЕНА", false);
|
||||
byId("modal-action").hidden = false;
|
||||
@@ -314,8 +389,9 @@
|
||||
} else if (message.type === "lobby") { state.lobby = message.payload; renderLobby();
|
||||
} else if (message.type === "name_suggestion") { byId("player-name").value = message.payload.name;
|
||||
} else if (message.type === "countdown") { showCountdown(message.payload);
|
||||
} else if (message.type === "active") { setScreen("match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
|
||||
} else if (message.type === "pause") { showModal("Пауза", "Второй герой переподключается. Ноги пока не считаются.", "ЖДЁМ СВЯЗЬ", false);
|
||||
} else if (message.type === "active") { setScreen(state.session && state.session.role === "observer" ? "observer" : "match"); byId("countdown").textContent = "ВПЕРЁД"; windowObject.setTimeout(hideModal, 750);
|
||||
} else if (message.type === "pause") { showModal("Пауза", `Игрок ${message.payload.slot || "?"} переподключается. Ноги пока не считаются.`, "ЖДЁМ СВЯЗЬ", false);
|
||||
} else if (message.type === "resume") { hideModal(); showNotice("Оба героя снова на связи. Гонка продолжается.", "success");
|
||||
} else if (message.type === "match_result") { renderResult(message.payload.result);
|
||||
} else if (message.type === "ack") { feedback(true);
|
||||
} else if (message.type === "error") {
|
||||
@@ -352,13 +428,22 @@
|
||||
byId("join-player").addEventListener("click", () => send("join", {role: "player", name: byId("player-name").value}, byId("join-player")));
|
||||
byId("join-observer").addEventListener("click", () => send("join", {role: "observer"}, byId("join-observer")));
|
||||
byId("start-match").addEventListener("click", () => send("start", {}, byId("start-match")));
|
||||
byId("zoom-in").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale * 1.25); applyObserverTransform(); });
|
||||
byId("zoom-out").addEventListener("click", () => { state.observer.scale = clampObserverZoom(state.observer.scale / 1.25); applyObserverTransform(); });
|
||||
byId("zoom-reset").addEventListener("click", () => { if (state.snapshot) resetObserverView(state.snapshot); });
|
||||
byId("observer-stage").addEventListener("wheel", event => { event.preventDefault(); state.observer.scale = clampObserverZoom(state.observer.scale * (event.deltaY < 0 ? 1.12 : .89)); applyObserverTransform(); }, {passive: false});
|
||||
byId("observer-stage").addEventListener("pointerdown", event => { state.observer.dragging = {id: event.pointerId, x: event.clientX, y: event.clientY, originX: state.observer.x, originY: state.observer.y}; byId("observer-stage").classList.add("dragging"); if (byId("observer-stage").setPointerCapture) byId("observer-stage").setPointerCapture(event.pointerId); });
|
||||
byId("observer-stage").addEventListener("pointermove", event => { const drag = state.observer.dragging; if (!drag || drag.id !== event.pointerId) return; state.observer.x = drag.originX + event.clientX - drag.x; state.observer.y = drag.originY + event.clientY - drag.y; applyObserverTransform(); });
|
||||
const stopObserverDrag = event => { if (state.observer.dragging && state.observer.dragging.id === event.pointerId) { state.observer.dragging = null; byId("observer-stage").classList.remove("dragging"); } };
|
||||
byId("observer-stage").addEventListener("pointerup", stopObserverDrag);
|
||||
byId("observer-stage").addEventListener("pointercancel", stopObserverDrag);
|
||||
byId("details-toggle").addEventListener("click", () => { const panel = byId("details-panel"); panel.hidden = !panel.hidden; byId("details-toggle").setAttribute("aria-expanded", String(!panel.hidden)); });
|
||||
byId("modal-action").addEventListener("click", () => { hideModal(); setScreen("lobby"); });
|
||||
connect();
|
||||
return {state, handleMessage, move};
|
||||
}
|
||||
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, boot});
|
||||
const api = Object.freeze({TOKEN_KEY, MAX_VIEW_CHUNKS, directionFromKey, directionFromSwipe, createEnvelope, snapshotIsValid, SnapshotAssembler, projectedCellIndices, clampObserverZoom, observerFitScale, boot});
|
||||
if (typeof module !== "undefined" && module.exports) module.exports = api;
|
||||
root.Maze32Player = api;
|
||||
if (root.document) root.document.addEventListener("DOMContentLoaded", () => { root.maze32PlayerApp = boot(root.document, root); });
|
||||
|
||||
Reference in New Issue
Block a user