feat: implement safe leave, profile reset, and full game reset semantics
This commit is contained in:
@@ -1557,7 +1557,7 @@ At completion, provide the reference sheet, screenshots of the mobile and tablet
|
|||||||
|
|
||||||
## Milestone 021 — Define and implement safe leave, profile reset, and full game reset semantics
|
## Milestone 021 — Define and implement safe leave, profile reset, and full game reset semantics
|
||||||
|
|
||||||
**Status:** `READY`
|
**Status:** `DONE`
|
||||||
**Depends on:** Milestone 020
|
**Depends on:** Milestone 020
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -1646,11 +1646,22 @@ Add host tests covering at least:
|
|||||||
|
|
||||||
When all criteria pass, set Milestone 021 to `DONE`, append its execution record, and change Milestone 022 from `BLOCKED` to `READY`. Do not start Milestone 022 in the same task unless explicitly requested.
|
When all criteria pass, set Milestone 021 to `DONE`, append its execution record, and change Milestone 022 from `BLOCKED` to `READY`. Do not start Milestone 022 in the same task unless explicitly requested.
|
||||||
|
|
||||||
|
### Execution record
|
||||||
|
|
||||||
|
- Date: 2026-08-30
|
||||||
|
- Board model and revision: ESP32-C6FH4 QFN32, revision v0.2.
|
||||||
|
- Toolchain and library versions: PlatformIO Core 6.1.19; `espressif32` 7.0.1; ESP-IDF 6.0.1; `esp_littlefs` 1.20.4.
|
||||||
|
- Result: PASS.
|
||||||
|
- Evidence: Added bounded `POST /api/session/leave`, `POST /api/session/profile-reset`, and player-authorized `POST /api/game/reset` commands. Leave/profile reset release only the authenticated session; an active player departure safely creates a new lobby game identity while preserving other valid sessions. Full reset cancels bot work, clears all sessions, boards, mode, bot state, rematch approvals, match and cumulative statistics, and command queue, then creates a fresh game identity and recovery generation. Invalidated HTTP state polling receives stable `SESSION_INVALIDATED` recovery metadata. Authenticated WebSocket owners receive a minimal `{type:"reset"}` notification without board or token data and are invalidated before any role-safe state can be serialized.
|
||||||
|
- Measurements: Added lifecycle coverage for spectator/player leave and full reset, HTTP coverage for idempotent leave/reset and invalidated polling, and WebSocket coverage for minimal reset notification/invalidation. `make -C test/host run` passed all ten suites. `node --check data/app.js` and the 12 browser/unit tests passed. `pio run -e esp32-c6-devkitm-1 -t buildfs`, `pio run -e esp32-c6-devkitm-1`, and `git diff --check` passed. Firmware uses 39,604 / 327,680 B RAM (12.1%) and 1,020,806 / 2,097,152 B flash (48.7%).
|
||||||
|
- Issues or deviations: No firmware upload or physical multi-client recovery exercise was performed. Milestone 022 owns the browser controls and confirmations; none were added here.
|
||||||
|
- Next action: Milestone 022 is READY. Do not start it unless explicitly requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 022 — Add the always-available recovery menu and child-safe confirmations
|
## Milestone 022 — Add the always-available recovery menu and child-safe confirmations
|
||||||
|
|
||||||
**Status:** `BLOCKED`
|
**Status:** `READY`
|
||||||
**Depends on:** Milestone 021
|
**Depends on:** Milestone 021
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|||||||
+13
-1
@@ -38,7 +38,7 @@ Failure (maximum 160 encoded bytes):
|
|||||||
```
|
```
|
||||||
|
|
||||||
`code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`,
|
`code` is one of `MALFORMED_JSON`, `PAYLOAD_TOO_LARGE`, `INVALID_NAME`,
|
||||||
`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`,
|
`INVALID_ROLE`, `INVALID_MODE`, `INVALID_COORDINATE`, `UNAUTHORIZED`, `SESSION_INVALIDATED`,
|
||||||
`NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`,
|
`NO_PLAYER_SLOT`, `NO_SPECTATOR_SLOT`, `FORBIDDEN_ROLE`, `WRONG_PHASE`,
|
||||||
`NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`.
|
`NOT_YOUR_TURN`, `CELL_ALREADY_SHOT`, `STALE_GAME`, or `SERVER_BUSY`.
|
||||||
`message` is Russian and at most 80 UTF-8 bytes.
|
`message` is Russian and at most 80 UTF-8 bytes.
|
||||||
@@ -51,11 +51,14 @@ Failure (maximum 160 encoded bytes):
|
|||||||
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
|
| `GET /api/health` | 128 B target | diagnostics without secrets, 320 B; includes reset reason |
|
||||||
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
| `POST /api/session/join` | 192 B body | `{name,requestedRole}`; token and role, 192 B |
|
||||||
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
| `POST /api/session/resume` | 96 B body | `{token}`; role and state metadata, 192 B |
|
||||||
|
| `POST /api/session/leave` | 80 B body | `{token,gameId}`; releases only that session |
|
||||||
|
| `POST /api/session/profile-reset` | 80 B body | `{token,gameId}`; server release for local profile reset |
|
||||||
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope |
|
| `POST /api/game/config` | 96 B body | `{token,gameId,mode}`; common envelope |
|
||||||
| `POST /api/game/start` | 80 B body | `{token,gameId}`; common envelope |
|
| `POST /api/game/start` | 80 B body | `{token,gameId}`; common envelope |
|
||||||
| `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; common envelope |
|
| `POST /api/game/shot` | 96 B body | `{token,gameId,x,y}`; common envelope |
|
||||||
| `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope |
|
| `POST /api/game/rematch` | 80 B body | `{token,gameId}`; common envelope |
|
||||||
| `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope |
|
| `POST /api/game/abort` | 80 B body | `{token,gameId}`; common envelope |
|
||||||
|
| `POST /api/game/reset` | 80 B body | `{token,gameId}`; player-only full RAM reset |
|
||||||
| `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B |
|
| `GET /api/state?version=N` | 128 B target | one role-safe state, 768 B |
|
||||||
| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters |
|
| `GET /api/statistics` | 128 B target | role and bounded match/cumulative counters |
|
||||||
|
|
||||||
@@ -63,6 +66,15 @@ The session token is in each POST body. For `GET /api/state`, it is supplied
|
|||||||
in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL
|
in `X-Session-Token`; absence creates a spectator-safe view. It is never a URL
|
||||||
parameter.
|
parameter.
|
||||||
|
|
||||||
|
`leave` and `profile-reset` release only the requesting session; profile data is
|
||||||
|
cleared by the browser in Milestone 022. A player leaving an active match aborts
|
||||||
|
that match and returns remaining valid players to the lobby. `game/reset` is
|
||||||
|
available only to an authenticated player and atomically invalidates every
|
||||||
|
session, clears match and cumulative statistics, and starts a fresh game
|
||||||
|
generation. Successful recovery responses contain `resetReason` (`session_left`,
|
||||||
|
`profile_reset`, or `game_reset`) and `generation`. Invalidated-token state
|
||||||
|
polling returns `SESSION_INVALIDATED` with the same bounded recovery metadata.
|
||||||
|
|
||||||
## Role-safe state event
|
## Role-safe state event
|
||||||
|
|
||||||
The HTTP state response and WebSocket `state` event use this single 768-byte
|
The HTTP state response and WebSocket `state` event use this single 768-byte
|
||||||
|
|||||||
@@ -274,10 +274,13 @@ struct Board {
|
|||||||
| `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных |
|
| `GET` | `/api/info` | Состояние устройства и доступность мест без скрытых данных |
|
||||||
| `POST` | `/api/session/join` | Вход по имени и желаемой роли |
|
| `POST` | `/api/session/join` | Вход по имени и желаемой роли |
|
||||||
| `POST` | `/api/session/resume` | Восстановление роли по токену |
|
| `POST` | `/api/session/resume` | Восстановление роли по токену |
|
||||||
|
| `POST` | `/api/session/leave` | Освобождение только текущей сессии |
|
||||||
|
| `POST` | `/api/session/profile-reset` | Освобождение сессии перед локальной очисткой профиля |
|
||||||
| `POST` | `/api/game/config` | Выбор режима игроком 1 |
|
| `POST` | `/api/game/config` | Выбор режима игроком 1 |
|
||||||
| `POST` | `/api/game/start` | Запуск готовой партии |
|
| `POST` | `/api/game/start` | Запуск готовой партии |
|
||||||
| `POST` | `/api/game/shot` | Выстрел по координатам |
|
| `POST` | `/api/game/shot` | Выстрел по координатам |
|
||||||
| `POST` | `/api/game/rematch` | Подтверждение повторной игры |
|
| `POST` | `/api/game/rematch` | Подтверждение повторной игры |
|
||||||
|
| `POST` | `/api/game/reset` | Аварийный полный сброс игровой памяти игроком |
|
||||||
| `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос |
|
| `GET` | `/api/state?version=N` | Снимок разрешённого состояния и резервный опрос |
|
||||||
| `GET` | `/api/health` | Проверка доступности сервера |
|
| `GET` | `/api/health` | Проверка доступности сервера |
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,24 @@ paths:
|
|||||||
'400': { $ref: '#/components/responses/BadRequest' }
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
'401': { $ref: '#/components/responses/Unauthorized' }
|
'401': { $ref: '#/components/responses/Unauthorized' }
|
||||||
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
/api/session/leave:
|
||||||
|
post:
|
||||||
|
summary: Release only the authenticated session
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
|
||||||
|
responses: &recoveryResponses
|
||||||
|
'200': { $ref: '#/components/responses/RecoveryAccepted' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
|
/api/session/profile-reset:
|
||||||
|
post:
|
||||||
|
summary: Release the authenticated session for a browser-local profile reset
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
|
||||||
|
responses: *recoveryResponses
|
||||||
/api/game/config:
|
/api/game/config:
|
||||||
post:
|
post:
|
||||||
summary: Set the game mode (Player 1, lobby only)
|
summary: Set the game mode (Player 1, lobby only)
|
||||||
@@ -131,6 +149,18 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/GameRequest'
|
$ref: '#/components/schemas/GameRequest'
|
||||||
responses: *commandResponses
|
responses: *commandResponses
|
||||||
|
/api/game/reset:
|
||||||
|
post:
|
||||||
|
summary: Player-only full in-memory game reset
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content: { application/json: { schema: { $ref: '#/components/schemas/GameRequest' } } }
|
||||||
|
responses:
|
||||||
|
'200': { $ref: '#/components/responses/RecoveryAccepted' }
|
||||||
|
'400': { $ref: '#/components/responses/BadRequest' }
|
||||||
|
'403': { $ref: '#/components/responses/Forbidden' }
|
||||||
|
'409': { $ref: '#/components/responses/Conflict' }
|
||||||
|
'413': { $ref: '#/components/responses/PayloadTooLarge' }
|
||||||
/api/state:
|
/api/state:
|
||||||
get:
|
get:
|
||||||
summary: Get a complete role-safe state snapshot
|
summary: Get a complete role-safe state snapshot
|
||||||
@@ -175,6 +205,11 @@ components:
|
|||||||
schema: { $ref: '#/components/schemas/Token' }
|
schema: { $ref: '#/components/schemas/Token' }
|
||||||
description: Omit only when a spectator-safe public view is intended.
|
description: Omit only when a spectator-safe public view is intended.
|
||||||
responses:
|
responses:
|
||||||
|
RecoveryAccepted:
|
||||||
|
description: Recovery completed or an idempotent retry observed the same completed recovery.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/RecoveryAccepted' }
|
||||||
CommandAccepted:
|
CommandAccepted:
|
||||||
description: Command accepted by the authoritative application layer.
|
description: Command accepted by the authoritative application layer.
|
||||||
content:
|
content:
|
||||||
@@ -269,6 +304,13 @@ components:
|
|||||||
- type: object
|
- type: object
|
||||||
required: [role]
|
required: [role]
|
||||||
properties: { role: { $ref: '#/components/schemas/Role' } }
|
properties: { role: { $ref: '#/components/schemas/Role' } }
|
||||||
|
RecoveryAccepted:
|
||||||
|
type: object
|
||||||
|
required: [ok, resetReason, generation]
|
||||||
|
properties:
|
||||||
|
ok: { type: boolean, enum: [true] }
|
||||||
|
resetReason: { type: string, enum: [session_left, profile_reset, game_reset] }
|
||||||
|
generation: { $ref: '#/components/schemas/GameId' }
|
||||||
Info:
|
Info:
|
||||||
type: object
|
type: object
|
||||||
required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable]
|
required: [ok, phase, gameId, version, player1Available, player2Available, player1Name, player2Name, spectatorsAvailable]
|
||||||
|
|||||||
@@ -18,5 +18,7 @@ lifecycle_result_t application_resume(application_t *application,
|
|||||||
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
||||||
bool application_session_for_token(const application_t *application,
|
bool application_session_for_token(const application_t *application,
|
||||||
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
||||||
|
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
|
||||||
|
recovery_reason_t reason);
|
||||||
lifecycle_result_t application_submit(application_t *application, const app_command_t *command);
|
lifecycle_result_t application_submit(application_t *application, const app_command_t *command);
|
||||||
bool application_bot_take_turn(application_t *application);
|
bool application_bot_take_turn(application_t *application);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
#include "game_types.h"
|
#include "game_types.h"
|
||||||
|
|
||||||
typedef uint8_t command_type_t;
|
typedef uint8_t command_type_t;
|
||||||
enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT };
|
enum { COMMAND_CONFIG, COMMAND_START, COMMAND_SHOT, COMMAND_REMATCH, COMMAND_ABORT, COMMAND_LEAVE, COMMAND_PROFILE_RESET, COMMAND_RESET };
|
||||||
typedef struct { command_type_t type; uint8_t session_index; uint32_t game_id; uint32_t version; coordinate_t coordinate; game_mode_t mode; } app_command_t;
|
typedef struct { command_type_t type; uint8_t session_index; uint32_t game_id; uint32_t version; coordinate_t coordinate; game_mode_t mode; } app_command_t;
|
||||||
typedef struct { app_command_t entries[kCommandQueueCapacity]; uint8_t head; uint8_t tail; uint8_t count; } command_queue_t;
|
typedef struct { app_command_t entries[kCommandQueueCapacity]; uint8_t head; uint8_t tail; uint8_t count; } command_queue_t;
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ enum {
|
|||||||
LIFECYCLE_RESULT_GENERATION_FAILED,
|
LIFECYCLE_RESULT_GENERATION_FAILED,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
typedef uint8_t recovery_reason_t;
|
||||||
|
enum { RECOVERY_REASON_NONE, RECOVERY_REASON_SESSION_LEFT, RECOVERY_REASON_PROFILE_RESET, RECOVERY_REASON_GAME_RESET };
|
||||||
|
|
||||||
typedef struct {
|
typedef struct {
|
||||||
game_engine_t game;
|
game_engine_t game;
|
||||||
session_manager_t sessions;
|
session_manager_t sessions;
|
||||||
@@ -34,6 +37,8 @@ typedef struct {
|
|||||||
bool bot_reserved;
|
bool bot_reserved;
|
||||||
bool rematch_confirmed[kPlayerCapacity];
|
bool rematch_confirmed[kPlayerCapacity];
|
||||||
uint32_t next_game_id;
|
uint32_t next_game_id;
|
||||||
|
uint32_t recovery_generation;
|
||||||
|
recovery_reason_t recovery_reason;
|
||||||
} game_lifecycle_t;
|
} game_lifecycle_t;
|
||||||
|
|
||||||
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random);
|
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random);
|
||||||
@@ -44,6 +49,8 @@ lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
|
|||||||
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
const uint8_t token[kSessionTokenBytes], uint8_t *session_index);
|
||||||
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index);
|
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index);
|
||||||
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index);
|
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index);
|
||||||
|
lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
|
||||||
|
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason);
|
||||||
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
|
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
|
||||||
uint32_t game_id, game_mode_t mode);
|
uint32_t game_id, game_mode_t mode);
|
||||||
lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
|
lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
|
||||||
|
|||||||
@@ -13,11 +13,14 @@ typedef enum {
|
|||||||
HTTP_API_ROUTE_HEALTH,
|
HTTP_API_ROUTE_HEALTH,
|
||||||
HTTP_API_ROUTE_JOIN,
|
HTTP_API_ROUTE_JOIN,
|
||||||
HTTP_API_ROUTE_RESUME,
|
HTTP_API_ROUTE_RESUME,
|
||||||
|
HTTP_API_ROUTE_LEAVE,
|
||||||
|
HTTP_API_ROUTE_PROFILE_RESET,
|
||||||
HTTP_API_ROUTE_CONFIG,
|
HTTP_API_ROUTE_CONFIG,
|
||||||
HTTP_API_ROUTE_START,
|
HTTP_API_ROUTE_START,
|
||||||
HTTP_API_ROUTE_SHOT,
|
HTTP_API_ROUTE_SHOT,
|
||||||
HTTP_API_ROUTE_REMATCH,
|
HTTP_API_ROUTE_REMATCH,
|
||||||
HTTP_API_ROUTE_ABORT,
|
HTTP_API_ROUTE_ABORT,
|
||||||
|
HTTP_API_ROUTE_RESET,
|
||||||
HTTP_API_ROUTE_STATE,
|
HTTP_API_ROUTE_STATE,
|
||||||
HTTP_API_ROUTE_STATISTICS,
|
HTTP_API_ROUTE_STATISTICS,
|
||||||
} http_api_route_t;
|
} http_api_route_t;
|
||||||
|
|||||||
@@ -37,6 +37,14 @@ bool application_session_for_token(const application_t *application,
|
|||||||
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
|
return application != NULL && session_manager_find(&application->lifecycle.sessions, token, session_index);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t application_leave(application_t *application, uint8_t session_index, uint32_t game_id,
|
||||||
|
recovery_reason_t reason) {
|
||||||
|
if (application == NULL || application->lifecycle.game.state.game_id != game_id) return LIFECYCLE_RESULT_STALE_GAME;
|
||||||
|
const lifecycle_result_t result = game_lifecycle_leave(&application->lifecycle, session_index);
|
||||||
|
if (result == LIFECYCLE_RESULT_OK) game_lifecycle_set_recovery_reason(&application->lifecycle, reason);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
|
lifecycle_result_t application_submit(application_t *application, const app_command_t *command) {
|
||||||
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
|
if (!application_enqueue(application, command)) return LIFECYCLE_RESULT_GENERATION_FAILED;
|
||||||
app_command_t next = {0};
|
app_command_t next = {0};
|
||||||
@@ -52,6 +60,16 @@ lifecycle_result_t application_submit(application_t *application, const app_comm
|
|||||||
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
|
return game_lifecycle_rematch(&application->lifecycle, next.session_index, next.game_id);
|
||||||
case COMMAND_ABORT:
|
case COMMAND_ABORT:
|
||||||
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
|
return game_lifecycle_abort(&application->lifecycle, next.session_index, next.game_id);
|
||||||
|
case COMMAND_LEAVE:
|
||||||
|
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_SESSION_LEFT);
|
||||||
|
case COMMAND_PROFILE_RESET:
|
||||||
|
return application_leave(application, next.session_index, next.game_id, RECOVERY_REASON_PROFILE_RESET);
|
||||||
|
case COMMAND_RESET:
|
||||||
|
{
|
||||||
|
const lifecycle_result_t result = game_lifecycle_reset(&application->lifecycle, next.session_index, next.game_id);
|
||||||
|
if (result == LIFECYCLE_RESULT_OK) application->command_queue = (command_queue_t){0};
|
||||||
|
return result;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
return LIFECYCLE_RESULT_GENERATION_FAILED;
|
return LIFECYCLE_RESULT_GENERATION_FAILED;
|
||||||
}
|
}
|
||||||
|
|||||||
+45
-2
@@ -68,11 +68,24 @@ static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
|
|||||||
return LIFECYCLE_RESULT_OK;
|
return LIFECYCLE_RESULT_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void return_to_lobby(game_lifecycle_t *lifecycle) {
|
||||||
|
const uint32_t version = lifecycle->game.state.version + 1U;
|
||||||
|
const uint32_t game_id = lifecycle->next_game_id++;
|
||||||
|
game_engine_init(&lifecycle->game);
|
||||||
|
lifecycle->game.state.game_id = game_id;
|
||||||
|
lifecycle->game.state.version = version;
|
||||||
|
lifecycle->bot_reserved = false;
|
||||||
|
lifecycle->rematch_confirmed[0] = false;
|
||||||
|
lifecycle->rematch_confirmed[1] = false;
|
||||||
|
bot_player_cancel_turn(&lifecycle->bot);
|
||||||
|
}
|
||||||
|
|
||||||
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
|
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
|
||||||
if (lifecycle == NULL) return;
|
if (lifecycle == NULL) return;
|
||||||
memset(lifecycle, 0, sizeof(*lifecycle));
|
memset(lifecycle, 0, sizeof(*lifecycle));
|
||||||
lifecycle->random = random;
|
lifecycle->random = random;
|
||||||
lifecycle->next_game_id = 2U;
|
lifecycle->next_game_id = 2U;
|
||||||
|
lifecycle->recovery_generation = 1U;
|
||||||
game_engine_init(&lifecycle->game);
|
game_engine_init(&lifecycle->game);
|
||||||
lifecycle->game.state.game_id = 1U;
|
lifecycle->game.state.game_id = 1U;
|
||||||
session_manager_init(&lifecycle->sessions);
|
session_manager_init(&lifecycle->sessions);
|
||||||
@@ -110,8 +123,38 @@ lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_
|
|||||||
}
|
}
|
||||||
|
|
||||||
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
|
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
|
||||||
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
|
if (lifecycle == NULL || session_index >= kSessionCapacity || !lifecycle->sessions.entries[session_index].occupied) return LIFECYCLE_RESULT_UNAUTHORIZED;
|
||||||
session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase));
|
const bool player = session_index < kPlayerCapacity && lifecycle->sessions.entries[session_index].role == (role_t)session_index;
|
||||||
|
if (player && lifecycle->game.state.phase != PHASE_LOBBY) return_to_lobby(lifecycle);
|
||||||
|
memset(&lifecycle->sessions.entries[session_index], 0, sizeof(session_t));
|
||||||
|
++lifecycle->recovery_generation;
|
||||||
|
lifecycle->recovery_reason = RECOVERY_REASON_SESSION_LEFT;
|
||||||
|
return LIFECYCLE_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_reset(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
|
||||||
|
uint8_t player = 0;
|
||||||
|
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
|
||||||
|
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
|
||||||
|
const uint32_t version = lifecycle->game.state.version + 1U;
|
||||||
|
const uint32_t next_game_id = lifecycle->next_game_id++;
|
||||||
|
const random_source_t random = lifecycle->random;
|
||||||
|
const scheduler_t scheduler = lifecycle->bot_scheduler;
|
||||||
|
bot_player_cancel_turn(&lifecycle->bot);
|
||||||
|
memset(lifecycle, 0, sizeof(*lifecycle));
|
||||||
|
lifecycle->random = random;
|
||||||
|
lifecycle->bot_scheduler = scheduler;
|
||||||
|
lifecycle->next_game_id = next_game_id + 1U;
|
||||||
|
lifecycle->recovery_generation = next_game_id;
|
||||||
|
lifecycle->recovery_reason = RECOVERY_REASON_GAME_RESET;
|
||||||
|
game_engine_init(&lifecycle->game);
|
||||||
|
lifecycle->game.state.game_id = next_game_id;
|
||||||
|
lifecycle->game.state.version = version;
|
||||||
|
return LIFECYCLE_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
void game_lifecycle_set_recovery_reason(game_lifecycle_t *lifecycle, recovery_reason_t reason) {
|
||||||
|
if (lifecycle != NULL) lifecycle->recovery_reason = reason;
|
||||||
}
|
}
|
||||||
|
|
||||||
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
|
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
|
||||||
|
|||||||
+40
-1
@@ -118,6 +118,15 @@ static const char *phase_text(phase_t phase) {
|
|||||||
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
|
return phase <= PHASE_REMATCH_WAIT ? values[phase] : "lobby";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const char *recovery_reason_text(recovery_reason_t reason) {
|
||||||
|
switch (reason) {
|
||||||
|
case RECOVERY_REASON_SESSION_LEFT: return "session_left";
|
||||||
|
case RECOVERY_REASON_PROFILE_RESET: return "profile_reset";
|
||||||
|
case RECOVERY_REASON_GAME_RESET: return "game_reset";
|
||||||
|
default: return "session_invalidated";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
|
static void response_write(http_api_response_t *response, uint16_t status, const char *format, ...) {
|
||||||
va_list arguments;
|
va_list arguments;
|
||||||
va_start(arguments, format);
|
va_start(arguments, format);
|
||||||
@@ -153,6 +162,16 @@ static void response_lifecycle_error(http_api_response_t *response, lifecycle_re
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void response_recovery(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
|
||||||
|
response_write(response, 200U, "{\"ok\":true,\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
|
||||||
|
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void response_invalidated(http_api_response_t *response, const game_lifecycle_t *lifecycle) {
|
||||||
|
response_write(response, 401U, "{\"ok\":false,\"code\":\"SESSION_INVALIDATED\",\"resetReason\":\"%s\",\"generation\":%" PRIu32 "}",
|
||||||
|
recovery_reason_text(lifecycle->recovery_reason), lifecycle->recovery_generation);
|
||||||
|
}
|
||||||
|
|
||||||
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
|
static bool expect_post_body(const http_api_request_t *request, size_t maximum, http_api_response_t *response,
|
||||||
uint32_t version) {
|
uint32_t version) {
|
||||||
if (request->method != HTTP_API_POST || !request->content_type_json) {
|
if (request->method != HTTP_API_POST || !request->content_type_json) {
|
||||||
@@ -289,10 +308,14 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
|
|||||||
uint8_t token[kSessionTokenBytes];
|
uint8_t token[kSessionTokenBytes];
|
||||||
uint8_t session_index = 0;
|
uint8_t session_index = 0;
|
||||||
if (request->session_token != NULL && request->session_token[0] != '\0') {
|
if (request->session_token != NULL && request->session_token[0] != '\0') {
|
||||||
if (!parse_token(request->session_token, token) || !application_session_for_token(api->application, token, &session_index)) {
|
if (!parse_token(request->session_token, token)) {
|
||||||
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
|
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (!application_session_for_token(api->application, token, &session_index)) {
|
||||||
|
response_invalidated(response, lifecycle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
viewer = lifecycle->sessions.entries[session_index].role;
|
viewer = lifecycle->sessions.entries[session_index].role;
|
||||||
}
|
}
|
||||||
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
|
if (!state_presenter_write_lifecycle(lifecycle, viewer, response->body, sizeof(response->body), &response->body_length)) {
|
||||||
@@ -374,6 +397,18 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (!application_session_for_token(api->application, token, &command.session_index)) {
|
if (!application_session_for_token(api->application, token, &command.session_index)) {
|
||||||
|
if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET) {
|
||||||
|
response_recovery(response, lifecycle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (request->route == HTTP_API_ROUTE_RESET && lifecycle->recovery_reason == RECOVERY_REASON_GAME_RESET) {
|
||||||
|
response_recovery(response, lifecycle);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (request->route == HTTP_API_ROUTE_RESET) {
|
||||||
|
response_error(response, 403U, "FORBIDDEN_ROLE", "Роль не может выполнить действие", version);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
|
response_error(response, 401U, "UNAUTHORIZED", "Сессия не найдена", version);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -383,11 +418,15 @@ bool http_api_handle(http_api_t *api, const http_api_request_t *request, http_ap
|
|||||||
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
|
case HTTP_API_ROUTE_SHOT: command.type = COMMAND_SHOT; break;
|
||||||
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
|
case HTTP_API_ROUTE_REMATCH: command.type = COMMAND_REMATCH; break;
|
||||||
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
|
case HTTP_API_ROUTE_ABORT: command.type = COMMAND_ABORT; break;
|
||||||
|
case HTTP_API_ROUTE_LEAVE: command.type = COMMAND_LEAVE; break;
|
||||||
|
case HTTP_API_ROUTE_PROFILE_RESET: command.type = COMMAND_PROFILE_RESET; break;
|
||||||
|
case HTTP_API_ROUTE_RESET: command.type = COMMAND_RESET; break;
|
||||||
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
|
default: response_error(response, 404U, "MALFORMED_JSON", "Маршрут не найден", version); return true;
|
||||||
}
|
}
|
||||||
command.version = version;
|
command.version = version;
|
||||||
const lifecycle_result_t result = application_submit(api->application, &command);
|
const lifecycle_result_t result = application_submit(api->application, &command);
|
||||||
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
|
if (result != LIFECYCLE_RESULT_OK) response_lifecycle_error(response, result, lifecycle->game.state.version);
|
||||||
|
else if (request->route == HTTP_API_ROUTE_LEAVE || request->route == HTTP_API_ROUTE_PROFILE_RESET || request->route == HTTP_API_ROUTE_RESET) response_recovery(response, lifecycle);
|
||||||
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
|
else response_write(response, 200U, "{\"ok\":true,\"version\":%" PRIu32 ",\"gameId\":%" PRIu32 "}",
|
||||||
lifecycle->game.state.version, lifecycle->game.state.game_id);
|
lifecycle->game.state.version, lifecycle->game.state.game_id);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
+7
-1
@@ -167,11 +167,14 @@ static size_t route_body_limit(http_api_route_t route) {
|
|||||||
switch (route) {
|
switch (route) {
|
||||||
case HTTP_API_ROUTE_JOIN: return 192U;
|
case HTTP_API_ROUTE_JOIN: return 192U;
|
||||||
case HTTP_API_ROUTE_RESUME:
|
case HTTP_API_ROUTE_RESUME:
|
||||||
|
case HTTP_API_ROUTE_LEAVE:
|
||||||
|
case HTTP_API_ROUTE_PROFILE_RESET:
|
||||||
case HTTP_API_ROUTE_CONFIG:
|
case HTTP_API_ROUTE_CONFIG:
|
||||||
case HTTP_API_ROUTE_SHOT: return 96U;
|
case HTTP_API_ROUTE_SHOT: return 96U;
|
||||||
case HTTP_API_ROUTE_START:
|
case HTTP_API_ROUTE_START:
|
||||||
case HTTP_API_ROUTE_REMATCH:
|
case HTTP_API_ROUTE_REMATCH:
|
||||||
case HTTP_API_ROUTE_ABORT: return 80U;
|
case HTTP_API_ROUTE_ABORT: return 80U;
|
||||||
|
case HTTP_API_ROUTE_RESET: return 80U;
|
||||||
default: return 0U;
|
default: return 0U;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -363,7 +366,7 @@ static esp_err_t static_file_handler(httpd_req_t *request) {
|
|||||||
|
|
||||||
static esp_err_t start_http_server(void) {
|
static esp_err_t start_http_server(void) {
|
||||||
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
httpd_config_t config = HTTPD_DEFAULT_CONFIG();
|
||||||
config.max_uri_handlers = 16U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
|
config.max_uri_handlers = 18U; config.max_open_sockets = kHttpMaxOpenSockets; config.uri_match_fn = httpd_uri_match_wildcard; config.lru_purge_enable = true;
|
||||||
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
|
ESP_RETURN_ON_ERROR(httpd_start(&s_server, &config), kLogTag, "http server start failed");
|
||||||
const httpd_uri_t routes[] = {
|
const httpd_uri_t routes[] = {
|
||||||
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
|
{.uri = "/", .method = HTTP_GET, .handler = root_handler},
|
||||||
@@ -371,11 +374,14 @@ static esp_err_t start_http_server(void) {
|
|||||||
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
|
{.uri = "/api/health", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_HEALTH},
|
||||||
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
|
{.uri = "/api/session/join", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_JOIN},
|
||||||
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
|
{.uri = "/api/session/resume", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESUME},
|
||||||
|
{.uri = "/api/session/leave", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_LEAVE},
|
||||||
|
{.uri = "/api/session/profile-reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_PROFILE_RESET},
|
||||||
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
|
{.uri = "/api/game/config", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_CONFIG},
|
||||||
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
|
{.uri = "/api/game/start", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_START},
|
||||||
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
|
{.uri = "/api/game/shot", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_SHOT},
|
||||||
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
|
{.uri = "/api/game/rematch", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_REMATCH},
|
||||||
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
|
{.uri = "/api/game/abort", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_ABORT},
|
||||||
|
{.uri = "/api/game/reset", .method = HTTP_POST, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_RESET},
|
||||||
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
|
{.uri = "/api/state", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATE},
|
||||||
{.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS},
|
{.uri = "/api/statistics", .method = HTTP_GET, .handler = api_handler, .user_ctx = (void *)(uintptr_t)HTTP_API_ROUTE_STATISTICS},
|
||||||
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
|
{.uri = "/ws", .method = HTTP_GET, .handler = websocket_handler, .is_websocket = true},
|
||||||
|
|||||||
+17
-1
@@ -21,6 +21,11 @@ static void response_error(char output[kStateMessageCapacity], size_t *output_le
|
|||||||
code, message, version);
|
code, message, version);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static const char *recovery_reason_text(recovery_reason_t reason) {
|
||||||
|
return reason == RECOVERY_REASON_GAME_RESET ? "game_reset" :
|
||||||
|
reason == RECOVERY_REASON_PROFILE_RESET ? "profile_reset" : "session_left";
|
||||||
|
}
|
||||||
|
|
||||||
static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) {
|
static bool token_bytes(const char *text, uint8_t token[kSessionTokenBytes]) {
|
||||||
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
|
if (text == NULL || strlen(text) != kSessionTokenBytes * 2U) return false;
|
||||||
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
|
for (uint8_t index = 0; index < kSessionTokenBytes; ++index) {
|
||||||
@@ -211,9 +216,20 @@ void sync_service_broadcast(sync_service_t *service, sync_send_fn send, void *co
|
|||||||
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
|
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
|
||||||
sync_connection_t *connection = &service->connections[index];
|
sync_connection_t *connection = &service->connections[index];
|
||||||
if (!connection->active || !connection->authenticated) continue;
|
if (!connection->active || !connection->authenticated) continue;
|
||||||
|
const session_t *session = &service->application->lifecycle.sessions.entries[connection->session_index];
|
||||||
|
if (!session->occupied) {
|
||||||
|
char reset[kStateMessageCapacity] = {0};
|
||||||
|
size_t reset_length = 0U;
|
||||||
|
response_write(reset, &reset_length, "{\"type\":\"reset\",\"reason\":\"%s\",\"generation\":%" PRIu32 "}",
|
||||||
|
recovery_reason_text(service->application->lifecycle.recovery_reason),
|
||||||
|
service->application->lifecycle.recovery_generation);
|
||||||
|
(void)send(context, connection->client_id, reset, reset_length);
|
||||||
|
*connection = (sync_connection_t){0};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
char payload[kStateMessageCapacity];
|
char payload[kStateMessageCapacity];
|
||||||
size_t length = 0U;
|
size_t length = 0U;
|
||||||
const role_t role = service->application->lifecycle.sessions.entries[connection->session_index].role;
|
const role_t role = session->role;
|
||||||
if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) ||
|
if (!state_presenter_write_lifecycle(&service->application->lifecycle, role, payload, sizeof(payload), &length) ||
|
||||||
!send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0};
|
!send(context, connection->client_id, payload, length)) *connection = (sync_connection_t){0};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,10 +104,35 @@ static void test_bot_lifecycle_and_stale_actions(void) {
|
|||||||
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
|
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_leave_and_full_reset(void) {
|
||||||
|
test_random_t random = {.value = 23U};
|
||||||
|
game_lifecycle_t lifecycle = new_lifecycle(&random);
|
||||||
|
uint8_t player_1 = 0;
|
||||||
|
uint8_t player_2 = 0;
|
||||||
|
uint8_t spectator = 0;
|
||||||
|
join_players(&lifecycle, &player_1, &player_2);
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &spectator) == LIFECYCLE_RESULT_OK);
|
||||||
|
const uint32_t lobby_game_id = lifecycle.game.state.game_id;
|
||||||
|
assert(game_lifecycle_leave(&lifecycle, spectator) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(!lifecycle.sessions.entries[spectator].occupied && lifecycle.game.state.game_id == lobby_game_id);
|
||||||
|
assert(game_lifecycle_start(&lifecycle, player_1, lobby_game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(game_lifecycle_leave(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(!lifecycle.sessions.entries[player_2].occupied && lifecycle.sessions.entries[player_1].occupied);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != lobby_game_id);
|
||||||
|
const uint32_t reset_game_id = lifecycle.game.state.game_id;
|
||||||
|
lifecycle.cumulative[0].games = 3U;
|
||||||
|
assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_LOBBY && lifecycle.game.state.game_id != reset_game_id);
|
||||||
|
assert(!lifecycle.sessions.entries[0].occupied && !lifecycle.sessions.entries[1].occupied);
|
||||||
|
assert(lifecycle.cumulative[0].games == 0U && lifecycle.recovery_reason == RECOVERY_REASON_GAME_RESET);
|
||||||
|
assert(game_lifecycle_reset(&lifecycle, player_1, reset_game_id) == LIFECYCLE_RESULT_FORBIDDEN_ROLE);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_capacity_sanitize_and_resume();
|
test_capacity_sanitize_and_resume();
|
||||||
test_human_lifecycle_and_guards();
|
test_human_lifecycle_and_guards();
|
||||||
test_bot_lifecycle_and_stale_actions();
|
test_bot_lifecycle_and_stale_actions();
|
||||||
|
test_leave_and_full_reset();
|
||||||
puts("game lifecycle tests passed");
|
puts("game lifecycle tests passed");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,9 +186,36 @@ static void test_sessions_commands_and_state(void) {
|
|||||||
expect_code(&response, 503U, "SERVER_BUSY");
|
expect_code(&response, 503U, "SERVER_BUSY");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_recovery_routes(void) {
|
||||||
|
application_t application;
|
||||||
|
test_random_t random = {.value = 31U};
|
||||||
|
http_api_t api = new_api(&application, &random);
|
||||||
|
http_api_response_t response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
|
||||||
|
char player_token[33]; token_from_response(&response, player_token);
|
||||||
|
char command[96];
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "session_left") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_LEAVE, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "session_left") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_JOIN, HTTP_API_POST,
|
||||||
|
"{\"name\":\"Alice\",\"requestedRole\":\"player1\"}", NULL);
|
||||||
|
token_from_response(&response, player_token);
|
||||||
|
snprintf(command, sizeof(command), "{\"token\":\"%s\",\"gameId\":1}", player_token);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST, command, NULL);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "game_reset") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_STATE, HTTP_API_GET, NULL, player_token);
|
||||||
|
assert(response.status == 401U && strstr(response.body, "SESSION_INVALIDATED") != NULL && strstr(response.body, "game_reset") != NULL);
|
||||||
|
response = call(&api, HTTP_API_ROUTE_RESET, HTTP_API_POST,
|
||||||
|
"{\"token\":\"00000000000000000000000000000000\",\"gameId\":1}", NULL);
|
||||||
|
assert(response.status == 200U && strstr(response.body, "game_reset") != NULL);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_public_routes_and_parse_limits();
|
test_public_routes_and_parse_limits();
|
||||||
test_sessions_commands_and_state();
|
test_sessions_commands_and_state();
|
||||||
|
test_recovery_routes();
|
||||||
puts("http api tests passed");
|
puts("http api tests passed");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -124,9 +124,27 @@ static void test_timeout_ping_and_command(void) {
|
|||||||
assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT);
|
assert(changed && !close && output_length == 0U && application.lifecycle.game.state.mode == MODE_BOT);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static void test_reset_notification_invalidates_authenticated_client(void) {
|
||||||
|
test_random_t random = {.value = 17U};
|
||||||
|
application_t application;
|
||||||
|
application_init(&application, (random_source_t){.next_u32 = next_random, .context = &random});
|
||||||
|
uint8_t player = 0;
|
||||||
|
assert(application_join(&application, ROLE_PLAYER_1, "Alice", &player) == LIFECYCLE_RESULT_OK);
|
||||||
|
sync_service_t service;
|
||||||
|
sync_service_init(&service, &application);
|
||||||
|
assert(sync_service_open(&service, 0, 0U));
|
||||||
|
hello(&service, 0, application.lifecycle.sessions.entries[player].token, 0U);
|
||||||
|
assert(game_lifecycle_reset(&application.lifecycle, player, 1U) == LIFECYCLE_RESULT_OK);
|
||||||
|
capture_t capture = {.fail_client = -1};
|
||||||
|
sync_service_broadcast(&service, capture_send, &capture);
|
||||||
|
assert(capture.sends[0] == 1U && strstr(capture.payloads[0], "\"type\":\"reset\"") != NULL);
|
||||||
|
assert(strstr(capture.payloads[0], "game_reset") != NULL && !service.connections[0].active);
|
||||||
|
}
|
||||||
|
|
||||||
int main(void) {
|
int main(void) {
|
||||||
test_authentication_visibility_and_backpressure();
|
test_authentication_visibility_and_backpressure();
|
||||||
test_timeout_ping_and_command();
|
test_timeout_ping_and_command();
|
||||||
|
test_reset_notification_invalidates_authenticated_client();
|
||||||
puts("sync service tests passed");
|
puts("sync service tests passed");
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user