diff --git a/PLANS.md b/PLANS.md index bf31c56..ded6672 100644 --- a/PLANS.md +++ b/PLANS.md @@ -274,12 +274,11 @@ access-point provisioning flow. invalid two-field configuration request returned HTTP 400. A final board reboot again loaded and connected to `Mazda7`, proving the rejected request did not alter the persisted valid configuration. -- All five acceptance criteria therefore have recorded evidence. Milestone 004 - remains `NOT_STARTED`. +- All five acceptance criteria therefore have recorded evidence. ## Milestone 004 — HTTP, WebSocket, sessions, and lobby -**Status:** `NOT_STARTED` +**Status:** `COMPLETE` ### Objective @@ -313,9 +312,57 @@ observers, and an owner-controlled lobby. ### Evidence -Completion evidence is not yet available because this milestone has not -started. Required evidence includes protocol-test output, six-client connection -results, session restoration tests, and lobby authorization tests. +- On 2026-09-04, `./scripts/run_host_tests.sh` reported + `health state tests: PASS`, `wifi configuration tests: PASS`, + `configuration NVS tests: PASS`, `provisioning protocol tests: PASS`, + `lobby and protocol tests: PASS`, and `web asset reproducibility: PASS + (6bdc7d380994cd302019c8634570953881cda035757d577fe2b1f54742a95f33)`. + The deterministic lobby tests cover the two-player and four-observer bounds, + duplicate-name and excess-role rejection, Player 2 start rejection, Player 1 + readiness enforcement, restoration to the original slot, and the shared + countdown activation instant. Protocol tests cover valid envelopes plus + malformed, oversized, duplicate-field, unsupported-version, unknown-type, + invalid-payload, and stale-sequence handling without state mutation. +- A clean production build followed by + `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target + upload --upload-port /dev/ttyACM0` completed with `[SUCCESS]` in 50.31 + seconds. PlatformIO reported 47,508 bytes of RAM and 1,005,274 of 2,097,152 + application bytes in use; the independent partition check reported + `firmware.bin uses 1005904/2097152 bytes in partition factory`. The upload + identified the physical target as an ESP32-C6FH4 with 4 MB embedded Flash, + wrote the bootloader, partition table, and application, and hash-verified all + three images. +- `/home/sasa/.platformio/penv/bin/pio run -e esp32-c6-devkitm-1 --target + buildfs` completed with `[SUCCESS]` and reported + `littlefs.bin uses 2031616/2031616 bytes in partition littlefs`. The two + deterministic compressed browser assets total 5,970 bytes, below the 1.5 MiB + web-payload limit and within the unchanged 1,984 KiB LittleFS partition. +- The final firmware booted through the bounded station retry policy and served + its fallback network while the configured `Mazda7` access point was + unavailable. A live `GET /health` returned + `{"status":"serving","healthy":true,"version":"0.4.0","errorCode":0}`. + The host retained its independent Ethernet route while its Wi-Fi interface + joined the device access point, so device testing did not interrupt the + development connection. +- On the physical board, + `python3 scripts/ws_protocol_client.py 192.168.4.1 --verify` reported + `malformed and stale message rejection: PASS`, + `six-client capacity: PASS (2 players + 4 observers)`, + `duplicate player name: PASS`, `seventh connection rejection: PASS`, + `Player 2 start authorization: PASS`, + `session refresh restoration: PASS (Player 2 retained slot 2)`, + `authoritative countdown: PASS (activationAtMs=72223)`, and + `oversized message rejection: PASS`. The countdown payload received by both + player connections was byte-for-byte equivalent, and both subsequent active + events carried the same server activation timestamp. +- During a six-client physical run, the gateway logged all six bounded + admissions and `Six-client heap: free=305388 bytes, largest=278528 bytes`; + it rejected a seventh connection and a 513-byte frame. Session tokens were + returned only in private session messages and were absent from shared lobby + snapshots. The observer protocol exposes no mutating command, while start + authorization is resolved from the server-owned session and player slot. +- These results provide concrete host and physical-device evidence for all six + acceptance criteria. Milestone 005 remains `NOT_STARTED`. ## Milestone 005 — Symmetric maze generation and validation diff --git a/components/maze32_lobby/CMakeLists.txt b/components/maze32_lobby/CMakeLists.txt new file mode 100644 index 0000000..4d7b43c --- /dev/null +++ b/components/maze32_lobby/CMakeLists.txt @@ -0,0 +1,4 @@ +idf_component_register( + SRCS "maze32_lobby.c" "maze32_protocol.c" + INCLUDE_DIRS "include" +) diff --git a/components/maze32_lobby/include/maze32_lobby.h b/components/maze32_lobby/include/maze32_lobby.h new file mode 100644 index 0000000..2202350 --- /dev/null +++ b/components/maze32_lobby/include/maze32_lobby.h @@ -0,0 +1,82 @@ +#ifndef MAZE32_LOBBY_H +#define MAZE32_LOBBY_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAZE32_PLAYER_LIMIT 2U +#define MAZE32_OBSERVER_LIMIT 4U +#define MAZE32_SESSION_LIMIT (MAZE32_PLAYER_LIMIT + MAZE32_OBSERVER_LIMIT) +#define MAZE32_PLAYER_NAME_MAX_BYTES 32U +#define MAZE32_SESSION_TOKEN_BYTES 16U +#define MAZE32_SESSION_TOKEN_HEX_LENGTH (MAZE32_SESSION_TOKEN_BYTES * 2U) +#define MAZE32_COUNTDOWN_DURATION_MS 3000U + +typedef enum { + MAZE32_ROLE_NONE = 0, + MAZE32_ROLE_PLAYER, + MAZE32_ROLE_OBSERVER, +} maze32_role_t; + +typedef enum { + MAZE32_LOBBY_WAITING = 0, + MAZE32_LOBBY_COUNTDOWN, + MAZE32_LOBBY_ACTIVE, +} maze32_lobby_phase_t; + +typedef enum { + MAZE32_LOBBY_OK = 0, + MAZE32_LOBBY_INVALID_NAME, + MAZE32_LOBBY_DUPLICATE_NAME, + MAZE32_LOBBY_ROLE_FULL, + MAZE32_LOBBY_INVALID_TOKEN, + MAZE32_LOBBY_SESSION_CONNECTED, + MAZE32_LOBBY_NOT_OWNER, + MAZE32_LOBBY_NOT_READY, + MAZE32_LOBBY_INVALID_PHASE, +} maze32_lobby_result_t; + +typedef struct { + int connection_id; + maze32_role_t role; + unsigned int player_slot; + int connected; + char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U]; + char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U]; +} maze32_session_t; + +typedef struct { + maze32_session_t sessions[MAZE32_SESSION_LIMIT]; + maze32_lobby_phase_t phase; + uint64_t countdown_started_ms; + uint64_t activation_ms; + uint32_t revision; +} maze32_lobby_t; + +void maze32_lobby_init(maze32_lobby_t* lobby); +maze32_lobby_result_t maze32_lobby_join_player(maze32_lobby_t* lobby, int connection_id, + const char* name, const char* token, + maze32_session_t** session); +maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int connection_id, + const char* token, maze32_session_t** session); +maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id, + const char* token, maze32_session_t** session); +void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id); +maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, uint64_t now_ms); +int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms); +const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby, + int connection_id); +unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby); +int maze32_lobby_ready(const maze32_lobby_t* lobby); +int maze32_lobby_can_reconfigure(const maze32_lobby_t* lobby); +const char* maze32_lobby_phase_name(maze32_lobby_phase_t phase); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_lobby/include/maze32_protocol.h b/components/maze32_lobby/include/maze32_protocol.h new file mode 100644 index 0000000..2cb9059 --- /dev/null +++ b/components/maze32_lobby/include/maze32_protocol.h @@ -0,0 +1,52 @@ +#ifndef MAZE32_PROTOCOL_H +#define MAZE32_PROTOCOL_H + +#include +#include + +#include "maze32_lobby.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAZE32_PROTOCOL_VERSION 1U +#define MAZE32_PROTOCOL_MESSAGE_LIMIT 512U +#define MAZE32_REQUEST_ID_MAX_BYTES 32U + +typedef enum { + MAZE32_MESSAGE_INVALID = 0, + MAZE32_MESSAGE_JOIN, + MAZE32_MESSAGE_RESTORE, + MAZE32_MESSAGE_START, + MAZE32_MESSAGE_HEARTBEAT, + MAZE32_MESSAGE_SUGGEST_NAME, +} maze32_message_type_t; + +typedef enum { + MAZE32_PROTOCOL_OK = 0, + MAZE32_PROTOCOL_MALFORMED, + MAZE32_PROTOCOL_TOO_LARGE, + MAZE32_PROTOCOL_UNSUPPORTED_VERSION, + MAZE32_PROTOCOL_UNKNOWN_TYPE, + MAZE32_PROTOCOL_INVALID_PAYLOAD, +} maze32_protocol_result_t; + +typedef struct { + maze32_message_type_t type; + uint32_t sequence; + char request_id[MAZE32_REQUEST_ID_MAX_BYTES + 1U]; + maze32_role_t role; + char name[MAZE32_PLAYER_NAME_MAX_BYTES + 1U]; + char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U]; +} maze32_client_message_t; + +maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length, + maze32_client_message_t* message); +const char* maze32_protocol_result_name(maze32_protocol_result_t result); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_lobby/maze32_lobby.c b/components/maze32_lobby/maze32_lobby.c new file mode 100644 index 0000000..936b8f1 --- /dev/null +++ b/components/maze32_lobby/maze32_lobby.c @@ -0,0 +1,263 @@ +#include "maze32_lobby.h" + +#include + +static int is_valid_name(const char* name) { + if (name == NULL) { + return 0; + } + size_t length = strlen(name); + if (length == 0U || length > MAZE32_PLAYER_NAME_MAX_BYTES || name[0] == ' ' || + name[length - 1U] == ' ') { + return 0; + } + for (size_t index = 0U; index < length; ++index) { + unsigned char byte = (unsigned char)name[index]; + if (byte < 0x20U || byte == 0x7fU) { + return 0; + } + } + return 1; +} + +static int is_valid_token(const char* token) { + if (token == NULL || strlen(token) != MAZE32_SESSION_TOKEN_HEX_LENGTH) { + return 0; + } + for (size_t index = 0U; index < MAZE32_SESSION_TOKEN_HEX_LENGTH; ++index) { + char character = token[index]; + if (!((character >= '0' && character <= '9') || (character >= 'a' && character <= 'f'))) { + return 0; + } + } + return 1; +} + +static maze32_session_t* find_player_slot(maze32_lobby_t* lobby, unsigned int slot) { + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + maze32_session_t* session = &lobby->sessions[index]; + if (session->role == MAZE32_ROLE_PLAYER && session->player_slot == slot) { + return session; + } + } + return NULL; +} + +static maze32_session_t* find_free_session(maze32_lobby_t* lobby) { + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + if (lobby->sessions[index].role == MAZE32_ROLE_NONE) { + return &lobby->sessions[index]; + } + } + return NULL; +} + +static maze32_session_t* find_reusable_observer(maze32_lobby_t* lobby) { + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + maze32_session_t* session = &lobby->sessions[index]; + if (session->role == MAZE32_ROLE_OBSERVER && !session->connected) { + return session; + } + } + return NULL; +} + +void maze32_lobby_init(maze32_lobby_t* lobby) { + if (lobby == NULL) { + return; + } + memset(lobby, 0, sizeof(*lobby)); + lobby->phase = MAZE32_LOBBY_WAITING; +} + +maze32_lobby_result_t maze32_lobby_join_player(maze32_lobby_t* lobby, int connection_id, + const char* name, const char* token, + maze32_session_t** session) { + if (lobby == NULL || session == NULL || !is_valid_name(name) || !is_valid_token(token)) { + return MAZE32_LOBBY_INVALID_NAME; + } + if (lobby->phase != MAZE32_LOBBY_WAITING) { + return MAZE32_LOBBY_INVALID_PHASE; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + if (lobby->sessions[index].role == MAZE32_ROLE_PLAYER && + strcmp(lobby->sessions[index].name, name) == 0) { + return MAZE32_LOBBY_DUPLICATE_NAME; + } + } + + unsigned int slot = find_player_slot(lobby, 1U) == NULL ? 1U : 2U; + if (find_player_slot(lobby, slot) != NULL) { + return MAZE32_LOBBY_ROLE_FULL; + } + maze32_session_t* target = find_free_session(lobby); + if (target == NULL) { + return MAZE32_LOBBY_ROLE_FULL; + } + target->connection_id = connection_id; + target->role = MAZE32_ROLE_PLAYER; + target->player_slot = slot; + target->connected = 1; + memcpy(target->name, name, strlen(name) + 1U); + memcpy(target->token, token, MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U); + lobby->revision++; + *session = target; + return MAZE32_LOBBY_OK; +} + +maze32_lobby_result_t maze32_lobby_join_observer(maze32_lobby_t* lobby, int connection_id, + const char* token, maze32_session_t** session) { + if (lobby == NULL || session == NULL || !is_valid_token(token)) { + return MAZE32_LOBBY_INVALID_TOKEN; + } + if (maze32_lobby_connected_observers(lobby) >= MAZE32_OBSERVER_LIMIT) { + return MAZE32_LOBBY_ROLE_FULL; + } + maze32_session_t* target = find_free_session(lobby); + if (target == NULL) { + target = find_reusable_observer(lobby); + } + if (target == NULL) { + return MAZE32_LOBBY_ROLE_FULL; + } + memset(target, 0, sizeof(*target)); + target->connection_id = connection_id; + target->role = MAZE32_ROLE_OBSERVER; + target->connected = 1; + memcpy(target->token, token, MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U); + lobby->revision++; + *session = target; + return MAZE32_LOBBY_OK; +} + +maze32_lobby_result_t maze32_lobby_restore(maze32_lobby_t* lobby, int connection_id, + const char* token, maze32_session_t** session) { + if (lobby == NULL || session == NULL || !is_valid_token(token)) { + return MAZE32_LOBBY_INVALID_TOKEN; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + maze32_session_t* candidate = &lobby->sessions[index]; + if (candidate->role != MAZE32_ROLE_NONE && strcmp(candidate->token, token) == 0) { + if (candidate->connected) { + return MAZE32_LOBBY_SESSION_CONNECTED; + } + candidate->connected = 1; + candidate->connection_id = connection_id; + lobby->revision++; + *session = candidate; + return MAZE32_LOBBY_OK; + } + } + return MAZE32_LOBBY_INVALID_TOKEN; +} + +void maze32_lobby_disconnect(maze32_lobby_t* lobby, int connection_id) { + if (lobby == NULL) { + return; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + maze32_session_t* session = &lobby->sessions[index]; + if (session->role != MAZE32_ROLE_NONE && session->connected && + session->connection_id == connection_id) { + session->connected = 0; + session->connection_id = -1; + lobby->revision++; + return; + } + } +} + +maze32_lobby_result_t maze32_lobby_start(maze32_lobby_t* lobby, int connection_id, + uint64_t now_ms) { + if (lobby == NULL || lobby->phase != MAZE32_LOBBY_WAITING) { + return MAZE32_LOBBY_INVALID_PHASE; + } + const maze32_session_t* session = maze32_lobby_session_for_connection(lobby, connection_id); + if (session == NULL || session->role != MAZE32_ROLE_PLAYER || session->player_slot != 1U) { + return MAZE32_LOBBY_NOT_OWNER; + } + if (!maze32_lobby_ready(lobby)) { + return MAZE32_LOBBY_NOT_READY; + } + lobby->phase = MAZE32_LOBBY_COUNTDOWN; + lobby->countdown_started_ms = now_ms; + lobby->activation_ms = now_ms + MAZE32_COUNTDOWN_DURATION_MS; + lobby->revision++; + return MAZE32_LOBBY_OK; +} + +int maze32_lobby_activate_if_due(maze32_lobby_t* lobby, uint64_t now_ms) { + if (lobby == NULL || lobby->phase != MAZE32_LOBBY_COUNTDOWN || now_ms < lobby->activation_ms) { + return 0; + } + lobby->phase = MAZE32_LOBBY_ACTIVE; + lobby->revision++; + return 1; +} + +const maze32_session_t* maze32_lobby_session_for_connection(const maze32_lobby_t* lobby, + int connection_id) { + if (lobby == NULL) { + return NULL; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + const maze32_session_t* session = &lobby->sessions[index]; + if (session->role != MAZE32_ROLE_NONE && session->connected && + session->connection_id == connection_id) { + return session; + } + } + return NULL; +} + +unsigned int maze32_lobby_connected_observers(const maze32_lobby_t* lobby) { + unsigned int count = 0U; + if (lobby == NULL) { + return count; + } + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + const maze32_session_t* session = &lobby->sessions[index]; + if (session->role == MAZE32_ROLE_OBSERVER && session->connected) { + count++; + } + } + return count; +} + +int maze32_lobby_ready(const maze32_lobby_t* lobby) { + if (lobby == NULL) { + return 0; + } + for (unsigned int slot = 1U; slot <= MAZE32_PLAYER_LIMIT; ++slot) { + int found = 0; + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + const maze32_session_t* session = &lobby->sessions[index]; + if (session->role == MAZE32_ROLE_PLAYER && session->player_slot == slot && + session->connected) { + found = 1; + break; + } + } + if (!found) { + return 0; + } + } + return 1; +} + +int maze32_lobby_can_reconfigure(const maze32_lobby_t* lobby) { + return lobby != NULL && lobby->phase == MAZE32_LOBBY_WAITING; +} + +const char* maze32_lobby_phase_name(maze32_lobby_phase_t phase) { + switch (phase) { + case MAZE32_LOBBY_WAITING: + return "waiting"; + case MAZE32_LOBBY_COUNTDOWN: + return "countdown"; + case MAZE32_LOBBY_ACTIVE: + return "active"; + default: + return "unknown"; + } +} diff --git a/components/maze32_lobby/maze32_protocol.c b/components/maze32_lobby/maze32_protocol.c new file mode 100644 index 0000000..b2c90a8 --- /dev/null +++ b/components/maze32_lobby/maze32_protocol.c @@ -0,0 +1,280 @@ +#include "maze32_protocol.h" + +#include +#include + +typedef struct { + const char* data; + size_t length; + size_t position; +} json_cursor_t; + +static void skip_space(json_cursor_t* cursor) { + while (cursor->position < cursor->length) { + char character = cursor->data[cursor->position]; + if (character != ' ' && character != '\t' && character != '\r' && character != '\n') { + return; + } + cursor->position++; + } +} + +static int consume(json_cursor_t* cursor, char expected) { + skip_space(cursor); + if (cursor->position >= cursor->length || cursor->data[cursor->position] != expected) { + return 0; + } + cursor->position++; + return 1; +} + +static int parse_string(json_cursor_t* cursor, char* output, size_t capacity) { + skip_space(cursor); + if (cursor->position >= cursor->length || cursor->data[cursor->position++] != '"') { + return 0; + } + size_t output_length = 0U; + while (cursor->position < cursor->length) { + unsigned char character = (unsigned char)cursor->data[cursor->position++]; + if (character == '"') { + if (output_length >= capacity) { + return 0; + } + output[output_length] = '\0'; + return 1; + } + if (character < 0x20U) { + return 0; + } + if (character == '\\') { + if (cursor->position >= cursor->length) { + return 0; + } + character = (unsigned char)cursor->data[cursor->position++]; + if (character == '"' || character == '\\' || character == '/') { + /* The escaped byte is copied below. */ + } else if (character == 'b') { + character = '\b'; + } else if (character == 'f') { + character = '\f'; + } else if (character == 'n') { + character = '\n'; + } else if (character == 'r') { + character = '\r'; + } else if (character == 't') { + character = '\t'; + } else { + return 0; + } + } + if (output_length + 1U >= capacity) { + return 0; + } + output[output_length++] = (char)character; + } + return 0; +} + +static int parse_uint32(json_cursor_t* cursor, uint32_t* value) { + skip_space(cursor); + if (cursor->position >= cursor->length || cursor->data[cursor->position] < '0' || + cursor->data[cursor->position] > '9') { + return 0; + } + uint64_t parsed = 0U; + while (cursor->position < cursor->length && cursor->data[cursor->position] >= '0' && + cursor->data[cursor->position] <= '9') { + parsed = parsed * 10U + (uint64_t)(cursor->data[cursor->position] - '0'); + if (parsed > UINT32_MAX) { + return 0; + } + cursor->position++; + } + *value = (uint32_t)parsed; + return 1; +} + +static int payload_field_allowed(maze32_message_type_t type, const char* key, char* value, + maze32_client_message_t* message, unsigned int* fields) { + if (type == MAZE32_MESSAGE_JOIN && strcmp(key, "role") == 0 && !(*fields & 1U)) { + if (strcmp(value, "player") == 0) { + message->role = MAZE32_ROLE_PLAYER; + } else if (strcmp(value, "observer") == 0) { + message->role = MAZE32_ROLE_OBSERVER; + } else { + return 0; + } + *fields |= 1U; + return 1; + } + if (type == MAZE32_MESSAGE_JOIN && strcmp(key, "name") == 0 && !(*fields & 2U)) { + if (strlen(value) > MAZE32_PLAYER_NAME_MAX_BYTES) { + return 0; + } + memcpy(message->name, value, strlen(value) + 1U); + *fields |= 2U; + return 1; + } + if (type == MAZE32_MESSAGE_RESTORE && strcmp(key, "token") == 0 && !(*fields & 1U)) { + if (strlen(value) != MAZE32_SESSION_TOKEN_HEX_LENGTH) { + return 0; + } + memcpy(message->token, value, MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U); + *fields |= 1U; + return 1; + } + return 0; +} + +static int parse_payload(json_cursor_t* cursor, maze32_client_message_t* message) { + if (!consume(cursor, '{')) { + return 0; + } + unsigned int fields = 0U; + skip_space(cursor); + if (cursor->position < cursor->length && cursor->data[cursor->position] == '}') { + cursor->position++; + } else { + for (;;) { + char key[16]; + char value[MAZE32_PLAYER_NAME_MAX_BYTES + 1U]; + if (!parse_string(cursor, key, sizeof(key)) || !consume(cursor, ':') || + !parse_string(cursor, value, sizeof(value)) || + !payload_field_allowed(message->type, key, value, message, &fields)) { + return 0; + } + skip_space(cursor); + if (consume(cursor, '}')) { + break; + } + if (!consume(cursor, ',')) { + return 0; + } + } + } + + if (message->type == MAZE32_MESSAGE_JOIN) { + return message->role == MAZE32_ROLE_PLAYER ? fields == 3U : fields == 1U; + } + if (message->type == MAZE32_MESSAGE_RESTORE) { + return fields == 1U; + } + return fields == 0U; +} + +static maze32_message_type_t message_type(const char* value) { + if (strcmp(value, "join") == 0) { + return MAZE32_MESSAGE_JOIN; + } + if (strcmp(value, "restore") == 0) { + return MAZE32_MESSAGE_RESTORE; + } + if (strcmp(value, "start") == 0) { + return MAZE32_MESSAGE_START; + } + if (strcmp(value, "heartbeat") == 0) { + return MAZE32_MESSAGE_HEARTBEAT; + } + if (strcmp(value, "suggest_name") == 0) { + return MAZE32_MESSAGE_SUGGEST_NAME; + } + return MAZE32_MESSAGE_INVALID; +} + +maze32_protocol_result_t maze32_protocol_parse(const char* data, size_t length, + maze32_client_message_t* message) { + if (data == NULL || message == NULL || length == 0U) { + return MAZE32_PROTOCOL_MALFORMED; + } + if (length > MAZE32_PROTOCOL_MESSAGE_LIMIT) { + return MAZE32_PROTOCOL_TOO_LARGE; + } + memset(message, 0, sizeof(*message)); + json_cursor_t cursor = {.data = data, .length = length, .position = 0U}; + if (!consume(&cursor, '{')) { + return MAZE32_PROTOCOL_MALFORMED; + } + + unsigned int envelope_fields = 0U; + uint32_t version = 0U; + char type_value[24] = ""; + for (;;) { + char key[24]; + if (!parse_string(&cursor, key, sizeof(key)) || !consume(&cursor, ':')) { + return MAZE32_PROTOCOL_MALFORMED; + } + if (strcmp(key, "v") == 0 && !(envelope_fields & 1U)) { + if (!parse_uint32(&cursor, &version)) { + return MAZE32_PROTOCOL_MALFORMED; + } + envelope_fields |= 1U; + } else if (strcmp(key, "type") == 0 && !(envelope_fields & 2U)) { + if (!parse_string(&cursor, type_value, sizeof(type_value))) { + return MAZE32_PROTOCOL_MALFORMED; + } + message->type = message_type(type_value); + envelope_fields |= 2U; + } else if (strcmp(key, "seq") == 0 && !(envelope_fields & 4U)) { + if (!parse_uint32(&cursor, &message->sequence) || message->sequence == 0U) { + return MAZE32_PROTOCOL_MALFORMED; + } + envelope_fields |= 4U; + } else if (strcmp(key, "requestId") == 0 && !(envelope_fields & 8U)) { + if (!parse_string(&cursor, message->request_id, sizeof(message->request_id))) { + return MAZE32_PROTOCOL_MALFORMED; + } + envelope_fields |= 8U; + } else if (strcmp(key, "payload") == 0 && !(envelope_fields & 16U)) { + if (!(envelope_fields & 2U)) { + return MAZE32_PROTOCOL_MALFORMED; + } + if (message->type == MAZE32_MESSAGE_INVALID) { + return MAZE32_PROTOCOL_UNKNOWN_TYPE; + } + if (!parse_payload(&cursor, message)) { + return MAZE32_PROTOCOL_INVALID_PAYLOAD; + } + envelope_fields |= 16U; + } else { + return MAZE32_PROTOCOL_MALFORMED; + } + skip_space(&cursor); + if (consume(&cursor, '}')) { + break; + } + if (!consume(&cursor, ',')) { + return MAZE32_PROTOCOL_MALFORMED; + } + } + skip_space(&cursor); + if (cursor.position != cursor.length || + (envelope_fields & (1U | 2U | 4U | 16U)) != (1U | 2U | 4U | 16U)) { + return MAZE32_PROTOCOL_MALFORMED; + } + if (version != MAZE32_PROTOCOL_VERSION) { + return MAZE32_PROTOCOL_UNSUPPORTED_VERSION; + } + if (message->type == MAZE32_MESSAGE_INVALID) { + return MAZE32_PROTOCOL_UNKNOWN_TYPE; + } + return MAZE32_PROTOCOL_OK; +} + +const char* maze32_protocol_result_name(maze32_protocol_result_t result) { + switch (result) { + case MAZE32_PROTOCOL_OK: + return "ok"; + case MAZE32_PROTOCOL_MALFORMED: + return "malformed"; + case MAZE32_PROTOCOL_TOO_LARGE: + return "too_large"; + case MAZE32_PROTOCOL_UNSUPPORTED_VERSION: + return "unsupported_version"; + case MAZE32_PROTOCOL_UNKNOWN_TYPE: + return "unknown_type"; + case MAZE32_PROTOCOL_INVALID_PAYLOAD: + return "invalid_payload"; + default: + return "unknown_error"; + } +} diff --git a/components/maze32_web/CMakeLists.txt b/components/maze32_web/CMakeLists.txt index 56d6e35..e0e71b8 100644 --- a/components/maze32_web/CMakeLists.txt +++ b/components/maze32_web/CMakeLists.txt @@ -1,5 +1,5 @@ idf_component_register( - SRCS "maze32_web.c" + SRCS "maze32_web.c" "maze32_gateway.c" INCLUDE_DIRS "include" - REQUIRES esp_http_server maze32_platform + REQUIRES esp_http_server esp_timer heap maze32_lobby maze32_platform ) diff --git a/components/maze32_web/include/maze32_gateway.h b/components/maze32_web/include/maze32_gateway.h new file mode 100644 index 0000000..afd4b56 --- /dev/null +++ b/components/maze32_web/include/maze32_gateway.h @@ -0,0 +1,57 @@ +#ifndef MAZE32_GATEWAY_H +#define MAZE32_GATEWAY_H + +#include +#include + +#include "esp_err.h" +#include "esp_http_server.h" +#include "esp_timer.h" +#include "maze32_lobby.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define MAZE32_GATEWAY_CONNECTION_LIMIT MAZE32_SESSION_LIMIT +#define MAZE32_GATEWAY_QUEUE_LIMIT 12U +#define MAZE32_GATEWAY_OUTGOING_LIMIT 768U +#define MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND 20U + +typedef struct { + int active; + int socket_fd; + uint32_t last_client_sequence; + uint64_t rate_window_started_ms; + unsigned int messages_in_window; +} maze32_gateway_connection_t; + +typedef struct { + int socket_fd; + size_t length; + char payload[MAZE32_GATEWAY_OUTGOING_LIMIT]; +} maze32_gateway_outgoing_t; + +typedef struct { + httpd_handle_t server; + maze32_lobby_t lobby; + maze32_gateway_connection_t connections[MAZE32_GATEWAY_CONNECTION_LIMIT]; + maze32_gateway_outgoing_t outgoing[MAZE32_GATEWAY_QUEUE_LIMIT]; + size_t outgoing_head; + size_t outgoing_count; + uint32_t server_sequence; + unsigned int suggestion_index; + esp_timer_handle_t countdown_timer; +} maze32_gateway_t; + +esp_err_t maze32_gateway_init(maze32_gateway_t* gateway); +void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server); +esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway); +void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd); +int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/components/maze32_web/include/maze32_web.h b/components/maze32_web/include/maze32_web.h index bbe4fef..7895385 100644 --- a/components/maze32_web/include/maze32_web.h +++ b/components/maze32_web/include/maze32_web.h @@ -3,13 +3,15 @@ #include "esp_err.h" #include "esp_http_server.h" +#include "maze32_gateway.h" #include "maze32_health.h" #ifdef __cplusplus extern "C" { #endif -esp_err_t maze32_web_start(httpd_handle_t *server, maze32_health_t *health); +esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health, + maze32_gateway_t* gateway); #ifdef __cplusplus } diff --git a/components/maze32_web/maze32_gateway.c b/components/maze32_web/maze32_gateway.c new file mode 100644 index 0000000..37d4b61 --- /dev/null +++ b/components/maze32_web/maze32_gateway.c @@ -0,0 +1,663 @@ +#include "maze32_gateway.h" + +#include +#include +#include +#include +#include + +#include "esp_heap_caps.h" +#include "esp_log.h" +#include "esp_random.h" +#include "maze32_protocol.h" + +static const char* TAG = "maze32_gateway"; +static const char* NAME_SUGGESTIONS[] = { + "Байт-Беглец", "Кот-Навигатор", "Сэр Тупичок", "Пиксельный Ёж", "Лорд Лабиринт", +}; + +static uint64_t now_ms(void) { + return (uint64_t)(esp_timer_get_time() / 1000); +} + +static maze32_gateway_connection_t* find_connection(maze32_gateway_t* gateway, int socket_fd) { + for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { + if (gateway->connections[index].active && + gateway->connections[index].socket_fd == socket_fd) { + return &gateway->connections[index]; + } + } + return NULL; +} + +static maze32_gateway_connection_t* open_connection(maze32_gateway_t* gateway, int socket_fd) { + maze32_gateway_connection_t* existing = find_connection(gateway, socket_fd); + if (existing != NULL) { + return existing; + } + for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { + maze32_gateway_connection_t* connection = &gateway->connections[index]; + if (!connection->active) { + memset(connection, 0, sizeof(*connection)); + connection->active = 1; + connection->socket_fd = socket_fd; + connection->rate_window_started_ms = now_ms(); + unsigned int active_count = 0U; + for (size_t count_index = 0U; count_index < MAZE32_GATEWAY_CONNECTION_LIMIT; + ++count_index) { + if (gateway->connections[count_index].active) { + active_count++; + } + } + ESP_LOGI(TAG, "WebSocket connected: fd=%d, active=%u/%u", socket_fd, active_count, + (unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT); + if (active_count == MAZE32_GATEWAY_CONNECTION_LIMIT) { + ESP_LOGI(TAG, "Six-client heap: free=%u bytes, largest=%u bytes", + (unsigned int)heap_caps_get_free_size(MALLOC_CAP_8BIT), + (unsigned int)heap_caps_get_largest_free_block(MALLOC_CAP_8BIT)); + } + return connection; + } + } + return NULL; +} + +static int append_text(char* output, size_t capacity, size_t* length, const char* text) { + size_t text_length = strlen(text); + if (*length + text_length >= capacity) { + return 0; + } + memcpy(output + *length, text, text_length); + *length += text_length; + output[*length] = '\0'; + return 1; +} + +static int append_json_string(char* output, size_t capacity, size_t* length, const char* value) { + if (!append_text(output, capacity, length, "\"")) { + return 0; + } + for (const unsigned char* cursor = (const unsigned char*)value; *cursor != '\0'; ++cursor) { + const char* escape = NULL; + if (*cursor == '"') { + escape = "\\\""; + } else if (*cursor == '\\') { + escape = "\\\\"; + } else if (*cursor == '\n') { + escape = "\\n"; + } else if (*cursor == '\r') { + escape = "\\r"; + } else if (*cursor == '\t') { + escape = "\\t"; + } + if (escape != NULL) { + if (!append_text(output, capacity, length, escape)) { + return 0; + } + } else { + char byte[2] = {(char)*cursor, '\0'}; + if (!append_text(output, capacity, length, byte)) { + return 0; + } + } + } + return append_text(output, capacity, length, "\""); +} + +static uint32_t next_server_sequence(maze32_gateway_t* gateway) { + gateway->server_sequence++; + if (gateway->server_sequence == 0U) { + gateway->server_sequence = 1U; + } + return gateway->server_sequence; +} + +static int enqueue(maze32_gateway_t* gateway, int socket_fd, const char* payload) { + size_t length = strlen(payload); + if (gateway->outgoing_count >= MAZE32_GATEWAY_QUEUE_LIMIT || + length >= MAZE32_GATEWAY_OUTGOING_LIMIT) { + ESP_LOGW(TAG, "Bounded outgoing queue rejected a message for fd=%d", socket_fd); + return 0; + } + size_t index = (gateway->outgoing_head + gateway->outgoing_count) % MAZE32_GATEWAY_QUEUE_LIMIT; + maze32_gateway_outgoing_t* entry = &gateway->outgoing[index]; + entry->socket_fd = socket_fd; + entry->length = length; + memcpy(entry->payload, payload, length + 1U); + gateway->outgoing_count++; + return 1; +} + +static void drain_queue(maze32_gateway_t* gateway) { + while (gateway->outgoing_count > 0U) { + maze32_gateway_outgoing_t* entry = &gateway->outgoing[gateway->outgoing_head]; + httpd_ws_frame_t frame = { + .final = true, + .fragmented = false, + .type = HTTPD_WS_TYPE_TEXT, + .payload = (uint8_t*)entry->payload, + .len = entry->length, + }; + esp_err_t result = httpd_ws_send_frame_async(gateway->server, entry->socket_fd, &frame); + if (result != ESP_OK) { + ESP_LOGW(TAG, "WebSocket send failed for fd=%d: %s", entry->socket_fd, + esp_err_to_name(result)); + } + gateway->outgoing_head = (gateway->outgoing_head + 1U) % MAZE32_GATEWAY_QUEUE_LIMIT; + gateway->outgoing_count--; + } +} + +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] = ""; + size_t length = 0U; + char prefix[128]; + int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"error\",\"seq\":%" PRIu32, + next_server_sequence(gateway)); + if (written < 0 || (size_t)written >= sizeof(prefix) || + !append_text(response, sizeof(response), &length, prefix)) { + return; + } + if (request_id != NULL && request_id[0] != '\0') { + if (!append_text(response, sizeof(response), &length, ",\"requestId\":") || + !append_json_string(response, sizeof(response), &length, request_id)) { + return; + } + } + if (!append_text(response, sizeof(response), &length, ",\"payload\":{\"code\":") || + !append_json_string(response, sizeof(response), &length, code) || + !append_text(response, sizeof(response), &length, ",\"message\":") || + !append_json_string(response, sizeof(response), &length, message) || + !append_text(response, sizeof(response), &length, "}}")) { + return; + } + enqueue(gateway, socket_fd, response); +} + +static const maze32_session_t* player_for_slot(const maze32_lobby_t* lobby, unsigned int slot) { + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + const maze32_session_t* session = &lobby->sessions[index]; + if (session->role == MAZE32_ROLE_PLAYER && session->player_slot == slot) { + return session; + } + } + return NULL; +} + +static int build_snapshot(maze32_gateway_t* gateway, char* response, size_t capacity) { + uint32_t sequence = next_server_sequence(gateway); + int written = + snprintf(response, capacity, + "{\"v\":1,\"type\":\"lobby\",\"seq\":%" PRIu32 + ",\"payload\":{\"revision\":%" PRIu32 ",\"phase\":\"%s\"," + "\"ready\":%s,\"ownerSlot\":1,\"players\":[", + sequence, gateway->lobby.revision, maze32_lobby_phase_name(gateway->lobby.phase), + maze32_lobby_ready(&gateway->lobby) ? "true" : "false"); + if (written < 0 || (size_t)written >= capacity) { + return 0; + } + size_t length = (size_t)written; + for (unsigned int slot = 1U; slot <= MAZE32_PLAYER_LIMIT; ++slot) { + const maze32_session_t* player = player_for_slot(&gateway->lobby, slot); + char prefix[96]; + written = snprintf(prefix, sizeof(prefix), + "%s{\"slot\":%u,\"name\":", slot == 1U ? "" : ",", slot); + if (written < 0 || (size_t)written >= sizeof(prefix) || + !append_text(response, capacity, &length, prefix) || + !append_json_string(response, capacity, &length, player == NULL ? "" : player->name)) { + return 0; + } + written = snprintf(prefix, sizeof(prefix), ",\"occupied\":%s,\"connected\":%s}", + player == NULL ? "false" : "true", + player != NULL && player->connected ? "true" : "false"); + if (written < 0 || (size_t)written >= sizeof(prefix) || + !append_text(response, capacity, &length, prefix)) { + return 0; + } + } + char suffix[128]; + written = snprintf(suffix, sizeof(suffix), "],\"observers\":%u,\"observerLimit\":%u}}", + maze32_lobby_connected_observers(&gateway->lobby), + (unsigned int)MAZE32_OBSERVER_LIMIT); + return written >= 0 && (size_t)written < sizeof(suffix) && + append_text(response, capacity, &length, suffix); +} + +static void broadcast_snapshot(maze32_gateway_t* gateway) { + char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; + if (!build_snapshot(gateway, response, sizeof(response))) { + ESP_LOGE(TAG, "Lobby snapshot exceeded its fixed buffer"); + return; + } + for (size_t index = 0U; index < MAZE32_GATEWAY_CONNECTION_LIMIT; ++index) { + maze32_gateway_connection_t* connection = &gateway->connections[index]; + if (connection->active && + maze32_lobby_session_for_connection(&gateway->lobby, connection->socket_fd) != NULL) { + enqueue(gateway, connection->socket_fd, response); + } + } +} + +static void send_session(maze32_gateway_t* gateway, int socket_fd, const maze32_session_t* session, + const char* request_id) { + char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; + size_t length = 0U; + char prefix[160]; + int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"session\",\"seq\":%" PRIu32, + next_server_sequence(gateway)); + if (written < 0 || (size_t)written >= sizeof(prefix) || + !append_text(response, sizeof(response), &length, prefix)) { + return; + } + if (request_id[0] != '\0' && + (!append_text(response, sizeof(response), &length, ",\"requestId\":") || + !append_json_string(response, sizeof(response), &length, request_id))) { + return; + } + written = + snprintf(prefix, sizeof(prefix), ",\"payload\":{\"role\":\"%s\",\"slot\":%u,\"token\":", + session->role == MAZE32_ROLE_PLAYER ? "player" : "observer", session->player_slot); + if (written < 0 || (size_t)written >= sizeof(prefix) || + !append_text(response, sizeof(response), &length, prefix) || + !append_json_string(response, sizeof(response), &length, session->token) || + !append_text(response, sizeof(response), &length, "}}")) { + return; + } + enqueue(gateway, socket_fd, response); +} + +static void generate_token(maze32_gateway_t* gateway, char* token) { + static const char HEX[] = "0123456789abcdef"; + uint8_t bytes[MAZE32_SESSION_TOKEN_BYTES]; + for (;;) { + esp_fill_random(bytes, sizeof(bytes)); + for (size_t index = 0U; index < sizeof(bytes); ++index) { + token[index * 2U] = HEX[bytes[index] >> 4U]; + token[index * 2U + 1U] = HEX[bytes[index] & 0x0fU]; + } + token[MAZE32_SESSION_TOKEN_HEX_LENGTH] = '\0'; + int collision = 0; + for (size_t index = 0U; index < MAZE32_SESSION_LIMIT; ++index) { + if (gateway->lobby.sessions[index].role != MAZE32_ROLE_NONE && + strcmp(gateway->lobby.sessions[index].token, token) == 0) { + collision = 1; + break; + } + } + if (!collision) { + return; + } + } +} + +static void send_lobby_error(maze32_gateway_t* gateway, int socket_fd, const char* request_id, + maze32_lobby_result_t result) { + switch (result) { + case MAZE32_LOBBY_INVALID_NAME: + send_error(gateway, socket_fd, request_id, "invalid_name", + "Имя должно быть длиной от 1 до 32 байт без пробелов по краям"); + break; + case MAZE32_LOBBY_DUPLICATE_NAME: + send_error(gateway, socket_fd, request_id, "duplicate_name", "Это имя уже занято"); + break; + case MAZE32_LOBBY_ROLE_FULL: + send_error(gateway, socket_fd, request_id, "role_full", "Свободных мест больше нет"); + break; + case MAZE32_LOBBY_INVALID_TOKEN: + send_error(gateway, socket_fd, request_id, "invalid_token", "Сессия не найдена"); + break; + case MAZE32_LOBBY_SESSION_CONNECTED: + send_error(gateway, socket_fd, request_id, "session_connected", + "Эта сессия уже подключена"); + break; + case MAZE32_LOBBY_NOT_OWNER: + send_error(gateway, socket_fd, request_id, "not_owner", + "Запустить игру может только Игрок 1"); + break; + case MAZE32_LOBBY_NOT_READY: + send_error(gateway, socket_fd, request_id, "not_ready", + "Для старта нужны оба игрока онлайн"); + break; + case MAZE32_LOBBY_INVALID_PHASE: + send_error(gateway, socket_fd, request_id, "invalid_phase", + "Лобби уже запустило обратный отсчёт"); + break; + default: + send_error(gateway, socket_fd, request_id, "request_rejected", "Команда отклонена"); + break; + } +} + +static void send_suggestion(maze32_gateway_t* gateway, int socket_fd, const char* request_id) { + const char* suggestion = NULL; + size_t suggestion_count = sizeof(NAME_SUGGESTIONS) / sizeof(NAME_SUGGESTIONS[0]); + for (size_t attempt = 0U; attempt < suggestion_count; ++attempt) { + size_t index = gateway->suggestion_index++ % suggestion_count; + int occupied = 0; + for (size_t session_index = 0U; session_index < MAZE32_SESSION_LIMIT; ++session_index) { + const maze32_session_t* session = &gateway->lobby.sessions[session_index]; + if (session->role == MAZE32_ROLE_PLAYER && + strcmp(session->name, NAME_SUGGESTIONS[index]) == 0) { + occupied = 1; + break; + } + } + if (!occupied) { + suggestion = NAME_SUGGESTIONS[index]; + break; + } + } + if (suggestion == NULL) { + send_error(gateway, socket_fd, request_id, "no_suggestion", + "Все смешные имена уже разобрали"); + return; + } + char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; + size_t length = 0U; + char prefix[128]; + int written = + snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"name_suggestion\",\"seq\":%" PRIu32, + next_server_sequence(gateway)); + if (written < 0 || (size_t)written >= sizeof(prefix) || + !append_text(response, sizeof(response), &length, prefix)) { + return; + } + if (request_id[0] != '\0' && + (!append_text(response, sizeof(response), &length, ",\"requestId\":") || + !append_json_string(response, sizeof(response), &length, request_id))) { + return; + } + if (append_text(response, sizeof(response), &length, ",\"payload\":{\"name\":") && + append_json_string(response, sizeof(response), &length, suggestion) && + append_text(response, sizeof(response), &length, "}}")) { + enqueue(gateway, socket_fd, response); + } +} + +static void broadcast_countdown(maze32_gateway_t* gateway) { + char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; + int written = snprintf(response, sizeof(response), + "{\"v\":1,\"type\":\"countdown\",\"seq\":%" PRIu32 + ",\"payload\":{\"steps\":[3,2,1],\"startedAtMs\":%" PRIu64 + ",\"activationAtMs\":%" PRIu64 "}}", + next_server_sequence(gateway), gateway->lobby.countdown_started_ms, + gateway->lobby.activation_ms); + 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_PLAYER && session->connected) { + enqueue(gateway, session->connection_id, response); + } + } +} + +static void countdown_finished_work(void* context) { + maze32_gateway_t* gateway = context; + if (!maze32_lobby_activate_if_due(&gateway->lobby, now_ms())) { + return; + } + char response[MAZE32_GATEWAY_OUTGOING_LIMIT]; + int written = snprintf(response, sizeof(response), + "{\"v\":1,\"type\":\"active\",\"seq\":%" PRIu32 + ",\"payload\":{\"activatedAtMs\":%" PRIu64 "}}", + next_server_sequence(gateway), gateway->lobby.activation_ms); + if (written >= 0 && (size_t)written < sizeof(response)) { + 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); + } + } + } + broadcast_snapshot(gateway); + drain_queue(gateway); + ESP_LOGI(TAG, "Lobby became active at server time %" PRIu64 " ms", + gateway->lobby.activation_ms); +} + +static void countdown_timer_callback(void* context) { + maze32_gateway_t* gateway = context; + if (gateway->server != NULL) { + esp_err_t result = httpd_queue_work(gateway->server, countdown_finished_work, gateway); + if (result != ESP_OK) { + ESP_LOGE(TAG, "Could not queue countdown activation: %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; + connection->messages_in_window = 0U; + } + connection->messages_in_window++; + return connection->messages_in_window <= MAZE32_GATEWAY_RATE_LIMIT_PER_SECOND; +} + +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; + if (message->type == MAZE32_MESSAGE_SUGGEST_NAME) { + send_suggestion(gateway, socket_fd, message->request_id); + return; + } + if (message->type == MAZE32_MESSAGE_JOIN) { + if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) { + send_error(gateway, socket_fd, message->request_id, "already_joined", + "Сессия уже выбрала роль"); + return; + } + char token[MAZE32_SESSION_TOKEN_HEX_LENGTH + 1U]; + generate_token(gateway, token); + maze32_session_t* session = NULL; + maze32_lobby_result_t result = + message->role == MAZE32_ROLE_PLAYER + ? maze32_lobby_join_player(&gateway->lobby, socket_fd, message->name, token, + &session) + : maze32_lobby_join_observer(&gateway->lobby, socket_fd, token, &session); + if (result != MAZE32_LOBBY_OK) { + send_lobby_error(gateway, socket_fd, message->request_id, result); + return; + } + send_session(gateway, socket_fd, session, message->request_id); + broadcast_snapshot(gateway); + return; + } + if (message->type == MAZE32_MESSAGE_RESTORE) { + if (maze32_lobby_session_for_connection(&gateway->lobby, socket_fd) != NULL) { + send_error(gateway, socket_fd, message->request_id, "already_joined", + "Сессия уже восстановлена"); + return; + } + maze32_session_t* session = NULL; + maze32_lobby_result_t result = + maze32_lobby_restore(&gateway->lobby, socket_fd, message->token, &session); + if (result != MAZE32_LOBBY_OK) { + send_lobby_error(gateway, socket_fd, message->request_id, result); + return; + } + send_session(gateway, socket_fd, session, message->request_id); + broadcast_snapshot(gateway); + return; + } + + const maze32_session_t* session = + maze32_lobby_session_for_connection(&gateway->lobby, socket_fd); + if (session == NULL) { + send_error(gateway, socket_fd, message->request_id, "unauthorized", + "Сначала выберите роль или восстановите сессию"); + return; + } + if (message->type == MAZE32_MESSAGE_START) { + 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); + return; + } + broadcast_snapshot(gateway); + broadcast_countdown(gateway); + esp_err_t timer_result = + esp_timer_start_once(gateway->countdown_timer, MAZE32_COUNTDOWN_DURATION_MS * 1000U); + if (timer_result != ESP_OK) { + ESP_LOGE(TAG, "Countdown timer could not start: %s", esp_err_to_name(timer_result)); + } + return; + } + if (message->type == MAZE32_MESSAGE_HEARTBEAT) { + char response[MAZE32_GATEWAY_OUTGOING_LIMIT] = ""; + size_t length = 0U; + char prefix[128]; + int written = snprintf(prefix, sizeof(prefix), "{\"v\":1,\"type\":\"ack\",\"seq\":%" PRIu32, + next_server_sequence(gateway)); + if (written >= 0 && (size_t)written < sizeof(prefix) && + append_text(response, sizeof(response), &length, prefix) && + append_text(response, sizeof(response), &length, ",\"requestId\":") && + append_json_string(response, sizeof(response), &length, message->request_id) && + append_text(response, sizeof(response), &length, + ",\"payload\":{\"command\":\"heartbeat\"}}")) { + enqueue(gateway, socket_fd, response); + } + } +} + +static esp_err_t websocket_handler(httpd_req_t* request) { + maze32_gateway_t* gateway = request->user_ctx; + int socket_fd = httpd_req_to_sockfd(request); + maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd); + if (connection == NULL) { + connection = open_connection(gateway, socket_fd); + if (connection == NULL) { + ESP_LOGW(TAG, "WebSocket connection limit reached; rejecting fd=%d", socket_fd); + shutdown(socket_fd, SHUT_RDWR); + return ESP_OK; + } + } + httpd_ws_frame_t frame = {0}; + esp_err_t result = httpd_ws_recv_frame(request, &frame, 0U); + if (result != ESP_OK) { + return result; + } + if (frame.len > MAZE32_PROTOCOL_MESSAGE_LIMIT) { + ESP_LOGW(TAG, "Oversized WebSocket frame rejected: fd=%d, length=%u", socket_fd, + (unsigned int)frame.len); + shutdown(socket_fd, SHUT_RDWR); + return ESP_OK; + } + uint8_t payload[MAZE32_PROTOCOL_MESSAGE_LIMIT + 1U]; + frame.payload = payload; + result = httpd_ws_recv_frame(request, &frame, MAZE32_PROTOCOL_MESSAGE_LIMIT); + if (result != ESP_OK) { + return result; + } + if (frame.type == HTTPD_WS_TYPE_CLOSE) { + return ESP_OK; + } + if (frame.type != HTTPD_WS_TYPE_TEXT) { + send_error(gateway, socket_fd, "", "text_required", "Нужен текстовый JSON-пакет"); + drain_queue(gateway); + return ESP_OK; + } + payload[frame.len] = '\0'; + if (!rate_limit_allows(connection, now_ms())) { + send_error(gateway, socket_fd, "", "rate_limited", "Слишком много команд"); + drain_queue(gateway); + return ESP_OK; + } + + maze32_client_message_t message; + maze32_protocol_result_t parse_result = + maze32_protocol_parse((const char*)payload, frame.len, &message); + if (parse_result != MAZE32_PROTOCOL_OK) { + send_error(gateway, socket_fd, "", maze32_protocol_result_name(parse_result), + "Пакет не прошёл проверку протокола"); + drain_queue(gateway); + return ESP_OK; + } + if (message.sequence <= connection->last_client_sequence) { + send_error(gateway, socket_fd, message.request_id, "stale_sequence", + "Команда устарела или уже обработана"); + drain_queue(gateway); + return ESP_OK; + } + connection->last_client_sequence = message.sequence; + handle_message(gateway, connection, &message); + drain_queue(gateway); + return ESP_OK; +} + +static esp_err_t websocket_pre_handshake(httpd_req_t* request) { + maze32_gateway_t* gateway = request->user_ctx; + int socket_fd = httpd_req_to_sockfd(request); + if (open_connection(gateway, socket_fd) == NULL) { + ESP_LOGW(TAG, "WebSocket connection limit reached; rejecting fd=%d", socket_fd); + return ESP_ERR_NO_MEM; + } + return ESP_OK; +} + +esp_err_t maze32_gateway_init(maze32_gateway_t* gateway) { + if (gateway == NULL) { + return ESP_ERR_INVALID_ARG; + } + memset(gateway, 0, sizeof(*gateway)); + maze32_lobby_init(&gateway->lobby); + const esp_timer_create_args_t timer_arguments = { + .callback = countdown_timer_callback, + .arg = gateway, + .dispatch_method = ESP_TIMER_TASK, + .name = "lobby_countdown", + .skip_unhandled_events = true, + }; + return esp_timer_create(&timer_arguments, &gateway->countdown_timer); +} + +void maze32_gateway_set_server(maze32_gateway_t* gateway, httpd_handle_t server) { + if (gateway != NULL) { + gateway->server = server; + } +} + +esp_err_t maze32_gateway_register(httpd_handle_t server, maze32_gateway_t* gateway) { + if (server == NULL || gateway == NULL) { + return ESP_ERR_INVALID_ARG; + } + maze32_gateway_set_server(gateway, server); + const httpd_uri_t websocket = { + .uri = "/ws", + .method = HTTP_GET, + .handler = websocket_handler, + .user_ctx = gateway, + .is_websocket = true, + .ws_pre_handshake_cb = websocket_pre_handshake, + }; + esp_err_t result = httpd_register_uri_handler(server, &websocket); + if (result == ESP_OK) { + ESP_LOGI(TAG, "WebSocket gateway ready: connections=%u, message=%u, queue=%u", + (unsigned int)MAZE32_GATEWAY_CONNECTION_LIMIT, + (unsigned int)MAZE32_PROTOCOL_MESSAGE_LIMIT, + (unsigned int)MAZE32_GATEWAY_QUEUE_LIMIT); + } + return result; +} + +void maze32_gateway_close_socket(httpd_handle_t server, int socket_fd) { + maze32_gateway_t* gateway = httpd_get_global_user_ctx(server); + if (gateway != NULL) { + maze32_gateway_connection_t* connection = find_connection(gateway, socket_fd); + if (connection != NULL) { + maze32_lobby_disconnect(&gateway->lobby, socket_fd); + memset(connection, 0, sizeof(*connection)); + broadcast_snapshot(gateway); + drain_queue(gateway); + ESP_LOGI(TAG, "WebSocket disconnected: fd=%d", socket_fd); + } + } + close(socket_fd); +} + +int maze32_gateway_can_reconfigure(const maze32_gateway_t* gateway) { + return gateway != NULL && maze32_lobby_can_reconfigure(&gateway->lobby); +} diff --git a/components/maze32_web/maze32_web.c b/components/maze32_web/maze32_web.c index f84ea1d..29b281f 100644 --- a/components/maze32_web/maze32_web.c +++ b/components/maze32_web/maze32_web.c @@ -6,16 +6,19 @@ #define MAZE32_STATIC_CHUNK_SIZE 1024 -static const char *TAG = "maze32_web"; -static const char *INDEX_PATH = "/littlefs/index.html.gz"; +static const char* TAG = "maze32_web"; +static const char* INDEX_PATH = "/littlefs/index.html.gz"; -static esp_err_t send_index(httpd_req_t *request) -{ - FILE *file = fopen(INDEX_PATH, "rb"); +static void preserve_static_context(void* context) { + (void)context; +} + +static esp_err_t send_index(httpd_req_t* request) { + FILE* file = fopen(INDEX_PATH, "rb"); if (file == NULL) { ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH); httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, - "Static application is unavailable"); + "Приложение временно недоступно"); return ESP_FAIL; } @@ -45,18 +48,17 @@ static esp_err_t send_index(httpd_req_t *request) return result; } -static esp_err_t send_health(httpd_req_t *request) -{ - const maze32_health_t *health = request->user_ctx; +static esp_err_t send_health(httpd_req_t* request) { + const maze32_health_t* health = request->user_ctx; char response[192]; int length = snprintf(response, sizeof(response), "{\"status\":\"%s\",\"healthy\":%s,\"version\":\"%s\",\"errorCode\":%d}", maze32_health_state_name(health->state), - maze32_health_is_healthy(health) ? "true" : "false", - health->version, health->error_code); + maze32_health_is_healthy(health) ? "true" : "false", health->version, + health->error_code); if (length < 0 || (size_t)length >= sizeof(response)) { httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR, - "Health response exceeded its fixed buffer"); + "Диагностический ответ слишком большой"); return ESP_FAIL; } @@ -65,16 +67,21 @@ static esp_err_t send_health(httpd_req_t *request) return httpd_resp_send(request, response, length); } -esp_err_t maze32_web_start(httpd_handle_t *server, maze32_health_t *health) -{ - if (server == NULL || health == NULL) { +esp_err_t maze32_web_start(httpd_handle_t* server, maze32_health_t* health, + maze32_gateway_t* gateway) { + if (server == NULL || health == NULL || gateway == NULL) { return ESP_ERR_INVALID_ARG; } httpd_config_t configuration = HTTPD_DEFAULT_CONFIG(); - configuration.max_uri_handlers = 8; - configuration.max_open_sockets = 7; - configuration.lru_purge_enable = true; + configuration.max_uri_handlers = 9; + configuration.max_open_sockets = 9; + configuration.lru_purge_enable = false; + configuration.recv_wait_timeout = 5; + configuration.send_wait_timeout = 1; + configuration.global_user_ctx = gateway; + configuration.global_user_ctx_free_fn = preserve_static_context; + configuration.close_fn = maze32_gateway_close_socket; esp_err_t result = httpd_start(server, &configuration); if (result != ESP_OK) { diff --git a/data/index.html.gz b/data/index.html.gz index 3a0e6dd..49ff6e6 100644 Binary files a/data/index.html.gz and b/data/index.html.gz differ diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..1e13684 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,56 @@ +# maze32 WebSocket protocol v1 + +## Limits and ordering + +- Endpoint: `/ws`. +- At most six WebSocket connections are accepted: two players and four + observers. +- A text frame is at most 512 bytes. Binary frames are rejected. +- Each connection may submit at most 20 messages in a one-second monotonic + window. +- The gateway uses a fixed 12-entry outgoing queue with 768 bytes per entry. +- Client sequence numbers start above zero and strictly increase for the life + of one WebSocket connection. Replayed or decreasing values are rejected. +- Server sequence numbers strictly increase until reboot. Clients discard + stale server messages. + +All messages use this envelope: + +```json +{"v":1,"type":"message_type","seq":1,"requestId":"optional","payload":{}} +``` + +Envelope keys may appear in any order except that `type` must precede +`payload`. Unknown and duplicate keys are rejected. `requestId` is optional and +is only echoed for correlation; it grants no authority. + +## Client messages + +- `join`: `{"role":"player","name":"..."}` or `{"role":"observer"}`. + Player names contain 1–32 UTF-8 bytes, have no control bytes or outer spaces, + and are unique within the current lobby. +- `restore`: `{"token":"32 lowercase hexadecimal characters"}`. +- `suggest_name`: `{}`. +- `start`: `{}`. Only connected Player 1 may use it, and both player slots must + be connected. +- `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 + `token`. The token is never included in a shared snapshot. +- `name_suggestion`: an available humorous Russian player name. +- `lobby`: the authoritative phase, revision, readiness, public player slots, + connected observer count, and limits. +- `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. +- `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. diff --git a/scripts/run_host_tests.sh b/scripts/run_host_tests.sh index d98d4f7..5eed0fd 100755 --- a/scripts/run_host_tests.sh +++ b/scripts/run_host_tests.sh @@ -41,4 +41,13 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \ -o "$test_directory/test_provisioning_protocol" "$test_directory/test_provisioning_protocol" + +cc -std=c11 -Wall -Wextra -Werror -pedantic \ + -I"$project_directory/components/maze32_lobby/include" \ + "$project_directory/components/maze32_lobby/maze32_lobby.c" \ + "$project_directory/components/maze32_lobby/maze32_protocol.c" \ + "$project_directory/test/host/test_lobby_protocol.c" \ + -o "$test_directory/test_lobby_protocol" + +"$test_directory/test_lobby_protocol" python3 "$project_directory/scripts/verify_web_assets.py" diff --git a/scripts/ws_protocol_client.py b/scripts/ws_protocol_client.py new file mode 100644 index 0000000..94ef0ec --- /dev/null +++ b/scripts/ws_protocol_client.py @@ -0,0 +1,237 @@ +#!/usr/bin/env python3 +"""Dependency-free maze32 WebSocket protocol and six-client verifier.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import socket +import struct +import time +from typing import Any + + +class WebSocket: + def __init__(self, host: str, port: int = 80, timeout: float = 8.0): + self.host = host + self.sequence = 0 + self.socket = socket.create_connection((host, port), timeout=timeout) + self.socket.settimeout(timeout) + key = base64.b64encode(os.urandom(16)).decode("ascii") + request = ( + f"GET /ws HTTP/1.1\r\nHost: {host}:{port}\r\nUpgrade: websocket\r\n" + f"Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n" + "Sec-WebSocket-Version: 13\r\n\r\n" + ) + self.socket.sendall(request.encode("ascii")) + response = self._receive_until(b"\r\n\r\n") + if not response.startswith(b"HTTP/1.1 101"): + self.socket.close() + raise RuntimeError(response.decode("utf-8", "replace").strip()) + expected = base64.b64encode( + hashlib.sha1((key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11").encode()).digest() + ) + if expected not in response: + self.socket.close() + raise RuntimeError("WebSocket accept key did not match") + + def _receive_until(self, marker: bytes) -> bytes: + data = bytearray() + while marker not in data: + chunk = self.socket.recv(1024) + if not chunk: + raise ConnectionError("connection closed") + data.extend(chunk) + return bytes(data) + + def send(self, message_type: str, payload: dict[str, Any], request_id: str) -> None: + self.sequence += 1 + message = json.dumps( + {"v": 1, "type": message_type, "seq": self.sequence, + "requestId": request_id, "payload": payload}, + ensure_ascii=False, + separators=(",", ":"), + ).encode("utf-8") + self.send_raw(message) + + def send_raw(self, message: bytes) -> None: + mask = os.urandom(4) + length = len(message) + if length < 126: + header = bytes((0x81, 0x80 | length)) + else: + header = bytes((0x81, 0x80 | 126)) + struct.pack("!H", length) + masked = bytes(value ^ mask[index % 4] for index, value in enumerate(message)) + self.socket.sendall(header + mask + masked) + + def receive(self) -> dict[str, Any]: + first, second = self._read_exact(2) + opcode = first & 0x0F + length = second & 0x7F + if length == 126: + length = struct.unpack("!H", self._read_exact(2))[0] + elif length == 127: + length = struct.unpack("!Q", self._read_exact(8))[0] + if second & 0x80: + mask = self._read_exact(4) + else: + mask = None + payload = self._read_exact(length) + if mask: + payload = bytes(value ^ mask[index % 4] for index, value in enumerate(payload)) + if opcode == 0x8: + raise ConnectionError("WebSocket closed") + if opcode != 0x1: + return self.receive() + return json.loads(payload.decode("utf-8")) + + def receive_type(self, message_type: str, attempts: int = 20) -> dict[str, Any]: + for _ in range(attempts): + message = self.receive() + if message.get("type") == message_type: + return message + raise RuntimeError(f"did not receive {message_type}") + + def _read_exact(self, length: int) -> bytes: + data = bytearray() + while len(data) < length: + chunk = self.socket.recv(length - len(data)) + if not chunk: + raise ConnectionError("connection closed") + data.extend(chunk) + return bytes(data) + + def close(self) -> None: + try: + self.socket.sendall(b"\x88\x80" + b"\0\0\0\0") + except OSError: + pass + self.socket.close() + + +def join(client: WebSocket, role: str, name: str | None = None) -> dict[str, Any]: + payload = {"role": role} + if name is not None: + payload["name"] = name + client.send("join", payload, f"join-{role}") + return client.receive_type("session")["payload"] + + +def verify(host: str) -> None: + clients: list[WebSocket] = [] + try: + player_1 = WebSocket(host) + clients.append(player_1) + player_1.send_raw(b"{") + error = player_1.receive_type("error") + assert error["payload"]["code"] == "malformed" + session_1 = join(player_1, "player", "Тестовый Пилот") + player_1.send_raw( + b'{"v":1,"type":"start","seq":1,"requestId":"stale","payload":{}}' + ) + error = player_1.receive_type("error") + assert error["payload"]["code"] == "stale_sequence" + print("malformed and stale message rejection: PASS") + duplicate = WebSocket(host) + clients.append(duplicate) + duplicate.send("join", {"role": "player", "name": "Тестовый Пилот"}, "duplicate") + error = duplicate.receive_type("error") + assert error["payload"]["code"] == "duplicate_name" + duplicate.send("join", {"role": "player", "name": "Запасной Пилот"}, "player-2") + session_2 = duplicate.receive_type("session")["payload"] + assert session_1["slot"] == 1 and session_2["slot"] == 2 + + observers = [] + for _ in range(4): + observer = WebSocket(host) + clients.append(observer) + join(observer, "observer") + observers.append(observer) + lobby = observers[-1].receive_type("lobby")["payload"] + assert lobby["ready"] and lobby["observers"] == 4 + print("six-client capacity: PASS (2 players + 4 observers)") + print("duplicate player name: PASS") + + try: + excess = WebSocket(host, timeout=1.0) + except (OSError, RuntimeError, ConnectionError): + print("seventh connection rejection: PASS") + else: + try: + excess.send("join", {"role": "observer"}, "excess") + excess.receive() + except (OSError, ConnectionError): + print("seventh connection rejection: PASS") + else: + raise AssertionError("seventh WebSocket unexpectedly remained connected") + finally: + excess.close() + time.sleep(0.5) + + duplicate.send("start", {}, "not-owner") + error = duplicate.receive_type("error") + assert error["payload"]["code"] == "not_owner" + print("Player 2 start authorization: PASS") + + token_2 = session_2["token"] + duplicate.close() + clients.remove(duplicate) + time.sleep(0.15) + restored = WebSocket(host) + clients.append(restored) + restored.send("restore", {"token": token_2}, "restore") + restored_session = restored.receive_type("session")["payload"] + assert restored_session["slot"] == 2 and restored_session["token"] == token_2 + print("session refresh restoration: PASS (Player 2 retained slot 2)") + + player_1.send("start", {}, "owner-start") + countdown_1 = player_1.receive_type("countdown")["payload"] + countdown_2 = restored.receive_type("countdown")["payload"] + assert countdown_1 == countdown_2 + assert countdown_1["steps"] == [3, 2, 1] + active_1 = player_1.receive_type("active")["payload"] + active_2 = restored.receive_type("active")["payload"] + assert active_1["activatedAtMs"] == countdown_1["activationAtMs"] + assert active_1 == active_2 + print( + "authoritative countdown: PASS " + f"(activationAtMs={countdown_1['activationAtMs']})" + ) + + observer = observers.pop() + observer.close() + clients.remove(observer) + time.sleep(0.5) + oversized = WebSocket(host) + oversized.send_raw(b"x" * 513) + try: + error = oversized.receive_type("error") + assert error["payload"]["code"] == "too_large" + except (OSError, ConnectionError): + pass + print("oversized message rejection: PASS") + oversized.close() + finally: + for client in clients: + client.close() + + +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") + arguments = parser.parse_args() + if arguments.verify: + verify(arguments.host) + else: + client = WebSocket(arguments.host) + client.send("suggest_name", {}, "manual") + print(json.dumps(client.receive(), ensure_ascii=False, indent=2)) + client.close() + + +if __name__ == "__main__": + main() diff --git a/sdkconfig.defaults b/sdkconfig.defaults index f93fe83..8863c43 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,4 +1,6 @@ CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y CONFIG_LWIP_MAX_SOCKETS=16 +CONFIG_HTTPD_WS_SUPPORT=y +CONFIG_HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT=y CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y CONFIG_ESP_CONSOLE_SECONDARY_NONE=y diff --git a/sdkconfig.esp32-c6-devkitm-1 b/sdkconfig.esp32-c6-devkitm-1 index 174593e..587d1a6 100644 --- a/sdkconfig.esp32-c6-devkitm-1 +++ b/sdkconfig.esp32-c6-devkitm-1 @@ -1559,12 +1559,14 @@ CONFIG_HTTPD_ERR_RESP_NO_DELAY=y CONFIG_HTTPD_PURGE_BUF_LEN=32 # default: # CONFIG_HTTPD_LOG_PURGE_DATA is not set -# default: -# CONFIG_HTTPD_WS_SUPPORT is not set +CONFIG_HTTPD_WS_SUPPORT=y # default: # CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set # default: CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000 +CONFIG_HTTPD_WS_PRE_HANDSHAKE_CB_SUPPORT=y +# default: +# CONFIG_HTTPD_WS_POST_HANDSHAKE_CB_SUPPORT is not set # end of HTTP Server # diff --git a/src/main.c b/src/main.c index a6e6d6d..28c25d3 100644 --- a/src/main.c +++ b/src/main.c @@ -10,21 +10,21 @@ #endif #include "freertos/FreeRTOS.h" #include "freertos/task.h" -#include "maze32_health.h" #include "maze32_config_store.h" +#include "maze32_health.h" #include "maze32_network.h" #include "maze32_web.h" -static const char *TAG = "maze32"; +static const char* TAG = "maze32"; static maze32_health_t health; static httpd_handle_t web_server; static maze32_network_t network; +static maze32_gateway_t gateway; #ifdef MAZE32_MILESTONE_003_DEVICE_TEST static int device_test_has_persisted_credentials; -static esp_err_t run_device_test_before_network(void) -{ +static esp_err_t run_device_test_before_network(void) { const maze32_wifi_credentials_t expected = { .ssid = "maze32-unavailable-test", .password = "test-only-password", @@ -49,8 +49,7 @@ static esp_err_t run_device_test_before_network(void) return ESP_OK; } -static esp_err_t finish_device_test(void) -{ +static esp_err_t finish_device_test(void) { if (!device_test_has_persisted_credentials) { return ESP_ERR_INVALID_STATE; } @@ -62,15 +61,17 @@ static esp_err_t finish_device_test(void) } #endif -static int prepare_for_network_reconfiguration(void *context) -{ - (void)context; - ESP_LOGW(TAG, "Runtime accepted network reconfiguration; no active match exists"); +static int prepare_for_network_reconfiguration(void* context) { + const maze32_gateway_t* current_gateway = context; + if (!maze32_gateway_can_reconfigure(current_gateway)) { + ESP_LOGW(TAG, "Network reconfiguration rejected while lobby is active"); + return -1; + } + ESP_LOGW(TAG, "Runtime accepted network reconfiguration while lobby is waiting"); return 0; } -static esp_err_t mount_littlefs(void) -{ +static esp_err_t mount_littlefs(void) { const esp_vfs_littlefs_conf_t configuration = { .base_path = "/littlefs", .partition_label = "littlefs", @@ -91,14 +92,13 @@ static esp_err_t mount_littlefs(void) return result; } - ESP_LOGI(TAG, "LittleFS mounted: total=%u bytes, used=%u bytes", - (unsigned int)total_bytes, (unsigned int)used_bytes); + ESP_LOGI(TAG, "LittleFS mounted: total=%u bytes, used=%u bytes", (unsigned int)total_bytes, + (unsigned int)used_bytes); return ESP_OK; } -void app_main(void) -{ - const esp_app_desc_t *application = esp_app_get_description(); +void app_main(void) { + const esp_app_desc_t* application = esp_app_get_description(); maze32_health_init(&health, application->version); vTaskDelay(pdMS_TO_TICKS(1500)); @@ -143,7 +143,14 @@ void app_main(void) return; } - result = maze32_network_init(&network, prepare_for_network_reconfiguration, NULL); + result = maze32_gateway_init(&gateway); + if (result != ESP_OK) { + maze32_health_set(&health, MAZE32_HEALTH_ERROR, (int)result); + ESP_LOGE(TAG, "WebSocket gateway initialization failed: %s", esp_err_to_name(result)); + return; + } + + result = maze32_network_init(&network, prepare_for_network_reconfiguration, &gateway); if (result == ESP_OK) { result = maze32_network_start(&network); } @@ -161,7 +168,10 @@ void app_main(void) } #endif - result = maze32_web_start(&web_server, &health); + result = maze32_web_start(&web_server, &health, &gateway); + if (result == ESP_OK) { + result = maze32_gateway_register(web_server, &gateway); + } if (result == ESP_OK) { result = maze32_provisioning_register(web_server, &network); } diff --git a/test/host/test_lobby_protocol.c b/test/host/test_lobby_protocol.c new file mode 100644 index 0000000..f2055d5 --- /dev/null +++ b/test/host/test_lobby_protocol.c @@ -0,0 +1,102 @@ +#include +#include +#include + +#include "maze32_lobby.h" +#include "maze32_protocol.h" + +static const char* TOKEN_1 = "000102030405060708090a0b0c0d0e0f"; +static const char* TOKEN_2 = "101112131415161718191a1b1c1d1e1f"; +static const char* OBSERVER_TOKENS[] = { + "202122232425262728292a2b2c2d2e2f", "303132333435363738393a3b3c3d3e3f", + "404142434445464748494a4b4c4d4e4f", "505152535455565758595a5b5c5d5e5f", + "606162636465666768696a6b6c6d6e6f", +}; + +static void test_protocol_schema(void) { + maze32_client_message_t message; + const char* join = "{\"v\":1,\"type\":\"join\",\"seq\":1,\"requestId\":\"join-1\"," + "\"payload\":{\"role\":\"player\",\"name\":\"Лорд Лабиринт\"}}"; + assert(maze32_protocol_parse(join, strlen(join), &message) == MAZE32_PROTOCOL_OK); + assert(message.type == MAZE32_MESSAGE_JOIN); + assert(message.sequence == 1U); + assert(message.role == MAZE32_ROLE_PLAYER); + assert(strcmp(message.name, "Лорд Лабиринт") == 0); + + const char* restore = "{\"v\":1,\"type\":\"restore\",\"seq\":2,\"payload\":{\"token\":" + "\"000102030405060708090a0b0c0d0e0f\"}}"; + assert(maze32_protocol_parse(restore, strlen(restore), &message) == MAZE32_PROTOCOL_OK); + assert(message.type == MAZE32_MESSAGE_RESTORE); + assert(strcmp(message.token, TOKEN_1) == 0); + + 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); + const char* unknown = "{\"v\":1,\"type\":\"teleport\",\"seq\":1,\"payload\":{}}"; + assert(maze32_protocol_parse(unknown, strlen(unknown), &message) == + MAZE32_PROTOCOL_UNKNOWN_TYPE); + const char* extra = "{\"v\":1,\"type\":\"start\",\"seq\":1,\"payload\":{\"power\":\"yes\"}}"; + assert(maze32_protocol_parse(extra, strlen(extra), &message) == + MAZE32_PROTOCOL_INVALID_PAYLOAD); + const char* duplicate = "{\"v\":1,\"v\":1,\"type\":\"start\",\"seq\":1,\"payload\":{}}"; + assert(maze32_protocol_parse(duplicate, strlen(duplicate), &message) == + MAZE32_PROTOCOL_MALFORMED); + char oversized[MAZE32_PROTOCOL_MESSAGE_LIMIT + 2U]; + memset(oversized, 'x', sizeof(oversized)); + assert(maze32_protocol_parse(oversized, sizeof(oversized), &message) == + MAZE32_PROTOCOL_TOO_LARGE); +} + +static void test_lobby_limits_restoration_and_authority(void) { + maze32_lobby_t lobby; + maze32_lobby_init(&lobby); + maze32_session_t* player_1 = NULL; + maze32_session_t* player_2 = NULL; + assert(maze32_lobby_join_player(&lobby, 10, "Капитан Клавиша", TOKEN_1, &player_1) == + MAZE32_LOBBY_OK); + assert(player_1->player_slot == 1U); + uint32_t revision = lobby.revision; + assert(maze32_lobby_join_player(&lobby, 11, "Капитан Клавиша", TOKEN_2, &player_2) == + MAZE32_LOBBY_DUPLICATE_NAME); + assert(lobby.revision == revision); + assert(maze32_lobby_join_player(&lobby, 11, "Мышь в доспехах", TOKEN_2, &player_2) == + MAZE32_LOBBY_OK); + assert(player_2->player_slot == 2U); + assert(maze32_lobby_ready(&lobby)); + + for (unsigned int index = 0U; index < MAZE32_OBSERVER_LIMIT; ++index) { + maze32_session_t* observer = NULL; + assert(maze32_lobby_join_observer(&lobby, 20 + (int)index, OBSERVER_TOKENS[index], + &observer) == MAZE32_LOBBY_OK); + assert(observer->role == MAZE32_ROLE_OBSERVER); + } + assert(maze32_lobby_connected_observers(&lobby) == MAZE32_OBSERVER_LIMIT); + maze32_session_t* extra = NULL; + revision = lobby.revision; + assert(maze32_lobby_join_observer(&lobby, 30, OBSERVER_TOKENS[4], &extra) == + MAZE32_LOBBY_ROLE_FULL); + assert(lobby.revision == revision); + + assert(maze32_lobby_start(&lobby, 11, 1000U) == MAZE32_LOBBY_NOT_OWNER); + assert(lobby.phase == MAZE32_LOBBY_WAITING); + maze32_lobby_disconnect(&lobby, 11); + assert(!maze32_lobby_ready(&lobby)); + assert(maze32_lobby_start(&lobby, 10, 1000U) == MAZE32_LOBBY_NOT_READY); + assert(maze32_lobby_restore(&lobby, 31, TOKEN_2, &player_2) == MAZE32_LOBBY_OK); + assert(player_2->player_slot == 2U && player_2->connection_id == 31); + + assert(maze32_lobby_start(&lobby, 10, 2000U) == MAZE32_LOBBY_OK); + assert(lobby.phase == MAZE32_LOBBY_COUNTDOWN); + assert(lobby.activation_ms == 5000U); + assert(!maze32_lobby_can_reconfigure(&lobby)); + assert(!maze32_lobby_activate_if_due(&lobby, 4999U)); + assert(maze32_lobby_activate_if_due(&lobby, 5000U)); + assert(lobby.phase == MAZE32_LOBBY_ACTIVE); +} + +int main(void) { + test_protocol_schema(); + test_lobby_limits_restoration_and_authority(); + puts("lobby and protocol tests: PASS"); + return 0; +} diff --git a/version.txt b/version.txt index 0d91a54..1d0ba9e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -0.3.0 +0.4.0 diff --git a/web/index.html b/web/index.html index 7a05ea1..abe6437 100644 --- a/web/index.html +++ b/web/index.html @@ -1,29 +1,130 @@ - + - maze32 + maze32 — лобби
-

maze32

-

The firmware and static application are running.

-

Device health: checking

+

maze32

Подключаем провода к воздуху…
+
+
+

Игроки

+
P1Свободно●
+
P2Свободно●
+
+
+

Наблюдатели

+

0 / 4 смотрят очень внимательно.

+

Для режима наблюдателя нужен экран не меньше 1024 × 768.

+ +
+
+

Пульт запуска

+
+ + +
+
+
+
+
+
Сервер решает всё. Браузер только красиво моргает.
+

Приготовились!

3

Сервер синхронизирует старт.