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
+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);
});