feat: implement sessions, lobby, roles, and rematch lifecycle
This commit is contained in:
@@ -521,7 +521,7 @@ If all criteria pass, set this milestone to `DONE`, append its execution record,
|
|||||||
|
|
||||||
## Milestone 009 — Implement sessions, lobby, roles, and rematch lifecycle
|
## Milestone 009 — Implement sessions, lobby, roles, and rematch lifecycle
|
||||||
|
|
||||||
**Status:** `READY`
|
**Status:** `DONE`
|
||||||
**Depends on:** Milestone 008
|
**Depends on:** Milestone 008
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
@@ -549,11 +549,22 @@ Implement the complete in-memory application state machine and bounded client/se
|
|||||||
|
|
||||||
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 010 from `BLOCKED` to `READY`.
|
If all criteria pass, set this milestone to `DONE`, append its execution record, and change Milestone 010 from `BLOCKED` to `READY`.
|
||||||
|
|
||||||
|
### Execution record
|
||||||
|
|
||||||
|
- Date: 2026-08-28
|
||||||
|
- 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 fixed ten-entry session storage, bounded UTF-8 name validation, opaque 16-byte tokens, resume, disconnect, leave, role/capacity checks, and a transport-independent lifecycle owner. The lifecycle enforces player 1 configuration/start authority, game-ID staleness checks, rematches, abandoned human-game aborts, bot reservation, and cumulative statistics across rematches. Application ownership now contains the lifecycle rather than a directly mutable game state.
|
||||||
|
- Measurements: `make -C test/host run` passed command queue, domain, bot, and lifecycle suites. Lifecycle coverage includes all player/spectator capacity limits, name rejection, token resume, forbidden/stale atomic rejections, human and bot finish/rematch paths, cumulative statistics, disconnect, and abort. Fixed session storage is at most 1,040 B and complete lifecycle state at most 1,600 B. `pio run -e esp32-c6-devkitm-1` passed with 38,212 / 327,680 B RAM (11.7%) and 1,000,730 / 2,097,152 B flash (47.7%), within the Milestone 005 limits.
|
||||||
|
- Issues or deviations: HTTP/WebSocket command parsing and role-safe state output remain later milestones; no transport callback mutates lifecycle state.
|
||||||
|
- Next action: Milestone 010 is ready. Do not start it unless explicitly requested.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Milestone 010 — Implement safe state presentation and bounded serialization
|
## Milestone 010 — Implement safe state presentation and bounded serialization
|
||||||
|
|
||||||
**Status:** `BLOCKED`
|
**Status:** `READY`
|
||||||
**Depends on:** Milestone 009
|
**Depends on:** Milestone 009
|
||||||
|
|
||||||
### Objective
|
### Objective
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
#include <stdbool.h>
|
#include <stdbool.h>
|
||||||
|
|
||||||
#include "command_queue.h"
|
#include "command_queue.h"
|
||||||
|
#include "game_lifecycle.h"
|
||||||
|
|
||||||
/* The application task is the sole owner of game_state_t mutations. */
|
/* The application task is the sole owner of lifecycle and game mutations. */
|
||||||
typedef struct { command_queue_t command_queue; game_state_t state; } application_t;
|
typedef struct { command_queue_t command_queue; game_lifecycle_t lifecycle; } application_t;
|
||||||
|
|
||||||
bool application_enqueue(application_t *application, const app_command_t *command);
|
bool application_enqueue(application_t *application, const app_command_t *command);
|
||||||
bool application_take_next_command(application_t *application, app_command_t *command);
|
bool application_take_next_command(application_t *application, app_command_t *command);
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include "fleet_generator.h"
|
||||||
|
#include "game_engine.h"
|
||||||
|
#include "session_manager.h"
|
||||||
|
#include "statistics.h"
|
||||||
|
|
||||||
|
typedef uint8_t lifecycle_result_t;
|
||||||
|
enum {
|
||||||
|
LIFECYCLE_RESULT_OK,
|
||||||
|
LIFECYCLE_RESULT_INVALID_NAME,
|
||||||
|
LIFECYCLE_RESULT_INVALID_ROLE,
|
||||||
|
LIFECYCLE_RESULT_INVALID_MODE,
|
||||||
|
LIFECYCLE_RESULT_UNAUTHORIZED,
|
||||||
|
LIFECYCLE_RESULT_NO_PLAYER_SLOT,
|
||||||
|
LIFECYCLE_RESULT_NO_SPECTATOR_SLOT,
|
||||||
|
LIFECYCLE_RESULT_FORBIDDEN_ROLE,
|
||||||
|
LIFECYCLE_RESULT_WRONG_PHASE,
|
||||||
|
LIFECYCLE_RESULT_NOT_YOUR_TURN,
|
||||||
|
LIFECYCLE_RESULT_CELL_ALREADY_SHOT,
|
||||||
|
LIFECYCLE_RESULT_INVALID_COORDINATE,
|
||||||
|
LIFECYCLE_RESULT_STALE_GAME,
|
||||||
|
LIFECYCLE_RESULT_GENERATION_FAILED,
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
game_engine_t game;
|
||||||
|
session_manager_t sessions;
|
||||||
|
cumulative_statistics_t cumulative[kPlayerCapacity];
|
||||||
|
random_source_t random;
|
||||||
|
bool bot_reserved;
|
||||||
|
bool rematch_confirmed[kPlayerCapacity];
|
||||||
|
uint32_t next_game_id;
|
||||||
|
} game_lifecycle_t;
|
||||||
|
|
||||||
|
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random);
|
||||||
|
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
|
||||||
|
uint8_t *session_index);
|
||||||
|
lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
|
||||||
|
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_leave(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);
|
||||||
|
lifecycle_result_t game_lifecycle_start(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
|
||||||
|
lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id,
|
||||||
|
coordinate_t coordinate, shot_result_t *result);
|
||||||
|
lifecycle_result_t game_lifecycle_rematch(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
|
||||||
|
lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id);
|
||||||
|
|
||||||
|
_Static_assert(sizeof(game_lifecycle_t) <= 1600, "lifecycle state grew beyond fixed budget");
|
||||||
@@ -1,7 +1,40 @@
|
|||||||
#pragma once
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "app_interfaces.h"
|
||||||
#include "app_config.h"
|
#include "app_config.h"
|
||||||
#include "game_types.h"
|
#include "game_types.h"
|
||||||
|
|
||||||
typedef struct { role_t role; bool occupied; uint8_t token[kSessionTokenBytes]; char name[kDisplayNameBytes + 1]; } session_t;
|
typedef uint8_t session_result_t;
|
||||||
|
enum {
|
||||||
|
SESSION_RESULT_OK,
|
||||||
|
SESSION_RESULT_INVALID_NAME,
|
||||||
|
SESSION_RESULT_INVALID_ROLE,
|
||||||
|
SESSION_RESULT_NO_PLAYER_SLOT,
|
||||||
|
SESSION_RESULT_NO_SPECTATOR_SLOT,
|
||||||
|
SESSION_RESULT_UNAUTHORIZED,
|
||||||
|
SESSION_RESULT_WRONG_PHASE,
|
||||||
|
};
|
||||||
|
|
||||||
|
typedef struct {
|
||||||
|
role_t role;
|
||||||
|
bool occupied;
|
||||||
|
bool connected;
|
||||||
|
uint8_t token[kSessionTokenBytes];
|
||||||
|
char name[kDisplayNameBytes + 1];
|
||||||
|
} session_t;
|
||||||
typedef struct { session_t entries[kSessionCapacity]; } session_manager_t;
|
typedef struct { session_t entries[kSessionCapacity]; } session_manager_t;
|
||||||
|
|
||||||
|
void session_manager_init(session_manager_t *manager);
|
||||||
|
session_result_t session_manager_join(session_manager_t *manager, role_t requested_role, const char *name,
|
||||||
|
random_source_t random, uint8_t *session_index);
|
||||||
|
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes],
|
||||||
|
uint8_t *session_index);
|
||||||
|
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index);
|
||||||
|
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase);
|
||||||
|
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index);
|
||||||
|
|
||||||
|
_Static_assert(sizeof(session_t) <= 104, "session grew beyond fixed budget");
|
||||||
|
_Static_assert(sizeof(session_manager_t) <= 1040, "session table grew beyond fixed budget");
|
||||||
|
|||||||
@@ -2,5 +2,6 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
typedef struct { uint16_t shots; uint16_t hits; uint16_t misses; uint8_t ships_sunk; } match_statistics_t;
|
#include "game_types.h"
|
||||||
|
|
||||||
typedef struct { uint16_t games; uint16_t wins; uint16_t losses; uint32_t shots; uint32_t hits; } cumulative_statistics_t;
|
typedef struct { uint16_t games; uint16_t wins; uint16_t losses; uint32_t shots; uint32_t hits; } cumulative_statistics_t;
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# without default 'CMakeLists.txt' file.
|
# without default 'CMakeLists.txt' file.
|
||||||
|
|
||||||
idf_component_register(
|
idf_component_register(
|
||||||
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c"
|
SRCS "main.c" "application.c" "command_queue.c" "fleet_generator.c" "game_engine.c" "bot_player.c" "session_manager.c" "game_lifecycle.c"
|
||||||
INCLUDE_DIRS "../include"
|
INCLUDE_DIRS "../include"
|
||||||
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,174 @@
|
|||||||
|
#include "game_lifecycle.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static lifecycle_result_t session_result(session_result_t result) {
|
||||||
|
switch (result) {
|
||||||
|
case SESSION_RESULT_OK: return LIFECYCLE_RESULT_OK;
|
||||||
|
case SESSION_RESULT_INVALID_NAME: return LIFECYCLE_RESULT_INVALID_NAME;
|
||||||
|
case SESSION_RESULT_INVALID_ROLE: return LIFECYCLE_RESULT_INVALID_ROLE;
|
||||||
|
case SESSION_RESULT_NO_PLAYER_SLOT: return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
|
||||||
|
case SESSION_RESULT_NO_SPECTATOR_SLOT: return LIFECYCLE_RESULT_NO_SPECTATOR_SLOT;
|
||||||
|
case SESSION_RESULT_WRONG_PHASE: return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
default: return LIFECYCLE_RESULT_UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static lifecycle_result_t game_result(game_result_t result) {
|
||||||
|
switch (result) {
|
||||||
|
case GAME_RESULT_OK: return LIFECYCLE_RESULT_OK;
|
||||||
|
case GAME_RESULT_INVALID_COORDINATE: return LIFECYCLE_RESULT_INVALID_COORDINATE;
|
||||||
|
case GAME_RESULT_WRONG_PHASE: return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
case GAME_RESULT_NOT_YOUR_TURN: return LIFECYCLE_RESULT_NOT_YOUR_TURN;
|
||||||
|
case GAME_RESULT_CELL_ALREADY_SHOT: return LIFECYCLE_RESULT_CELL_ALREADY_SHOT;
|
||||||
|
default: return LIFECYCLE_RESULT_GENERATION_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool is_player(const game_lifecycle_t *lifecycle, uint8_t session_index, uint8_t *player) {
|
||||||
|
if (lifecycle == NULL || session_index >= kPlayerCapacity || !lifecycle->sessions.entries[session_index].occupied) return false;
|
||||||
|
if (lifecycle->sessions.entries[session_index].role != session_index) return false;
|
||||||
|
if (player != NULL) *player = session_index;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool game_id_matches(const game_lifecycle_t *lifecycle, uint32_t game_id) {
|
||||||
|
return lifecycle != NULL && lifecycle->game.state.game_id == game_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void record_cumulative(game_lifecycle_t *lifecycle) {
|
||||||
|
for (uint8_t player = 0; player < kPlayerCapacity; ++player) {
|
||||||
|
cumulative_statistics_t *total = &lifecycle->cumulative[player];
|
||||||
|
const match_statistics_t *match = &lifecycle->game.state.statistics[player];
|
||||||
|
++total->games;
|
||||||
|
total->shots += match->shots;
|
||||||
|
total->hits += match->hits;
|
||||||
|
if (lifecycle->game.state.winner == player) ++total->wins;
|
||||||
|
else ++total->losses;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static lifecycle_result_t start_current_game(game_lifecycle_t *lifecycle) {
|
||||||
|
const uint32_t game_id = lifecycle->game.state.game_id;
|
||||||
|
const game_mode_t mode = lifecycle->game.state.mode;
|
||||||
|
const uint32_t version = lifecycle->game.state.version;
|
||||||
|
game_engine_init(&lifecycle->game);
|
||||||
|
lifecycle->game.state.game_id = game_id;
|
||||||
|
lifecycle->game.state.version = version;
|
||||||
|
return game_result(game_engine_start(&lifecycle->game, game_id, mode,
|
||||||
|
&(fleet_generator_t){.random = lifecycle->random}));
|
||||||
|
}
|
||||||
|
|
||||||
|
void game_lifecycle_init(game_lifecycle_t *lifecycle, random_source_t random) {
|
||||||
|
if (lifecycle == NULL) return;
|
||||||
|
memset(lifecycle, 0, sizeof(*lifecycle));
|
||||||
|
lifecycle->random = random;
|
||||||
|
lifecycle->next_game_id = 2U;
|
||||||
|
game_engine_init(&lifecycle->game);
|
||||||
|
lifecycle->game.state.game_id = 1U;
|
||||||
|
session_manager_init(&lifecycle->sessions);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_join(game_lifecycle_t *lifecycle, role_t requested_role, const char *name,
|
||||||
|
uint8_t *session_index) {
|
||||||
|
if (lifecycle == NULL) return LIFECYCLE_RESULT_UNAUTHORIZED;
|
||||||
|
if ((requested_role == ROLE_PLAYER_1 || requested_role == ROLE_PLAYER_2) && lifecycle->game.state.phase != PHASE_LOBBY) {
|
||||||
|
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
|
||||||
|
}
|
||||||
|
if (requested_role == ROLE_PLAYER_2 && !session_manager_player_present(&lifecycle->sessions, 0U)) {
|
||||||
|
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
|
||||||
|
}
|
||||||
|
if (requested_role == ROLE_PLAYER_2 && (lifecycle->game.state.mode == MODE_BOT || lifecycle->bot_reserved)) {
|
||||||
|
return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
|
||||||
|
}
|
||||||
|
return session_result(session_manager_join(&lifecycle->sessions, requested_role, name, lifecycle->random, session_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_resume(game_lifecycle_t *lifecycle,
|
||||||
|
const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
|
||||||
|
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
|
||||||
|
session_result(session_manager_resume(&lifecycle->sessions, token, session_index));
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_disconnect(game_lifecycle_t *lifecycle, uint8_t session_index) {
|
||||||
|
return lifecycle != NULL && session_manager_disconnect(&lifecycle->sessions, session_index) ?
|
||||||
|
LIFECYCLE_RESULT_OK : LIFECYCLE_RESULT_UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_leave(game_lifecycle_t *lifecycle, uint8_t session_index) {
|
||||||
|
return lifecycle == NULL ? LIFECYCLE_RESULT_UNAUTHORIZED :
|
||||||
|
session_result(session_manager_leave(&lifecycle->sessions, session_index, lifecycle->game.state.phase));
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_configure(game_lifecycle_t *lifecycle, uint8_t session_index,
|
||||||
|
uint32_t game_id, game_mode_t mode) {
|
||||||
|
uint8_t player = 0;
|
||||||
|
if (!is_player(lifecycle, session_index, &player)) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
|
||||||
|
if (player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
|
||||||
|
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
|
||||||
|
if (lifecycle->game.state.phase != PHASE_LOBBY) return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
if (mode != MODE_HUMAN && mode != MODE_BOT) return LIFECYCLE_RESULT_INVALID_MODE;
|
||||||
|
if (mode == MODE_BOT && session_manager_player_present(&lifecycle->sessions, 1U)) return LIFECYCLE_RESULT_NO_PLAYER_SLOT;
|
||||||
|
lifecycle->game.state.mode = mode;
|
||||||
|
lifecycle->bot_reserved = mode == MODE_BOT;
|
||||||
|
++lifecycle->game.state.version;
|
||||||
|
return LIFECYCLE_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_start(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 (player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
|
||||||
|
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
|
||||||
|
if (lifecycle->game.state.phase != PHASE_LOBBY) return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
if (lifecycle->game.state.mode == MODE_HUMAN && !session_manager_player_present(&lifecycle->sessions, 1U)) return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
if (lifecycle->game.state.mode == MODE_BOT && !lifecycle->bot_reserved) return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
return start_current_game(lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_shot(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id,
|
||||||
|
coordinate_t coordinate, shot_result_t *result) {
|
||||||
|
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 lifecycle_result_t outcome = game_result(game_engine_shot(&lifecycle->game, player, coordinate, result));
|
||||||
|
if (outcome == LIFECYCLE_RESULT_OK && lifecycle->game.state.phase == PHASE_FINISHED) record_cumulative(lifecycle);
|
||||||
|
return outcome;
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_rematch(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;
|
||||||
|
if (lifecycle->game.state.phase != PHASE_FINISHED && lifecycle->game.state.phase != PHASE_REMATCH_WAIT) return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
lifecycle->rematch_confirmed[player] = true;
|
||||||
|
const bool ready = lifecycle->game.state.mode == MODE_BOT ? lifecycle->rematch_confirmed[0] :
|
||||||
|
lifecycle->rematch_confirmed[0] && lifecycle->rematch_confirmed[1];
|
||||||
|
if (!ready) {
|
||||||
|
if (lifecycle->game.state.phase == PHASE_FINISHED) {
|
||||||
|
lifecycle->game.state.phase = PHASE_REMATCH_WAIT;
|
||||||
|
++lifecycle->game.state.version;
|
||||||
|
}
|
||||||
|
return LIFECYCLE_RESULT_OK;
|
||||||
|
}
|
||||||
|
lifecycle->rematch_confirmed[0] = false;
|
||||||
|
lifecycle->rematch_confirmed[1] = false;
|
||||||
|
lifecycle->game.state.game_id = lifecycle->next_game_id++;
|
||||||
|
return start_current_game(lifecycle);
|
||||||
|
}
|
||||||
|
|
||||||
|
lifecycle_result_t game_lifecycle_abort(game_lifecycle_t *lifecycle, uint8_t session_index, uint32_t game_id) {
|
||||||
|
uint8_t player = 0;
|
||||||
|
if (!is_player(lifecycle, session_index, &player) || player != 0U) return LIFECYCLE_RESULT_FORBIDDEN_ROLE;
|
||||||
|
if (!game_id_matches(lifecycle, game_id)) return LIFECYCLE_RESULT_STALE_GAME;
|
||||||
|
if (lifecycle->game.state.phase != PHASE_IN_PROGRESS || lifecycle->game.state.mode != MODE_HUMAN ||
|
||||||
|
lifecycle->sessions.entries[1].connected) return LIFECYCLE_RESULT_WRONG_PHASE;
|
||||||
|
const uint32_t version = lifecycle->game.state.version + 1U;
|
||||||
|
const uint32_t next_game_id = lifecycle->next_game_id++;
|
||||||
|
game_engine_init(&lifecycle->game);
|
||||||
|
lifecycle->game.state.game_id = next_game_id;
|
||||||
|
lifecycle->game.state.version = version;
|
||||||
|
lifecycle->bot_reserved = false;
|
||||||
|
return LIFECYCLE_RESULT_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
#include "session_manager.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
static bool valid_utf8_scalar(const unsigned char *text, size_t remaining, size_t *bytes) {
|
||||||
|
if (text[0] < 0x80U) { *bytes = 1; return text[0] >= 0x20U && text[0] != '<' && text[0] != '>'; }
|
||||||
|
if (text[0] >= 0xC2U && text[0] <= 0xDFU && remaining >= 2 && (text[1] & 0xC0U) == 0x80U) { *bytes = 2; return true; }
|
||||||
|
if (text[0] >= 0xE0U && text[0] <= 0xEFU && remaining >= 3 && (text[1] & 0xC0U) == 0x80U && (text[2] & 0xC0U) == 0x80U) { *bytes = 3; return true; }
|
||||||
|
if (text[0] >= 0xF0U && text[0] <= 0xF4U && remaining >= 4 && (text[1] & 0xC0U) == 0x80U && (text[2] & 0xC0U) == 0x80U && (text[3] & 0xC0U) == 0x80U) { *bytes = 4; return true; }
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool sanitize_name(const char *name, char output[kDisplayNameBytes + 1]) {
|
||||||
|
if (name == NULL) return false;
|
||||||
|
size_t input = 0;
|
||||||
|
size_t written = 0;
|
||||||
|
uint8_t scalars = 0;
|
||||||
|
while (name[input] != '\0') {
|
||||||
|
size_t bytes = 0;
|
||||||
|
if (!valid_utf8_scalar((const unsigned char *)&name[input], strlen(&name[input]), &bytes)) return false;
|
||||||
|
if (written + bytes > kDisplayNameBytes || ++scalars > 20U) return false;
|
||||||
|
memcpy(&output[written], &name[input], bytes);
|
||||||
|
input += bytes;
|
||||||
|
written += bytes;
|
||||||
|
}
|
||||||
|
output[written] = '\0';
|
||||||
|
return scalars != 0U;
|
||||||
|
}
|
||||||
|
|
||||||
|
static int8_t first_free(const session_manager_t *manager, uint8_t start, uint8_t end) {
|
||||||
|
for (uint8_t index = start; index < end; ++index) if (!manager->entries[index].occupied) return (int8_t)index;
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void session_manager_init(session_manager_t *manager) {
|
||||||
|
if (manager != NULL) memset(manager, 0, sizeof(*manager));
|
||||||
|
}
|
||||||
|
|
||||||
|
session_result_t session_manager_join(session_manager_t *manager, role_t requested_role, const char *name,
|
||||||
|
random_source_t random, uint8_t *session_index) {
|
||||||
|
if (manager == NULL || session_index == NULL || random.next_u32 == NULL) return SESSION_RESULT_UNAUTHORIZED;
|
||||||
|
char sanitized[kDisplayNameBytes + 1] = {0};
|
||||||
|
if (!sanitize_name(name, sanitized)) return SESSION_RESULT_INVALID_NAME;
|
||||||
|
int8_t selected = -1;
|
||||||
|
if (requested_role == ROLE_PLAYER_1) selected = first_free(manager, 0, 1);
|
||||||
|
else if (requested_role == ROLE_PLAYER_2) selected = first_free(manager, 1, 2);
|
||||||
|
else if (requested_role == ROLE_SPECTATOR) selected = first_free(manager, kPlayerCapacity, kSessionCapacity);
|
||||||
|
else return SESSION_RESULT_INVALID_ROLE;
|
||||||
|
if (selected < 0) return requested_role == ROLE_SPECTATOR ? SESSION_RESULT_NO_SPECTATOR_SLOT : SESSION_RESULT_NO_PLAYER_SLOT;
|
||||||
|
session_t candidate = {.role = requested_role, .occupied = true, .connected = true};
|
||||||
|
memcpy(candidate.name, sanitized, sizeof(candidate.name));
|
||||||
|
for (uint8_t offset = 0; offset < kSessionTokenBytes; offset += 4) {
|
||||||
|
const uint32_t value = random.next_u32(random.context);
|
||||||
|
for (uint8_t byte = 0; byte < 4; ++byte) candidate.token[offset + byte] = (uint8_t)(value >> (byte * 8U));
|
||||||
|
}
|
||||||
|
manager->entries[selected] = candidate;
|
||||||
|
*session_index = (uint8_t)selected;
|
||||||
|
return SESSION_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_result_t session_manager_resume(session_manager_t *manager, const uint8_t token[kSessionTokenBytes], uint8_t *session_index) {
|
||||||
|
if (manager == NULL || token == NULL || session_index == NULL) return SESSION_RESULT_UNAUTHORIZED;
|
||||||
|
for (uint8_t index = 0; index < kSessionCapacity; ++index) {
|
||||||
|
session_t *session = &manager->entries[index];
|
||||||
|
if (session->occupied && memcmp(session->token, token, kSessionTokenBytes) == 0) {
|
||||||
|
session->connected = true;
|
||||||
|
*session_index = index;
|
||||||
|
return SESSION_RESULT_OK;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SESSION_RESULT_UNAUTHORIZED;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool session_manager_disconnect(session_manager_t *manager, uint8_t session_index) {
|
||||||
|
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return false;
|
||||||
|
if (manager->entries[session_index].role == ROLE_SPECTATOR) memset(&manager->entries[session_index], 0, sizeof(session_t));
|
||||||
|
else manager->entries[session_index].connected = false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
session_result_t session_manager_leave(session_manager_t *manager, uint8_t session_index, phase_t phase) {
|
||||||
|
if (manager == NULL || session_index >= kSessionCapacity || !manager->entries[session_index].occupied) return SESSION_RESULT_UNAUTHORIZED;
|
||||||
|
if (manager->entries[session_index].role != ROLE_SPECTATOR && phase != PHASE_LOBBY) return SESSION_RESULT_WRONG_PHASE;
|
||||||
|
memset(&manager->entries[session_index], 0, sizeof(session_t));
|
||||||
|
return SESSION_RESULT_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool session_manager_player_present(const session_manager_t *manager, uint8_t player_index) {
|
||||||
|
return manager != NULL && player_index < kPlayerCapacity && manager->entries[player_index].occupied;
|
||||||
|
}
|
||||||
+6
-2
@@ -1,7 +1,7 @@
|
|||||||
CC ?= cc
|
CC ?= cc
|
||||||
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
CFLAGS ?= -std=c11 -Wall -Wextra -Werror -I../../include
|
||||||
|
|
||||||
all: test_command_queue test_game_domain test_bot_player
|
all: test_command_queue test_game_domain test_bot_player test_game_lifecycle
|
||||||
|
|
||||||
test_command_queue: test_command_queue.c ../../src/command_queue.c ../../src/application.c
|
test_command_queue: test_command_queue.c ../../src/command_queue.c ../../src/application.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -10,6 +10,7 @@ run: all
|
|||||||
./test_command_queue
|
./test_command_queue
|
||||||
./test_game_domain
|
./test_game_domain
|
||||||
./test_bot_player
|
./test_bot_player
|
||||||
|
./test_game_lifecycle
|
||||||
|
|
||||||
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
@@ -17,5 +18,8 @@ test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_
|
|||||||
test_bot_player: test_bot_player.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
|
test_bot_player: test_bot_player.c ../../src/bot_player.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
$(CC) $(CFLAGS) $^ -o $@
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
|
test_game_lifecycle: test_game_lifecycle.c ../../src/session_manager.c ../../src/game_lifecycle.c ../../src/fleet_generator.c ../../src/game_engine.c
|
||||||
|
$(CC) $(CFLAGS) $^ -o $@
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f test_command_queue test_game_domain test_bot_player
|
rm -f test_command_queue test_game_domain test_bot_player test_game_lifecycle
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
#include <assert.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "game_lifecycle.h"
|
||||||
|
|
||||||
|
typedef struct { uint32_t value; } test_random_t;
|
||||||
|
|
||||||
|
static uint32_t next_random(void *context) {
|
||||||
|
test_random_t *random = context;
|
||||||
|
random->value = random->value * 1664525U + 1013904223U;
|
||||||
|
return random->value;
|
||||||
|
}
|
||||||
|
|
||||||
|
static game_lifecycle_t new_lifecycle(test_random_t *random) {
|
||||||
|
game_lifecycle_t lifecycle;
|
||||||
|
game_lifecycle_init(&lifecycle, (random_source_t){.next_u32 = next_random, .context = random});
|
||||||
|
return lifecycle;
|
||||||
|
}
|
||||||
|
|
||||||
|
static void join_players(game_lifecycle_t *lifecycle, uint8_t *player_1, uint8_t *player_2) {
|
||||||
|
assert(game_lifecycle_join(lifecycle, ROLE_PLAYER_1, "Alice", player_1) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(game_lifecycle_join(lifecycle, ROLE_PLAYER_2, "Bob", player_2) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(*player_1 == 0U && *player_2 == 1U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void finish_for_player_1(game_lifecycle_t *lifecycle, uint8_t player_1) {
|
||||||
|
game_state_t *state = &lifecycle->game.state;
|
||||||
|
state->current_player = 0U;
|
||||||
|
memset(&state->boards[1], 0, sizeof(state->boards[1]));
|
||||||
|
state->boards[1].ships_alive = 1U;
|
||||||
|
state->boards[1].ships[0] = (ship_t){.x = 0, .y = 0, .length = 1, .horizontal = true};
|
||||||
|
state->boards[1].cells[0] = CELL_SHIP;
|
||||||
|
shot_result_t result = {0};
|
||||||
|
assert(game_lifecycle_shot(lifecycle, player_1, state->game_id, (coordinate_t){0, 0}, &result) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(result.finished && state->phase == PHASE_FINISHED && state->winner == 0U);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_capacity_sanitize_and_resume(void) {
|
||||||
|
test_random_t random = {.value = 1};
|
||||||
|
game_lifecycle_t lifecycle = new_lifecycle(&random);
|
||||||
|
uint8_t player_1 = 0;
|
||||||
|
uint8_t player_2 = 0;
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_2, "Bob", &player_2) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
|
||||||
|
join_players(&lifecycle, &player_1, &player_2);
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Other", &player_1) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "<bad>", &player_1) == LIFECYCLE_RESULT_INVALID_NAME);
|
||||||
|
for (uint8_t index = 0; index < kSpectatorCapacity; ++index) {
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Watch", &player_1) == LIFECYCLE_RESULT_OK);
|
||||||
|
}
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_SPECTATOR, "Extra", &player_1) == LIFECYCLE_RESULT_NO_SPECTATOR_SLOT);
|
||||||
|
const uint8_t token[kSessionTokenBytes] = {0};
|
||||||
|
uint8_t saved_token[kSessionTokenBytes] = {0};
|
||||||
|
memcpy(saved_token, lifecycle.sessions.entries[player_2].token, sizeof(saved_token));
|
||||||
|
assert(memcmp(token, saved_token, sizeof(token)) != 0);
|
||||||
|
assert(game_lifecycle_disconnect(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(!lifecycle.sessions.entries[player_2].connected);
|
||||||
|
uint8_t resumed = kSessionCapacity;
|
||||||
|
assert(game_lifecycle_resume(&lifecycle, saved_token, &resumed) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(resumed == player_2 && lifecycle.sessions.entries[resumed].role == ROLE_PLAYER_2);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_human_lifecycle_and_guards(void) {
|
||||||
|
test_random_t random = {.value = 9};
|
||||||
|
game_lifecycle_t lifecycle = new_lifecycle(&random);
|
||||||
|
uint8_t player_1 = 0;
|
||||||
|
uint8_t player_2 = 0;
|
||||||
|
join_players(&lifecycle, &player_1, &player_2);
|
||||||
|
const uint32_t game_id = lifecycle.game.state.game_id;
|
||||||
|
game_lifecycle_t before = lifecycle;
|
||||||
|
assert(game_lifecycle_configure(&lifecycle, player_2, game_id, MODE_BOT) == LIFECYCLE_RESULT_FORBIDDEN_ROLE);
|
||||||
|
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
|
||||||
|
assert(game_lifecycle_start(&lifecycle, player_1, game_id + 1U) == LIFECYCLE_RESULT_STALE_GAME);
|
||||||
|
assert(game_lifecycle_start(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS);
|
||||||
|
finish_for_player_1(&lifecycle, player_1);
|
||||||
|
assert(lifecycle.cumulative[0].games == 1U && lifecycle.cumulative[0].wins == 1U);
|
||||||
|
assert(lifecycle.cumulative[1].games == 1U && lifecycle.cumulative[1].losses == 1U);
|
||||||
|
assert(game_lifecycle_rematch(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_REMATCH_WAIT);
|
||||||
|
assert(game_lifecycle_rematch(&lifecycle, player_2, game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
|
||||||
|
assert(lifecycle.cumulative[0].wins == 1U);
|
||||||
|
assert(game_lifecycle_disconnect(&lifecycle, player_2) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(game_lifecycle_abort(&lifecycle, player_1, lifecycle.game.state.game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_LOBBY);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void test_bot_lifecycle_and_stale_actions(void) {
|
||||||
|
test_random_t random = {.value = 17};
|
||||||
|
game_lifecycle_t lifecycle = new_lifecycle(&random);
|
||||||
|
uint8_t player_1 = 0;
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_1, "Alice", &player_1) == LIFECYCLE_RESULT_OK);
|
||||||
|
const uint32_t game_id = lifecycle.game.state.game_id;
|
||||||
|
assert(game_lifecycle_configure(&lifecycle, player_1, game_id, MODE_BOT) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.bot_reserved);
|
||||||
|
assert(game_lifecycle_join(&lifecycle, ROLE_PLAYER_2, "Bob", &player_1) == LIFECYCLE_RESULT_NO_PLAYER_SLOT);
|
||||||
|
assert(game_lifecycle_start(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
finish_for_player_1(&lifecycle, player_1);
|
||||||
|
assert(game_lifecycle_rematch(&lifecycle, player_1, game_id) == LIFECYCLE_RESULT_OK);
|
||||||
|
assert(lifecycle.game.state.phase == PHASE_IN_PROGRESS && lifecycle.game.state.game_id != game_id);
|
||||||
|
const game_lifecycle_t before = lifecycle;
|
||||||
|
assert(game_lifecycle_shot(&lifecycle, player_1, game_id, (coordinate_t){0, 0}, NULL) == LIFECYCLE_RESULT_STALE_GAME);
|
||||||
|
assert(memcmp(&before, &lifecycle, sizeof(lifecycle)) == 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int main(void) {
|
||||||
|
test_capacity_sanitize_and_resume();
|
||||||
|
test_human_lifecycle_and_guards();
|
||||||
|
test_bot_lifecycle_and_stale_actions();
|
||||||
|
puts("game lifecycle tests passed");
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user