diff --git a/PLANS.md b/PLANS.md index 223c383..ac8e011 100644 --- a/PLANS.md +++ b/PLANS.md @@ -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 -**Status:** `READY` +**Status:** `DONE` **Depends on:** Milestone 008 ### 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`. +### 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 -**Status:** `BLOCKED` +**Status:** `READY` **Depends on:** Milestone 009 ### Objective diff --git a/include/application.h b/include/application.h index 5070ccd..869945e 100644 --- a/include/application.h +++ b/include/application.h @@ -3,9 +3,10 @@ #include #include "command_queue.h" +#include "game_lifecycle.h" -/* The application task is the sole owner of game_state_t mutations. */ -typedef struct { command_queue_t command_queue; game_state_t state; } application_t; +/* The application task is the sole owner of lifecycle and game mutations. */ +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_take_next_command(application_t *application, app_command_t *command); diff --git a/include/game_lifecycle.h b/include/game_lifecycle.h new file mode 100644 index 0000000..1edb14f --- /dev/null +++ b/include/game_lifecycle.h @@ -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"); diff --git a/include/session_manager.h b/include/session_manager.h index 317783c..45906f2 100644 --- a/include/session_manager.h +++ b/include/session_manager.h @@ -1,7 +1,40 @@ #pragma once +#include +#include + +#include "app_interfaces.h" #include "app_config.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; + +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"); diff --git a/include/statistics.h b/include/statistics.h index 4ee5d16..5696d70 100644 --- a/include/statistics.h +++ b/include/statistics.h @@ -2,5 +2,6 @@ #include -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; diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index a50bb9d..315f2f1 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -2,7 +2,7 @@ # without default 'CMakeLists.txt' file. 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" REQUIRES esp_event esp_http_server esp_netif esp_wifi esp_littlefs nvs_flash ) diff --git a/src/game_lifecycle.c b/src/game_lifecycle.c new file mode 100644 index 0000000..6213d0b --- /dev/null +++ b/src/game_lifecycle.c @@ -0,0 +1,174 @@ +#include "game_lifecycle.h" + +#include +#include + +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; +} diff --git a/src/session_manager.c b/src/session_manager.c new file mode 100644 index 0000000..b0c4fb5 --- /dev/null +++ b/src/session_manager.c @@ -0,0 +1,91 @@ +#include "session_manager.h" + +#include +#include + +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; +} diff --git a/test/host/Makefile b/test/host/Makefile index 02b0b95..fc31dc5 100644 --- a/test/host/Makefile +++ b/test/host/Makefile @@ -1,7 +1,7 @@ CC ?= cc 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 $(CC) $(CFLAGS) $^ -o $@ @@ -10,6 +10,7 @@ run: all ./test_command_queue ./test_game_domain ./test_bot_player + ./test_game_lifecycle test_game_domain: test_game_domain.c ../../src/fleet_generator.c ../../src/game_engine.c $(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 $(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: - 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 diff --git a/test/host/test_game_lifecycle.c b/test/host/test_game_lifecycle.c new file mode 100644 index 0000000..1053867 --- /dev/null +++ b/test/host/test_game_lifecycle.c @@ -0,0 +1,113 @@ +#include +#include +#include + +#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, "", &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; +}