feat(lobby): implement bounded WebSocket sessions and countdown

This commit is contained in:
2026-09-04 02:13:26 +03:00
parent b10c7f5972
commit 996bda9567
21 changed files with 2040 additions and 64 deletions
+53 -6
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
idf_component_register(
SRCS "maze32_lobby.c" "maze32_protocol.c"
INCLUDE_DIRS "include"
)
@@ -0,0 +1,82 @@
#ifndef MAZE32_LOBBY_H
#define MAZE32_LOBBY_H
#include <stddef.h>
#include <stdint.h>
#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
@@ -0,0 +1,52 @@
#ifndef MAZE32_PROTOCOL_H
#define MAZE32_PROTOCOL_H
#include <stddef.h>
#include <stdint.h>
#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
+263
View File
@@ -0,0 +1,263 @@
#include "maze32_lobby.h"
#include <string.h>
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";
}
}
+280
View File
@@ -0,0 +1,280 @@
#include "maze32_protocol.h"
#include <limits.h>
#include <string.h>
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";
}
}
+2 -2
View File
@@ -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
)
@@ -0,0 +1,57 @@
#ifndef MAZE32_GATEWAY_H
#define MAZE32_GATEWAY_H
#include <stddef.h>
#include <stdint.h>
#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
+3 -1
View File
@@ -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
}
+663
View File
@@ -0,0 +1,663 @@
#include "maze32_gateway.h"
#include <inttypes.h>
#include <stdio.h>
#include <string.h>
#include <sys/socket.h>
#include <unistd.h>
#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);
}
+25 -18
View File
@@ -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) {
Binary file not shown.
+56
View File
@@ -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.
+9
View File
@@ -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"
+237
View File
@@ -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()
+2
View File
@@ -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
+4 -2
View File
@@ -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
#
+29 -19
View File
@@ -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);
}
+102
View File
@@ -0,0 +1,102 @@
#include <assert.h>
#include <stdio.h>
#include <string.h>
#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;
}
+1 -1
View File
@@ -1 +1 @@
0.3.0
0.4.0
+116 -15
View File
@@ -1,29 +1,130 @@
<!doctype html>
<html lang="en">
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>maze32</title>
<title>maze32 — лобби</title>
<style>
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
body { display: grid; min-height: 100vh; margin: 0; place-items: center; background: #10151d; color: #e7edf5; }
main { width: min(32rem, calc(100% - 3rem)); padding: 2rem; border: 1px solid #344255; border-radius: 1rem; background: #18212d; }
h1 { margin-top: 0; letter-spacing: .08em; }
output { color: #79dfa3; }
:root { color-scheme: dark; font-family: ui-monospace, "Cascadia Mono", monospace; --ink:#f5edff; --bg:#090711; --panel:#1d1230; --edge:#65488a; --aqua:#42ead2; --p1:#59a9ff; --p2:#ff73bf; --ok:#72e28b; --warn:#ffd75a; --bad:#ff657a; }
* { box-sizing: border-box; }
body { min-height:100vh; margin:0; padding:max(1rem,env(safe-area-inset-top)) max(1rem,env(safe-area-inset-right)) max(1rem,env(safe-area-inset-bottom)) max(1rem,env(safe-area-inset-left)); background:radial-gradient(circle at top,#24113a,var(--bg) 55%); color:var(--ink); }
main { width:min(70rem,100%); margin:auto; }
header { display:flex; flex-wrap:wrap; align-items:center; justify-content:space-between; gap:.75rem; margin-bottom:1rem; }
h1,h2 { margin:.2rem 0; text-transform:uppercase; letter-spacing:.09em; text-shadow:2px 2px #000; }
h1 { color:var(--aqua); }
.status { padding:.45rem .7rem; border:1px solid var(--edge); background:#10091d; }
.grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(17rem,1fr)); gap:1rem; }
section { min-height:13rem; padding:1rem; border:2px solid var(--edge); box-shadow:5px 5px 0 #050307; background:var(--panel); }
.player { display:grid; grid-template-columns:2.2rem 1fr auto; gap:.7rem; align-items:center; margin:.75rem 0; padding:.75rem; border:1px solid #493563; background:#130c20; }
.p1 { border-left:5px solid var(--p1); } .p2 { border-left:5px solid var(--p2); }
.lamp { color:var(--bad); } .online .lamp { color:var(--ok); }
label { display:block; margin:.8rem 0 .3rem; }
input,button { min-height:2.8rem; border:2px solid var(--aqua); border-radius:0; background:#0c1520; color:var(--ink); font:inherit; }
input { width:100%; padding:.55rem; }
button { padding:.5rem .8rem; cursor:pointer; box-shadow:3px 3px 0 #020506; }
button:hover:not(:disabled) { background:#123a40; } button:active:not(:disabled) { transform:translate(2px,2px); box-shadow:1px 1px 0 #020506; }
button:disabled { opacity:.45; cursor:not-allowed; }
.actions { display:flex; flex-wrap:wrap; gap:.6rem; margin-top:.7rem; }
#notice { min-height:2.8rem; margin-top:1rem; padding:.7rem; border-left:4px solid var(--warn); background:#17101f; }
#notice.error { border-color:var(--bad); color:#ffc0c8; }
#notice.good { border-color:var(--ok); color:#b5ffc4; }
dialog { width:min(28rem,calc(100% - 2rem)); border:3px solid var(--aqua); background:#0b0712ef; color:var(--ink); text-align:center; }
dialog::backdrop { background:#030205d9; }
#count { margin:1rem; color:var(--warn); font-size:clamp(5rem,30vw,12rem); animation:pulse .65s steps(2,end) infinite; }
.observer-warning { display:none; color:var(--warn); }
body.observer-mode .observer-warning { display:block; }
@media (min-width:1024px) and (min-height:768px) { body.observer-mode .observer-warning { display:none; } }
@keyframes pulse { 50% { transform:scale(1.08); } }
</style>
</head>
<body>
<main>
<h1>maze32</h1>
<p>The firmware and static application are running.</p>
<p>Device health: <output id="health">checking</output></p>
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
<div class="grid">
<section>
<h2>Игроки</h2>
<div class="player p1" id="p1"><b>P1</b><span class="name">Свободно</span><span class="lamp">●</span></div>
<div class="player p2" id="p2"><b>P2</b><span class="name">Свободно</span><span class="lamp">●</span></div>
</section>
<section>
<h2>Наблюдатели</h2>
<p><strong id="observers">0</strong> / 4 смотрят очень внимательно.</p>
<p class="observer-warning">Для режима наблюдателя нужен экран не меньше 1024 × 768.</p>
<button id="observe">Смотреть лабиринт</button>
</section>
<section>
<h2>Пульт запуска</h2>
<div id="join">
<label for="name">Позывной игрока</label>
<input id="name" maxlength="32" autocomplete="nickname" placeholder="Например, Байт-Беглец">
<div class="actions"><button id="suggest">Подкинуть имя</button><button id="play">Занять слот</button></div>
</div>
<div class="actions"><button id="start" disabled>Начать 3-2-1</button><a href="/provision"><button>Wi‑Fi</button></a></div>
</section>
</div>
<div id="notice" role="status">Сервер решает всё. Браузер только красиво моргает.</div>
</main>
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
<script>
const output = document.querySelector('#health');
fetch('/health', { cache: 'no-store' })
.then(response => response.ok ? response.json() : Promise.reject(new Error('Health request failed')))
.then(health => { output.textContent = `${health.status} · version ${health.version}`; })
.catch(() => { output.textContent = 'unavailable'; });
(() => {
const $ = id => document.getElementById(id);
const state = { socket:null, clientSeq:0, serverSeq:0, session:null, lobby:null, reconnect:null };
const tokenKey = 'maze32.session.v1';
const notice = (text, kind='') => { $('notice').textContent=text; $('notice').className=kind; };
const send = (type,payload={},requestId=type) => {
if (!state.socket || state.socket.readyState !== WebSocket.OPEN) return;
state.socket.send(JSON.stringify({v:1,type,seq:++state.clientSeq,requestId,payload}));
};
const render = () => {
if (!state.lobby) return;
state.lobby.players.forEach(player => {
const row = $('p'+player.slot);
row.querySelector('.name').textContent = player.occupied ? player.name : 'Свободно';
row.classList.toggle('online',player.connected);
});
$('observers').textContent=state.lobby.observers;
$('join').hidden=!!state.session;
$('observe').hidden=!!state.session;
const owner=state.session?.role==='player' && state.session.slot===1;
$('start').disabled=!(owner && state.lobby.ready && state.lobby.phase==='waiting');
document.body.classList.toggle('observer-mode',state.session?.role==='observer');
};
const countdown = () => {
const dialog=$('overlay'); $('overlay-title').textContent='Приготовились!'; $('overlay-text').textContent='Один сервер — один момент старта.';
if (!dialog.open) dialog.showModal();
let value=3; $('count').textContent=value;
const timer=setInterval(() => { value--; if (value>0) $('count').textContent=value; else clearInterval(timer); },1000);
};
const handle = event => {
let message; try { message=JSON.parse(event.data); } catch { notice('Сервер прислал нечитаемый пакет.','error'); return; }
if (message.v!==1 || !Number.isInteger(message.seq) || message.seq<=state.serverSeq) return;
state.serverSeq=message.seq;
if (message.type==='session') {
state.session=message.payload; localStorage.setItem(tokenKey,message.payload.token);
notice(message.payload.role==='player' ? `Слот P${message.payload.slot} закреплён. Токен спрятан в этом браузере.` : 'Вы в зрительном зале. Руками не трогаем!','good');
} else if (message.type==='lobby') { state.lobby=message.payload; render();
} else if (message.type==='name_suggestion') { $('name').value=message.payload.name;
} else if (message.type==='countdown') { countdown();
} else if (message.type==='active') { $('count').textContent='GO'; $('overlay-title').textContent='Старт!'; $('overlay-text').textContent='Лобби активировано. Лабиринт появится в следующем этапе.'; setTimeout(()=>$('overlay').close(),1200);
} else if (message.type==='error') {
if (message.payload.code==='invalid_token') { localStorage.removeItem(tokenKey); state.session=null; render(); }
notice(message.payload.message || 'Команда отклонена сервером.','error');
}
};
const connect = () => {
clearTimeout(state.reconnect); state.clientSeq=0; state.serverSeq=0;
const scheme=location.protocol==='https:'?'wss':'ws'; state.socket=new WebSocket(`${scheme}://${location.host}/ws`);
state.socket.onopen=() => { $('connection').textContent='Связь с ESP: есть'; const token=localStorage.getItem(tokenKey); if(token) send('restore',{token},'refresh'); };
state.socket.onmessage=handle;
state.socket.onclose=() => { $('connection').textContent='Связь потеряна, переподключаемся…'; state.reconnect=setTimeout(connect,1000); };
state.socket.onerror=() => notice('WebSocket споткнулся. Пробуем снова…','error');
};
$('suggest').onclick=()=>send('suggest_name',{},'suggest');
$('play').onclick=()=>send('join',{role:'player',name:$('name').value},'join-player');
$('observe').onclick=()=>send('join',{role:'observer'},'join-observer');
$('start').onclick=()=>send('start',{},'start');
connect();
})();
</script>
</body>
</html>