feat(observer): add observer mode and disconnect recovery

This commit is contained in:
2026-09-05 21:19:52 +03:00
parent d042c3ce90
commit ffdaa299bc
23 changed files with 988 additions and 43 deletions
+62 -13
View File
@@ -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);
+110 -8
View File
@@ -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,8 +95,14 @@ 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 &&
(!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;
}
}
}
static maze32_game_result_t enqueue_command(maze32_game_t* game, maze32_game_role_t role,
@@ -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,
+18
View File
@@ -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 -1
View File
@@ -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);
+273 -1
View File
@@ -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
View File
@@ -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.
+37
View File
@@ -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.
+3
View File
@@ -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
View File
@@ -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.
+10
View File
@@ -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" \
+130 -1
View File
@@ -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)
+96
View File
@@ -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;
}
+3
View File
@@ -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;
}
+18 -1
View File
@@ -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);
+30
View File
@@ -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
View File
@@ -1 +1 @@
0.8.0
0.9.0
+12
View File
@@ -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>
+33
View File
@@ -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
View File
@@ -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); });