feat(lobby): implement bounded WebSocket sessions and countdown
This commit is contained in:
@@ -274,12 +274,11 @@ access-point provisioning flow.
|
|||||||
invalid two-field configuration request returned HTTP 400. A final board
|
invalid two-field configuration request returned HTTP 400. A final board
|
||||||
reboot again loaded and connected to `Mazda7`, proving the rejected request
|
reboot again loaded and connected to `Mazda7`, proving the rejected request
|
||||||
did not alter the persisted valid configuration.
|
did not alter the persisted valid configuration.
|
||||||
- All five acceptance criteria therefore have recorded evidence. Milestone 004
|
- All five acceptance criteria therefore have recorded evidence.
|
||||||
remains `NOT_STARTED`.
|
|
||||||
|
|
||||||
## Milestone 004 — HTTP, WebSocket, sessions, and lobby
|
## Milestone 004 — HTTP, WebSocket, sessions, and lobby
|
||||||
|
|
||||||
**Status:** `NOT_STARTED`
|
**Status:** `COMPLETE`
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|
||||||
@@ -313,9 +312,57 @@ observers, and an owner-controlled lobby.
|
|||||||
|
|
||||||
### Evidence
|
### Evidence
|
||||||
|
|
||||||
Completion evidence is not yet available because this milestone has not
|
- On 2026-09-04, `./scripts/run_host_tests.sh` reported
|
||||||
started. Required evidence includes protocol-test output, six-client connection
|
`health state tests: PASS`, `wifi configuration tests: PASS`,
|
||||||
results, session restoration tests, and lobby authorization tests.
|
`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
|
## Milestone 005 — Symmetric maze generation and validation
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "maze32_web.c"
|
SRCS "maze32_web.c" "maze32_gateway.c"
|
||||||
INCLUDE_DIRS "include"
|
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,13 +3,15 @@
|
|||||||
|
|
||||||
#include "esp_err.h"
|
#include "esp_err.h"
|
||||||
#include "esp_http_server.h"
|
#include "esp_http_server.h"
|
||||||
|
#include "maze32_gateway.h"
|
||||||
#include "maze32_health.h"
|
#include "maze32_health.h"
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
extern "C" {
|
extern "C" {
|
||||||
#endif
|
#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
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -9,13 +9,16 @@
|
|||||||
static const char* TAG = "maze32_web";
|
static const char* TAG = "maze32_web";
|
||||||
static const char* INDEX_PATH = "/littlefs/index.html.gz";
|
static const char* INDEX_PATH = "/littlefs/index.html.gz";
|
||||||
|
|
||||||
static esp_err_t send_index(httpd_req_t *request)
|
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");
|
FILE* file = fopen(INDEX_PATH, "rb");
|
||||||
if (file == NULL) {
|
if (file == NULL) {
|
||||||
ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH);
|
ESP_LOGE(TAG, "Static asset is unavailable: %s", INDEX_PATH);
|
||||||
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||||
"Static application is unavailable");
|
"Приложение временно недоступно");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,18 +48,17 @@ static esp_err_t send_index(httpd_req_t *request)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t send_health(httpd_req_t *request)
|
static esp_err_t send_health(httpd_req_t* request) {
|
||||||
{
|
|
||||||
const maze32_health_t* health = request->user_ctx;
|
const maze32_health_t* health = request->user_ctx;
|
||||||
char response[192];
|
char response[192];
|
||||||
int length = snprintf(response, sizeof(response),
|
int length = snprintf(response, sizeof(response),
|
||||||
"{\"status\":\"%s\",\"healthy\":%s,\"version\":\"%s\",\"errorCode\":%d}",
|
"{\"status\":\"%s\",\"healthy\":%s,\"version\":\"%s\",\"errorCode\":%d}",
|
||||||
maze32_health_state_name(health->state),
|
maze32_health_state_name(health->state),
|
||||||
maze32_health_is_healthy(health) ? "true" : "false",
|
maze32_health_is_healthy(health) ? "true" : "false", health->version,
|
||||||
health->version, health->error_code);
|
health->error_code);
|
||||||
if (length < 0 || (size_t)length >= sizeof(response)) {
|
if (length < 0 || (size_t)length >= sizeof(response)) {
|
||||||
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
httpd_resp_send_err(request, HTTPD_500_INTERNAL_SERVER_ERROR,
|
||||||
"Health response exceeded its fixed buffer");
|
"Диагностический ответ слишком большой");
|
||||||
return ESP_FAIL;
|
return ESP_FAIL;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,16 +67,21 @@ static esp_err_t send_health(httpd_req_t *request)
|
|||||||
return httpd_resp_send(request, response, length);
|
return httpd_resp_send(request, response, length);
|
||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
if (server == NULL || health == NULL) {
|
if (server == NULL || health == NULL || gateway == NULL) {
|
||||||
return ESP_ERR_INVALID_ARG;
|
return ESP_ERR_INVALID_ARG;
|
||||||
}
|
}
|
||||||
|
|
||||||
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t configuration = HTTPD_DEFAULT_CONFIG();
|
||||||
configuration.max_uri_handlers = 8;
|
configuration.max_uri_handlers = 9;
|
||||||
configuration.max_open_sockets = 7;
|
configuration.max_open_sockets = 9;
|
||||||
configuration.lru_purge_enable = true;
|
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);
|
esp_err_t result = httpd_start(server, &configuration);
|
||||||
if (result != ESP_OK) {
|
if (result != ESP_OK) {
|
||||||
|
|||||||
Binary file not shown.
@@ -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.
|
||||||
@@ -41,4 +41,13 @@ cc -std=c11 -Wall -Wextra -Werror -pedantic \
|
|||||||
-o "$test_directory/test_provisioning_protocol"
|
-o "$test_directory/test_provisioning_protocol"
|
||||||
|
|
||||||
"$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"
|
python3 "$project_directory/scripts/verify_web_assets.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()
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y
|
||||||
CONFIG_LWIP_MAX_SOCKETS=16
|
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_USB_SERIAL_JTAG=y
|
||||||
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
CONFIG_ESP_CONSOLE_SECONDARY_NONE=y
|
||||||
|
|||||||
@@ -1559,12 +1559,14 @@ CONFIG_HTTPD_ERR_RESP_NO_DELAY=y
|
|||||||
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
CONFIG_HTTPD_PURGE_BUF_LEN=32
|
||||||
# default:
|
# default:
|
||||||
# CONFIG_HTTPD_LOG_PURGE_DATA is not set
|
# CONFIG_HTTPD_LOG_PURGE_DATA is not set
|
||||||
# default:
|
CONFIG_HTTPD_WS_SUPPORT=y
|
||||||
# CONFIG_HTTPD_WS_SUPPORT is not set
|
|
||||||
# default:
|
# default:
|
||||||
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
# CONFIG_HTTPD_QUEUE_WORK_BLOCKING is not set
|
||||||
# default:
|
# default:
|
||||||
CONFIG_HTTPD_SERVER_EVENT_POST_TIMEOUT=2000
|
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
|
# end of HTTP Server
|
||||||
|
|
||||||
#
|
#
|
||||||
|
|||||||
+27
-17
@@ -10,8 +10,8 @@
|
|||||||
#endif
|
#endif
|
||||||
#include "freertos/FreeRTOS.h"
|
#include "freertos/FreeRTOS.h"
|
||||||
#include "freertos/task.h"
|
#include "freertos/task.h"
|
||||||
#include "maze32_health.h"
|
|
||||||
#include "maze32_config_store.h"
|
#include "maze32_config_store.h"
|
||||||
|
#include "maze32_health.h"
|
||||||
#include "maze32_network.h"
|
#include "maze32_network.h"
|
||||||
#include "maze32_web.h"
|
#include "maze32_web.h"
|
||||||
|
|
||||||
@@ -19,12 +19,12 @@ static const char *TAG = "maze32";
|
|||||||
static maze32_health_t health;
|
static maze32_health_t health;
|
||||||
static httpd_handle_t web_server;
|
static httpd_handle_t web_server;
|
||||||
static maze32_network_t network;
|
static maze32_network_t network;
|
||||||
|
static maze32_gateway_t gateway;
|
||||||
|
|
||||||
#ifdef MAZE32_MILESTONE_003_DEVICE_TEST
|
#ifdef MAZE32_MILESTONE_003_DEVICE_TEST
|
||||||
static int device_test_has_persisted_credentials;
|
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 = {
|
const maze32_wifi_credentials_t expected = {
|
||||||
.ssid = "maze32-unavailable-test",
|
.ssid = "maze32-unavailable-test",
|
||||||
.password = "test-only-password",
|
.password = "test-only-password",
|
||||||
@@ -49,8 +49,7 @@ static esp_err_t run_device_test_before_network(void)
|
|||||||
return ESP_OK;
|
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) {
|
if (!device_test_has_persisted_credentials) {
|
||||||
return ESP_ERR_INVALID_STATE;
|
return ESP_ERR_INVALID_STATE;
|
||||||
}
|
}
|
||||||
@@ -62,15 +61,17 @@ static esp_err_t finish_device_test(void)
|
|||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
static int prepare_for_network_reconfiguration(void *context)
|
static int prepare_for_network_reconfiguration(void* context) {
|
||||||
{
|
const maze32_gateway_t* current_gateway = context;
|
||||||
(void)context;
|
if (!maze32_gateway_can_reconfigure(current_gateway)) {
|
||||||
ESP_LOGW(TAG, "Runtime accepted network reconfiguration; no active match exists");
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static esp_err_t mount_littlefs(void)
|
static esp_err_t mount_littlefs(void) {
|
||||||
{
|
|
||||||
const esp_vfs_littlefs_conf_t configuration = {
|
const esp_vfs_littlefs_conf_t configuration = {
|
||||||
.base_path = "/littlefs",
|
.base_path = "/littlefs",
|
||||||
.partition_label = "littlefs",
|
.partition_label = "littlefs",
|
||||||
@@ -91,13 +92,12 @@ static esp_err_t mount_littlefs(void)
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
ESP_LOGI(TAG, "LittleFS mounted: total=%u bytes, used=%u bytes",
|
ESP_LOGI(TAG, "LittleFS mounted: total=%u bytes, used=%u bytes", (unsigned int)total_bytes,
|
||||||
(unsigned int)total_bytes, (unsigned int)used_bytes);
|
(unsigned int)used_bytes);
|
||||||
return ESP_OK;
|
return ESP_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
void app_main(void)
|
void app_main(void) {
|
||||||
{
|
|
||||||
const esp_app_desc_t* application = esp_app_get_description();
|
const esp_app_desc_t* application = esp_app_get_description();
|
||||||
maze32_health_init(&health, application->version);
|
maze32_health_init(&health, application->version);
|
||||||
|
|
||||||
@@ -143,7 +143,14 @@ void app_main(void)
|
|||||||
return;
|
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) {
|
if (result == ESP_OK) {
|
||||||
result = maze32_network_start(&network);
|
result = maze32_network_start(&network);
|
||||||
}
|
}
|
||||||
@@ -161,7 +168,10 @@ void app_main(void)
|
|||||||
}
|
}
|
||||||
#endif
|
#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) {
|
if (result == ESP_OK) {
|
||||||
result = maze32_provisioning_register(web_server, &network);
|
result = maze32_provisioning_register(web_server, &network);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -1 +1 @@
|
|||||||
0.3.0
|
0.4.0
|
||||||
|
|||||||
+116
-15
@@ -1,29 +1,130 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
<title>maze32</title>
|
<title>maze32 — лобби</title>
|
||||||
<style>
|
<style>
|
||||||
:root { color-scheme: dark; font-family: system-ui, sans-serif; }
|
: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; }
|
||||||
body { display: grid; min-height: 100vh; margin: 0; place-items: center; background: #10151d; color: #e7edf5; }
|
* { box-sizing: border-box; }
|
||||||
main { width: min(32rem, calc(100% - 3rem)); padding: 2rem; border: 1px solid #344255; border-radius: 1rem; background: #18212d; }
|
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); }
|
||||||
h1 { margin-top: 0; letter-spacing: .08em; }
|
main { width:min(70rem,100%); margin:auto; }
|
||||||
output { color: #79dfa3; }
|
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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
<h1>maze32</h1>
|
<header><h1>maze32</h1><span class="status" id="connection">Подключаем провода к воздуху…</span></header>
|
||||||
<p>The firmware and static application are running.</p>
|
<div class="grid">
|
||||||
<p>Device health: <output id="health">checking</output></p>
|
<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>
|
</main>
|
||||||
|
<dialog id="overlay"><h2 id="overlay-title">Приготовились!</h2><div id="count">3</div><p id="overlay-text">Сервер синхронизирует старт.</p></dialog>
|
||||||
<script>
|
<script>
|
||||||
const output = document.querySelector('#health');
|
(() => {
|
||||||
fetch('/health', { cache: 'no-store' })
|
const $ = id => document.getElementById(id);
|
||||||
.then(response => response.ok ? response.json() : Promise.reject(new Error('Health request failed')))
|
const state = { socket:null, clientSeq:0, serverSeq:0, session:null, lobby:null, reconnect:null };
|
||||||
.then(health => { output.textContent = `${health.status} · version ${health.version}`; })
|
const tokenKey = 'maze32.session.v1';
|
||||||
.catch(() => { output.textContent = 'unavailable'; });
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user