From 42e90e127e1b96400bb2fe84b22948a8c00c4162 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:29:39 +0300 Subject: [PATCH 01/25] =?UTF-8?q?sprint=20021:=20plan=20=E2=80=94=20unifie?= =?UTF-8?q?d=20save=20schema,=20world=20RNG=20reproducibility,=20autosave?= =?UTF-8?q?=20hardening?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/STATUS.md | 13 +++++- docs/sprints/021-save-schema/sprint.md | 60 ++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 docs/sprints/021-save-schema/sprint.md diff --git a/docs/STATUS.md b/docs/STATUS.md index d4bca6e8..5c8c72b4 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -9,7 +9,18 @@ ## Current Sprint -No active sprint. +**Sprint:** 021-save-schema +**Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. +**Started:** 2026-07-10 +**Phase:** Planning (COMPLETE) — 2026-07-10 + +Ready for Phase 1 task generation. + +### Phases + +1. RNG threading & determinism +2. Unified Pydantic save schema +3. Autosave hardening ## Recent activity (non-sprint) diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md new file mode 100644 index 00000000..363116e8 --- /dev/null +++ b/docs/sprints/021-save-schema/sprint.md @@ -0,0 +1,60 @@ +# Sprint 021 — Save Schema & World Reproducibility + +**Goal:** Мир сохраняется через единую версионированную Pydantic-схему сейва, симуляция воспроизводима от явного сида (RNG прокинут в слои, состояние RNG в сейве), автосейв периодический и не глушит ошибки. + +**Started:** 2026-07-10 + +## Context + +Первый эпик цепочки simulation-core: `save-schema` → `anchor-as-property`/`intents` → `trigger-table` → ... «Мир заморожен на полушаге» требует lossless-сейва, а значит единой схемы вместо рукописных dict-ов, размазанных по `commands_save` / `World.save` / пяти `get_state()`. Почва подготовлена Sprint 020 phase 3 (format-preserving дедуп, `entity_serialization.py`). + +Вторая половина — воспроизводимость: encounter rolls, roam movement и retreat selection сидят на процесс-глобальном `random` (`layers/entities/encounters.py`, `layers/ecology/movement.py`, `layers/ecology/squad_combat.py`), `PoliticsLayer`/`GeographyLayer` принимают seed, но `game_service` его не передаёт, `DND_DICE_SEED` сидит только dice-RNG. Флак PR #31 (осиротевшие раунды сдвигали глобальный RNG) — живая иллюстрация. Без явного RNG состояние генератора нечем класть в сейв. + +Закрываемые backlog-айтемы: `save-schema` (must), `layer-rng-threading`, `test-gap-world-rng-determinism`, `periodic-autosave-scheduler`, `silent-failure-autosave`; попутно минимальный фикс `saved-session-accumulation` (чистка saves/ в teardown интеграционных тестов). + +**Ссылки:** [simulation-core](../../brainstorms/simulation-core.md), [BACKLOG](../../BACKLOG.md#simulation-core-брейншторм-2026-07-04), [020 phase3 serialization dedup](../020-thermo-sweep/tasks/phase3-task1-serialization-dedup.md) + +## Phase 1: RNG threading & determinism + +Единый world-seed: RNG создаётся на уровне World/сессии и прокидывается в слои через конструкторы (расширение существующего паттерна `PoliticsLayer(seed)` / `WeatherEngine(seed)`; `EcologyLayer` получает seed впервые). Три bare-`random` сайта (encounters, roam movement, retreat) переводятся на слоевой RNG. `game_service` реально передаёт сиды (env `DND_WORLD_SEED`, по умолчанию случайный). Тесты детерминизма: одинаковый сид → идентичная эволюция мира (encounter rolls, движение сквадов, retreat), разный сид → расходится. + +Проверка: `make test`, новые unit-тесты детерминизма зелёные. Почему первым: без явного RNG его состояние не положить в схему сейва (phase 2). + +**Tasks:** + +_(генерируются отдельно перед началом фазы)_ + +## Phase 2: Unified Pydantic save schema + +Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{time, last_tick_times, layers}`) как единый source of truth. Слои отдают/принимают типизированные модели вместо сырых dict-ов; `entity_serialization` переезжает на модели. Состояние RNG (dice + per-layer) сериализуется в сейв — загрузка продолжает ту же случайную последовательность. Единый путь загрузки в `commands_save`: `schema_version=1`, legacy-фолбэки (три исторических формата) удаляются — сейвы dev-артефакты. Round-trip тесты перепиниваются на новый формат. + +Проверка: `make test-integration` (round-trip сьют), ручная проверка save→load→continue в UI. Почему вторым: схема фиксирует то, что phase 1 сделал явным. + +**Tasks:** + +_(генерируются отдельно перед началом фазы)_ + +## Phase 3: Autosave hardening + +Периодический автосейв: фоновый asyncio-таск в FastAPI lifespan (интервал env `DND_AUTOSAVE_SECONDS`, default ~120), cancel на shutdown перед финальным autosave. Ошибки автосейва логируются вместо `contextlib.suppress(Exception)` (3 сайта). Минимальный фикс накопления: интеграционные тесты чистят созданные сессии в `saves/` в teardown. + +Проверка: unit-тест шедулера (старт/останов/интервал), `make test-integration`, `saves/` не растёт после прогона. Почему последним: частый автосейв имеет смысл только поверх надёжной схемы. + +--- + +## Status + +**Current:** Planning complete. Ready to generate Phase 1 tasks. + +## Decisions + +- Legacy-форматы сейва (без `meta`, flat-world, top-level `player`) удаляются без миграции: сейвы — dev-артефакты, `schema_version=1` стартует с чистого листа (2026-07-10). +- RNG-паттерн: унифицируем на layer-constructor-owned `random.Random(seed)` (существующий Pattern B), сиды раздаёт World/сессия из одного world-seed; dice-RNG (`rules/dice.py`) остаётся отдельным потоком, но его состояние тоже попадает в сейв (2026-07-10). + +## Deferred + +_(заполняется по ходу спринта)_ + +## Results + +_(заполняется в конце спринта)_ From 46cc1858589d7b4a1d0c726a175978cbf188c493 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:31:51 +0300 Subject: [PATCH 02/25] sprint 021 phase 1: task breakdown --- docs/STATUS.md | 4 +- docs/sprints/021-save-schema/sprint.md | 4 +- .../tasks/phase1-task1-world-seed-plumbing.md | 41 +++++++++++++++++++ .../phase1-task2-bare-random-migration.md | 39 ++++++++++++++++++ .../phase1-task3-world-determinism-test.md | 32 +++++++++++++++ 5 files changed, 117 insertions(+), 3 deletions(-) create mode 100644 docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md create mode 100644 docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md create mode 100644 docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 5c8c72b4..6e15132e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** Planning (COMPLETE) — 2026-07-10 +**Phase:** 1 — RNG threading & determinism (tasks generated) — 2026-07-10 -Ready for Phase 1 task generation. +Ready to start task 1. ### Phases diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index 363116e8..e6b916b2 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -22,7 +22,9 @@ **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [World seed plumbing](tasks/phase1-task1-world-seed-plumbing.md) +2. [Миграция bare-random сайтов на слоевой RNG](tasks/phase1-task2-bare-random-migration.md) +3. [Сквозной тест воспроизводимости мира](tasks/phase1-task3-world-determinism-test.md) ## Phase 2: Unified Pydantic save schema diff --git a/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md b/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md new file mode 100644 index 00000000..54d685b7 --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md @@ -0,0 +1,41 @@ +# Task: World seed plumbing — единый сид раздаётся слоям + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 1 — RNG threading & determinism + +## Description + +Ввести единый world seed и раздать его слоям через конструкторы. Сейчас seed-параметры частично существуют, но не подключены: + +- `layers/politics/layer.py:94` — `self._rng = random.Random(seed)`, но `service/game_service.py:148` не передаёт seed → всегда unseeded. +- `layers/geography/weather.py:187` — `WeatherEngine.__init__(seed)` прокинут из `GeographyLayer(weather_seed=...)`, но game_service его тоже не передаёт. +- `layers/ecology/layer.py:32-49` — seed-параметра нет вообще, добавить `seed: int | None = None` → `self._rng = random.Random(seed)`. +- `layers/entities/layer.py` — добавить такой же `seed` → `self._rng` (потребитель — encounter rolls, подключается в task 2; в этой таске достаточно, что RNG существует и детерминирован). + +Схема раздачи: game_service при сборке мира читает env `DND_WORLD_SEED` (int). Если не задан — генерирует случайный и логирует (structlog), чтобы любой прогон был воспроизводим постфактум. Из world seed детерминированно выводятся слоевые сиды — фиксированная схема `random.Random(f"{world_seed}:{layer_name}")`-style или последовательный draw из `random.Random(world_seed)` в фиксированном порядке слоёв; главное требование — разные слои получают разные потоки, одинаковый world seed даёт одинаковый набор слоевых сидов. World seed сохранить как атрибут `World` (понадобится phase 2 для записи в сейв). + +`rules/dice.py` / `DND_DICE_SEED` не трогать — dice-RNG остаётся отдельным потоком (решение в sprint.md). + +## Tests First + +- Два `PoliticsLayer` с одним seed при одинаковой стартовой конфигурации наций дают идентичную последовательность дипломатических/военных исходов за N тиков; с разными seed — последовательности расходятся (хотя бы одно отличие на достаточном N). +- Два `WeatherEngine` (или `GeographyLayer` целиком) с одним seed дают идентичную последовательность погоды за N часов; с разными — расходятся. +- Мир, собранный через game_service дважды при `DND_WORLD_SEED=42` (monkeypatch env), получает одинаковые слоевые сиды: первые K значений `layer._rng.random()` каждого слоя совпадают между сборками. +- Слоевые сиды различны между слоями: при одном world seed потоки politics и ecology не совпадают. + +## Implementation + +После красных тестов: параметр `seed` в `EcologyLayer`/`EntitiesLayer`, вывод слоевых сидов в одном месте (helper рядом со сборкой мира в `service/game_service.py`), проводка в конструкторы всех четырёх слоёв (settlements — если там есть случайность; если нет, не добавлять пустой параметр), `World.seed` атрибут. Env-доки: упомянуть `DND_WORLD_SEED` в CLAUDE.md рядом с `DND_DICE_SEED`. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`) +- [ ] `DND_WORLD_SEED` документирован; незаданный seed логируется при сборке мира +- [ ] Один world seed → воспроизводимые и попарно различные слоевые потоки + +## Status + +`pending` diff --git a/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md b/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md new file mode 100644 index 00000000..97675ed5 --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md @@ -0,0 +1,39 @@ +# Task: Миграция bare-random сайтов на слоевой RNG + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 1 — RNG threading & determinism + +## Description + +Убрать процесс-глобальный `random` из симуляции мира (backlog `layer-rng-threading`). Сайты: + +- `layers/entities/encounters.py:112,123` — `random.random()` (шанс встречи), `random.randint()` (количество монстров) → RNG `EntitiesLayer` (появился в task 1), прокинуть в encounter-roller явно. +- `layers/ecology/movement.py:83` — `random.choice(candidates)` (roam) → RNG `EcologyLayer`, параметром функции (обязательным, без дефолта на глобальный). +- `layers/ecology/squad_combat.py:106` — `random.choice(edges)` (отступление проигравшего) → так же. +- `layers/ecology/lairs.py:33` — `get_global_rng().random()` (depletion chance) использует dice-RNG для мировой симуляции; перевести на RNG `EcologyLayer` для консистентности (dice-RNG — только броски костей правил). + +После миграции `grep -rn "random\." src/dnd_simulator/layers/` не должен находить обращений к модульному глобальному `random` (только `random.Random` в конструкторах). Функции в `movement.py`/`squad_combat.py` остаются чистыми: RNG приходит параметром, состояние владеет слой. + +## Tests First + +- Encounter rolls детерминированы: `EntitiesLayer`/roller с фиксированным seed на одной encounter-таблице выдаёт одну и ту же последовательность (встреча/нет, число монстров) за K заходов; другой seed — другую. +- Roam movement детерминирован: squad на графе локаций с seeded RNG проходит один и тот же маршрут за N тиков ecology. +- Retreat детерминирован: тот же проигравший на тех же рёбрах с тем же seed отступает в одну и ту же локацию. +- Изоляция от глобального RNG (урок флака PR #31): вызов `random.seed(...)` / потребление глобального `random` между тиками НЕ меняет исходы seeded-слоя — последовательности совпадают с прогоном без вмешательства. + +## Implementation + +После красных тестов: сигнатуры функций encounters/movement/squad_combat/lairs получают `rng: random.Random`, слои передают свой `self._rng`. Проверить вызывающие сайты (ActivationManager для encounters — см. `layers/entities/` после декомпозиции sprint 020 phase 3). + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`) +- [ ] В `src/dnd_simulator/layers/` нет обращений к глобальному `random` и к `get_global_rng()` вне бросков костей правил +- [ ] Тест изоляции от глобального RNG зелёный + +## Status + +`pending` diff --git a/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md b/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md new file mode 100644 index 00000000..78f04964 --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md @@ -0,0 +1,32 @@ +# Task: Сквозной тест воспроизводимости мира + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 1 — RNG threading & determinism + +## Description + +Продуктовый тест, фиксирующий свойство «одинаковый сид → одинаковая эволюция мира» на всей вертикали (закрывает backlog `test-gap-world-rng-determinism`). Не unit на отдельные функции (это tasks 1-2), а капстоун: мир собирается настоящим путём (content_loader + сборка слоёв как в game_service), живёт несколько внутриигровых дней, состояние сравнивается целиком. + +## Tests First + +- Один и тот же мир (реальный тестовый контент, rule-brains, без LLM и I/O) собирается дважды с одним world seed и одним dice seed (`set_global_seed`); `advance_time` на срок, покрывающий тики всех слоёв (politics/settlements — 30 дней, ecology — часы); `World.save()` обоих миров идентичен (сравнение структур целиком). +- Тот же сценарий с разными world seed → сейвы различаются (достаточно факта расхождения; при редкой коллизии на маленьком мире увеличить срок/мир, не ослаблять assert). +- Encounter-путь: при входе якоря в локацию с encounter-таблицей два прогона с одним seed спавнят одинаковые встречи (состав, количество), с разными — допускается расхождение. + +Ожидаемая полка: unit или integration по месту (если нужен запущенный backend — integration; предпочтительно unit-уровень с прямой сборкой World, быстрее и без docker). + +## Implementation + +Тест-only задача; допустимы минимальные правки прод-кода, если сравнение `World.save()` упирается в недетерминированные артефакты (например, порядок dict-ов или генерируемые id) — такие находки фиксировать в тексте задачи и чинить точечно (сортировка на сериализации и т.п.). Если найдётся источник недетерминизма, не покрытый tasks 1-2, — это находка фазы: зафиксировать в sprint.md Decisions/Deferred. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation; RED здесь = падают до фиксов недетерминизма либо честно зелёные, если tasks 1-2 уже всё закрыли — тогда тест играет роль пина) +- [ ] Same-seed прогоны дают идентичный `World.save()` +- [ ] Different-seed прогоны расходятся +- [ ] Existing tests still pass (`make check-backend`) + +## Status + +`pending` From 5356c507be70eddd055815796a34e3cd08b3a922 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:39:16 +0300 Subject: [PATCH 03/25] sprint 021 phase 1 task 1: thread world seed into layers --- CLAUDE.md | 1 + docs/STATUS.md | 4 +- .../tasks/phase1-task1-world-seed-plumbing.md | 8 +- src/dnd_simulator/core/world.py | 2 + src/dnd_simulator/layers/ecology/layer.py | 3 + src/dnd_simulator/layers/entities/layer.py | 3 + src/dnd_simulator/service/game_service.py | 34 ++++- tests/unit/test_world_seed.py | 127 ++++++++++++++++++ 8 files changed, 177 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_world_seed.py diff --git a/CLAUDE.md b/CLAUDE.md index c1cde95a..13ccbfa3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -164,6 +164,7 @@ Kill reputation drop (`rules/reputation.py`): omniscient, delta scaled by victim - Requires `.env` with `OPENROUTER_API_KEY` for LLM features (only if NPCs use `ai: llm`) - `LLM_MODEL` env var selects model (required if `OPENROUTER_API_KEY` is set, no default) - `DND_LANGUAGE` env var selects game language (default: `ru`); locale files in `src/dnd_simulator/locale/` +- `DND_WORLD_SEED` env var seeds world simulation layers; when absent, `GameService` logs the generated seed. - Save files: `saves/` directory (JSON) - Backend API: `make serve` → http://localhost:8001/docs (Swagger UI) - Frontend: `make frontend` → http://localhost:5173 (entry point, proxies /api to :8001) diff --git a/docs/STATUS.md b/docs/STATUS.md index 6e15132e..079f60ac 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 1 — RNG threading & determinism (tasks generated) — 2026-07-10 +**Phase:** 1 — RNG threading & determinism (task 1 done, task 2 pending) — 2026-07-10 -Ready to start task 1. +Task 1 is done. Ready to start task 2. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md b/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md index 54d685b7..1a309a06 100644 --- a/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md +++ b/docs/sprints/021-save-schema/tasks/phase1-task1-world-seed-plumbing.md @@ -38,4 +38,10 @@ ## Status -`pending` +`done` + +## Developer Notes + +Added `DND_WORLD_SEED` handling in `GameService`, deterministic layer seed derivation, and `World.seed`. +`EcologyLayer` and `EntitiesLayer` now own constructor-seeded RNGs; politics/weather receive derived seeds through the existing constructors. +The RED point was the service-built world lacking `World.seed` and layer RNG plumbing; existing politics/weather seed behavior was already deterministic. diff --git a/src/dnd_simulator/core/world.py b/src/dnd_simulator/core/world.py index 8565647e..40b2fa5a 100644 --- a/src/dnd_simulator/core/world.py +++ b/src/dnd_simulator/core/world.py @@ -33,10 +33,12 @@ def __init__( layers: list[Layer], time: GameDateTime | None = None, location_graph: LocationGraph | None = None, + seed: int | None = None, ) -> None: from dnd_simulator.core.location import LocationGraph as _LocationGraph self.time = time or GameDateTime() + self.seed = seed self._layers = layers self.location_graph = location_graph or _LocationGraph() self._last_tick_time: dict[str, GameDateTime] = {layer.name: self.time for layer in layers} diff --git a/src/dnd_simulator/layers/ecology/layer.py b/src/dnd_simulator/layers/ecology/layer.py index 3cff043b..8657ab7a 100644 --- a/src/dnd_simulator/layers/ecology/layer.py +++ b/src/dnd_simulator/layers/ecology/layer.py @@ -6,6 +6,7 @@ from __future__ import annotations +import random from typing import TYPE_CHECKING import structlog @@ -34,6 +35,7 @@ def __init__( squads: list[Squad] | None = None, location_graph: LocationGraph | None = None, lairs: list[Lair] | None = None, + seed: int | None = None, ) -> None: self._squads: dict[str, Squad] = {} if squads: @@ -47,6 +49,7 @@ def __init__( self._last_move_time: dict[str, int] = {} # squad_id → game-time seconds of last move self._route_index: dict[str, int] = {} # squad_id → current index in route self._route_direction: dict[str, int] = {} # squad_id → +1 forward, -1 reverse + self._rng = random.Random(seed) @property def name(self) -> str: diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 1e57ebb7..674241c1 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -2,6 +2,7 @@ from __future__ import annotations +import random from collections import defaultdict from typing import TYPE_CHECKING, Any @@ -78,6 +79,7 @@ def __init__( summarizer: MemorySummarizer | None = None, monster_templates: dict[str, MonsterTemplate] | None = None, encounter_tables: dict[str, list[EncounterEntry]] | None = None, + seed: int | None = None, ) -> None: self._entities: dict[str, Entity] = {} self._location_log: dict[str, list[Event]] = defaultdict(list) @@ -87,6 +89,7 @@ def __init__( self._encounter_cooldowns: dict[str, int] = {} # location_id → last spawn time (seconds) self._creature_locations: dict[str, str] = {} # creature_id → last known location_id self._spawn_counter = 0 + self._rng = random.Random(seed) # Materialization tracking: squad_id → (creature_ids, original_strength, spawn_count) self._materialized_squads: dict[str, tuple[list[str], int, int]] = {} # Lair materialization tracking: lair_id -> (creature_ids, core_creature_id, minion_templates) diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index 1c6b4cbd..29ebb97a 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -1,6 +1,8 @@ from __future__ import annotations import contextlib +import os +import random import uuid from pathlib import Path @@ -52,6 +54,22 @@ logger = structlog.get_logger(domain="service") +_LAYER_SEED_ORDER = ("geography", "politics", "ecology", "entities") + + +def _resolve_world_seed() -> int: + raw = os.getenv("DND_WORLD_SEED") + if raw is not None: + return int(raw) + seed = random.SystemRandom().getrandbits(64) + logger.info("world_seed_generated", world_seed=seed) + return seed + + +def _derive_layer_seeds(world_seed: int) -> dict[str, int]: + rng = random.Random(world_seed) + return {layer_name: rng.getrandbits(64) for layer_name in _LAYER_SEED_ORDER} + def _flatten_region_defaults[T]( locations: list[Location], @@ -112,6 +130,8 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe "All 5 layers must be defined before starting a session." ) meta = load_world_meta_from_manifest(world_path, lang=lang) + world_seed = _resolve_world_seed() + layer_seeds = _derive_layer_seeds(world_seed) regions = load_world(layer_paths["geography"], lang=lang) nations = load_nations(layer_paths["politics"], lang=lang) settlements = load_settlements(layer_paths["settlements"], lang=lang) @@ -143,7 +163,11 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe # Players are created via API (create_player), not from templates entities: list[Entity] = [*npcs] - geography = GeographyLayer(regions=regions, location_graph=location_graph) + geography = GeographyLayer( + regions=regions, + weather_seed=layer_seeds["geography"], + location_graph=location_graph, + ) settlements_layer = SettlementsLayer(settlements=settlements, region_terrains=region_terrains) politics = PoliticsLayer( nations=nations, @@ -152,6 +176,7 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe region_income_fn=settlements_layer.get_region_income, faction_relations=faction_data.relations, faction_names=faction_data.names, + seed=layer_seeds["politics"], ) summarizer = None if self._llm: @@ -162,7 +187,10 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe for squad in squads.values(): squad.member_crs = [monster_templates[tid].cr for tid in squad.member_templates] ecology_layer = EcologyLayer( - squads=list(squads.values()), location_graph=location_graph, lairs=list(lairs.values()) + squads=list(squads.values()), + location_graph=location_graph, + lairs=list(lairs.values()), + seed=layer_seeds["ecology"], ) # Battle maps and encounter tables both resolve region → location at load # time: a region-level declaration is the default for every location in @@ -181,6 +209,7 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe monster_templates=monster_templates, encounter_tables=effective_encounters, battle_map_configs=battle_map_configs, + seed=layer_seeds["entities"], ) # Assign brains via factory (content_loader only parses data, not brains) @@ -190,6 +219,7 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe layers=[geography, politics, settlements_layer, ecology_layer, entities_layer], time=GameDateTime(year=1490, month=6, day=1, hour=10), location_graph=location_graph, + seed=world_seed, ) # Initial tick to set weather/temperature diff --git a/tests/unit/test_world_seed.py b/tests/unit/test_world_seed.py new file mode 100644 index 00000000..b0478166 --- /dev/null +++ b/tests/unit/test_world_seed.py @@ -0,0 +1,127 @@ +"""World seed threading and deterministic layer RNG tests.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from dnd_simulator.core.models import GameDateTime, TimeDelta +from dnd_simulator.layers.ecology.layer import EcologyLayer +from dnd_simulator.layers.entities.layer import EntitiesLayer +from dnd_simulator.layers.geography.models import Region, Season, TerrainType, WeatherCondition +from dnd_simulator.layers.geography.weather import WeatherEngine +from dnd_simulator.layers.politics.layer import PoliticsLayer +from dnd_simulator.layers.politics.models import Leader, LeaderTrait, Nation +from dnd_simulator.service.game_service import GameService +from dnd_simulator.storage.store import JsonFileStore + + +def _make_nations() -> list[Nation]: + return [ + Nation( + id="alpha", + name="Alpha", + regions=["alpha_region"], + wealth=60.0, + military=80.0, + stability=35.0, + leader=Leader(name="Aster", age=76, trait=LeaderTrait.MILITARIST), + ), + Nation( + id="beta", + name="Beta", + regions=["beta_region"], + wealth=55.0, + military=55.0, + stability=40.0, + leader=Leader(name="Beryl", age=74, trait=LeaderTrait.DIPLOMAT), + ), + ] + + +def _make_politics(seed: int) -> PoliticsLayer: + return PoliticsLayer( + nations=_make_nations(), + region_adjacency={"alpha_region": ["beta_region"], "beta_region": ["alpha_region"]}, + seed=seed, + ) + + +def _tick_politics(layer: PoliticsLayer, months: int) -> list[dict[str, object]]: + snapshots: list[dict[str, object]] = [] + + def query_fn(layer_name: str, query: object) -> object: + raise AssertionError(f"unexpected query to {layer_name}: {query}") + + def emit_fn(event: object) -> object: + raise AssertionError(f"unexpected event emit: {event}") + + for month in range(months): + time = GameDateTime(year=1490, month=(month % 12) + 1, day=1, hour=0) + layer.tick(TimeDelta(seconds=2_592_000), time, query_fn, emit_fn) # type: ignore[arg-type] + snapshots.append(layer.get_state()) + return snapshots + + +def _make_region() -> Region: + return Region( + id="greenvale", + name="Greenvale", + latitude=45.0, + longitude=0.0, + elevation=0.0, + terrain=TerrainType.PLAINS, + water_proximity=0.3, + weather=WeatherCondition.CLEAR, + ) + + +def _weather_sequence(seed: int) -> list[WeatherCondition]: + engine = WeatherEngine(seed=seed) + region = _make_region() + sequence: list[WeatherCondition] = [] + for _ in range(20): + region.weather = engine.next_weather(region, Season.SUMMER, 20.0) + sequence.append(region.weather) + return sequence + + +def test_politics_seed_replays_same_outcomes_and_different_seed_diverges() -> None: + assert _tick_politics(_make_politics(seed=42), months=18) == _tick_politics(_make_politics(seed=42), months=18) + assert _tick_politics(_make_politics(seed=42), months=18) != _tick_politics(_make_politics(seed=43), months=18) + + +def test_weather_seed_replays_same_sequence_and_different_seed_diverges() -> None: + assert _weather_sequence(42) == _weather_sequence(42) + assert _weather_sequence(42) != _weather_sequence(43) + + +def test_game_service_threads_world_seed_to_layer_streams(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + from dnd_simulator.layers.geography.layer import GeographyLayer + + monkeypatch.setenv("DND_WORLD_SEED", "42") + first = GameService(store=JsonFileStore(tmp_path / "saves1")) + second = GameService(store=JsonFileStore(tmp_path / "saves2")) + + world_a = first.start_game("sword_vale").world + world_b = second.start_game("sword_vale").world + + assert world_a.seed == 42 + assert world_b.seed == 42 + + samples_a = { + "geography": tuple(world_a.get_layer(GeographyLayer)._weather._rng.random() for _ in range(3)), # type: ignore[attr-defined] + "politics": tuple(world_a.get_layer(PoliticsLayer)._rng.random() for _ in range(3)), # type: ignore[attr-defined] + "ecology": tuple(world_a.get_layer(EcologyLayer)._rng.random() for _ in range(3)), # type: ignore[attr-defined] + "entities": tuple(world_a.get_layer(EntitiesLayer)._rng.random() for _ in range(3)), # type: ignore[attr-defined] + } + samples_b = { + "geography": tuple(world_b.get_layer(GeographyLayer)._weather._rng.random() for _ in range(3)), # type: ignore[attr-defined] + "politics": tuple(world_b.get_layer(PoliticsLayer)._rng.random() for _ in range(3)), # type: ignore[attr-defined] + "ecology": tuple(world_b.get_layer(EcologyLayer)._rng.random() for _ in range(3)), # type: ignore[attr-defined] + "entities": tuple(world_b.get_layer(EntitiesLayer)._rng.random() for _ in range(3)), # type: ignore[attr-defined] + } + + assert samples_a == samples_b + assert len(set(samples_a.values())) == len(samples_a) From 9f08a3767f3074cda0666c7e023cd3c29b7cfdce Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:45:50 +0300 Subject: [PATCH 04/25] sprint 021 phase 1 task 2: move world randomness to layer rng --- docs/STATUS.md | 4 +- .../phase1-task2-bare-random-migration.md | 8 +- src/dnd_simulator/layers/ecology/lairs.py | 7 +- src/dnd_simulator/layers/ecology/layer.py | 5 +- src/dnd_simulator/layers/ecology/movement.py | 7 +- .../layers/ecology/squad_combat.py | 13 +- .../layers/entities/activation_manager.py | 3 + .../layers/entities/encounters.py | 5 +- src/dnd_simulator/layers/entities/layer.py | 1 + tests/unit/test_purity_determinism.py | 20 +-- tests/unit/test_region_encounters.py | 19 ++- tests/unit/test_spawn_engine.py | 115 +++++++++--------- tests/unit/test_squad_movement.py | 86 ++++++++++++- tests/unit/test_time_of_day_encounters.py | 19 ++- 14 files changed, 208 insertions(+), 104 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 079f60ac..55aa592e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 1 — RNG threading & determinism (task 1 done, task 2 pending) — 2026-07-10 +**Phase:** 1 — RNG threading & determinism (task 2 done, task 3 pending) — 2026-07-10 -Task 1 is done. Ready to start task 2. +Task 2 is done. Ready to start task 3. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md b/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md index 97675ed5..2fbaed04 100644 --- a/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md +++ b/docs/sprints/021-save-schema/tasks/phase1-task2-bare-random-migration.md @@ -36,4 +36,10 @@ ## Status -`pending` +`done` + +## Developer Notes + +Encounter rolls, squad roam movement, squad retreat, and lair depletion now consume the owning layer RNG instead of process-global `random` or dice RNG. +Existing spawn tests that mocked module-level `random` were updated to use deterministic encounter thresholds and fixed counts. +The lair depletion tests now seed `EcologyLayer` directly; this is the intended contract after moving world simulation off `rules.dice`. diff --git a/src/dnd_simulator/layers/ecology/lairs.py b/src/dnd_simulator/layers/ecology/lairs.py index ba3e8fc5..d37f507f 100644 --- a/src/dnd_simulator/layers/ecology/lairs.py +++ b/src/dnd_simulator/layers/ecology/lairs.py @@ -7,17 +7,18 @@ from __future__ import annotations +import random + import structlog from dnd_simulator.core.lair import Lair, LairState from dnd_simulator.core.models import Event -from dnd_simulator.rules.dice import get_global_rng from dnd_simulator.rules.lairs import should_deplete logger = structlog.get_logger(domain="ecology") -def apply_lair_dematerialize(lairs: dict[str, Lair], event: Event) -> None: +def apply_lair_dematerialize(lairs: dict[str, Lair], event: Event, rng: random.Random) -> None: """Sync a lair's surviving population from a finished visit.""" lair_id = str(event.data["lair_id"]) lair = lairs.get(lair_id) @@ -30,7 +31,7 @@ def apply_lair_dematerialize(lairs: dict[str, Lair], event: Event) -> None: lair.last_respawn_time = int(event.data.get("at_seconds", lair.last_respawn_time)) # Depletion decision is a pure rule; the roll is generated here and injected. - roll = get_global_rng().random() + roll = rng.random() if should_deplete(lair, roll): lair.state = LairState.DEPLETED diff --git a/src/dnd_simulator/layers/ecology/layer.py b/src/dnd_simulator/layers/ecology/layer.py index 8657ab7a..d85f12ee 100644 --- a/src/dnd_simulator/layers/ecology/layer.py +++ b/src/dnd_simulator/layers/ecology/layer.py @@ -71,7 +71,7 @@ def tick(self, delta: TimeDelta, time: GameDateTime, query_fn: QueryFn, emit_fn: if now - last < squad.tick_interval: logger.debug("squad_skip", squad_id=squad.id, cooldown_remaining=squad.tick_interval - (now - last)) continue - moved = move_squad(squad, self._route_index, self._route_direction, self._location_graph) + moved = move_squad(squad, self._route_index, self._route_direction, self._location_graph, self._rng) logger.info( "squad_tick", squad_id=squad.id, @@ -105,6 +105,7 @@ def tick(self, delta: TimeDelta, time: GameDateTime, query_fn: QueryFn, emit_fn: self._route_index, self._route_direction, query_fn, + self._rng, ) ) @@ -131,7 +132,7 @@ def handle_event(self, event: Event, query_fn: QueryFn, emit_fn: EmitFn) -> Acti self._squads[squad_id].strength = new_strength logger.info("squad_strength_updated", squad_id=squad_id, new_strength=new_strength) elif event.event_type is EventType.LAIR_DEMATERIALIZED: - apply_lair_dematerialize(self._lairs, event) + apply_lair_dematerialize(self._lairs, event, self._rng) return ActionResult() def query(self, query: Query) -> Answer: diff --git a/src/dnd_simulator/layers/ecology/movement.py b/src/dnd_simulator/layers/ecology/movement.py index 7d4c2453..8041fb6e 100644 --- a/src/dnd_simulator/layers/ecology/movement.py +++ b/src/dnd_simulator/layers/ecology/movement.py @@ -26,6 +26,7 @@ def move_squad( route_index: dict[str, int], route_direction: dict[str, int], location_graph: LocationGraph | None, + rng: random.Random, ) -> tuple[str, str] | None: """Move a squad according to its behavior. Returns (from, to) or None if no move.""" if squad.behavior is SquadBehavior.GUARD: @@ -33,7 +34,7 @@ def move_squad( if squad.behavior in _ROUTE_BEHAVIORS: return _move_route(squad, route_index, route_direction) if squad.behavior in _ROAM_BEHAVIORS: - return _move_roam(squad, location_graph) + return _move_roam(squad, location_graph, rng) return None @@ -69,7 +70,7 @@ def _move_route(squad: Squad, route_index: dict[str, int], route_direction: dict return (old, squad.current_location_id) -def _move_roam(squad: Squad, location_graph: LocationGraph | None) -> tuple[str, str] | None: +def _move_roam(squad: Squad, location_graph: LocationGraph | None, rng: random.Random) -> tuple[str, str] | None: """Move to a random neighbor within territory.""" if location_graph is None: return None @@ -80,5 +81,5 @@ def _move_roam(squad: Squad, location_graph: LocationGraph | None) -> tuple[str, return None old = squad.current_location_id - squad.current_location_id = random.choice(candidates) + squad.current_location_id = rng.choice(candidates) return (old, squad.current_location_id) diff --git a/src/dnd_simulator/layers/ecology/squad_combat.py b/src/dnd_simulator/layers/ecology/squad_combat.py index dc237f06..5f01f835 100644 --- a/src/dnd_simulator/layers/ecology/squad_combat.py +++ b/src/dnd_simulator/layers/ecology/squad_combat.py @@ -33,6 +33,7 @@ def resolve_squad_combat( route_index: dict[str, int], route_direction: dict[str, int], query_fn: QueryFn, + rng: random.Random, ) -> list[Event]: """Find hostile squads at the same location and resolve combat; remove destroyed squads.""" events: list[Event] = [] @@ -57,7 +58,7 @@ def resolve_squad_combat( if not _are_hostile(a, b, query_fn): continue - events.append(_fight_squads(a, b, location_id, location_graph)) + events.append(_fight_squads(a, b, location_id, location_graph, rng)) fought.add(a.id) fought.add(b.id) break # each squad fights at most once per tick @@ -81,7 +82,13 @@ def _are_hostile(a: Squad, b: Squad, query_fn: QueryFn) -> bool: return query_faction_relation(query_fn, a.faction_id, b.faction_id) is FactionRelation.HOSTILE -def _fight_squads(a: Squad, b: Squad, location_id: str, location_graph: LocationGraph | None) -> Event: +def _fight_squads( + a: Squad, + b: Squad, + location_id: str, + location_graph: LocationGraph | None, + rng: random.Random, +) -> Event: """Resolve combat between two squads. Loser retreats.""" # Model each squad as encounters for the other b_encounters = [TriggeredEncounter(cr=cr, count=1) for cr in b.member_crs] if b.member_crs else [] @@ -103,7 +110,7 @@ def _fight_squads(a: Squad, b: Squad, location_id: str, location_graph: Location if loser.strength > 0 and location_graph is not None: edges = location_graph.neighbors(location_id) if edges: - loser.current_location_id = random.choice(edges).target_id + loser.current_location_id = rng.choice(edges).target_id logger.info( "squad_combat", diff --git a/src/dnd_simulator/layers/entities/activation_manager.py b/src/dnd_simulator/layers/entities/activation_manager.py index 1e54a3bd..ba165638 100644 --- a/src/dnd_simulator/layers/entities/activation_manager.py +++ b/src/dnd_simulator/layers/entities/activation_manager.py @@ -7,6 +7,7 @@ from __future__ import annotations +import random from typing import TYPE_CHECKING import structlog @@ -43,6 +44,7 @@ def __init__( creature_locations: dict[str, str], materialized_squads: dict[str, tuple[list[str], int, int]], materialized_lairs: dict[str, tuple[list[str], str | None, list[str]]], + rng: random.Random, ) -> None: self._entities = entities self._location_log = location_log @@ -53,6 +55,7 @@ def __init__( self._creature_locations = creature_locations self._materialized_squads = materialized_squads self._materialized_lairs = materialized_lairs + self._rng = rng self._spawn_counter = 0 def update_activation( diff --git a/src/dnd_simulator/layers/entities/encounters.py b/src/dnd_simulator/layers/entities/encounters.py index e4ddc5ab..4102247a 100644 --- a/src/dnd_simulator/layers/entities/encounters.py +++ b/src/dnd_simulator/layers/entities/encounters.py @@ -7,7 +7,6 @@ from __future__ import annotations -import random from typing import TYPE_CHECKING import structlog @@ -109,7 +108,7 @@ def roll_encounters( is_day=is_day, ) continue - roll = random.random() + roll = mgr._rng.random() if roll >= entry.chance: logger.info( "encounter_roll_miss", @@ -120,7 +119,7 @@ def roll_encounters( ) continue template = mgr._monster_templates[entry.template_id] - count = random.randint(entry.count_min, entry.count_max) + count = mgr._rng.randint(entry.count_min, entry.count_max) for _ in range(count): mgr._spawn_counter += 1 instance_id = f"{template.id}_{mgr._spawn_counter}" diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 674241c1..6a3bca31 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -109,6 +109,7 @@ def __init__( self._creature_locations, self._materialized_squads, self._materialized_lairs, + self._rng, ) self._query_handler = QueryHandler( self._entities, diff --git a/tests/unit/test_purity_determinism.py b/tests/unit/test_purity_determinism.py index 2f7a1eb4..26bbfd86 100644 --- a/tests/unit/test_purity_determinism.py +++ b/tests/unit/test_purity_determinism.py @@ -332,13 +332,11 @@ def _dematerialize_event(self, lair_id: str = "warren", alive_members: list[str] def test_depletion_transitions_active_to_depleted(self) -> None: from dnd_simulator.layers.ecology.layer import EcologyLayer - from dnd_simulator.rules.dice import set_global_seed - # Seed so the roll is below 0.5 - set_global_seed(1) # random.Random(1).random() ≈ 0.134 — below 0.5 + # Seed so the ecology-layer roll is below 0.5. lair = _coreless_lair(alive_members=[], depletion_chance=0.5) - layer = EcologyLayer(lairs=[lair]) + layer = EcologyLayer(lairs=[lair], seed=1) event = self._dematerialize_event() layer.handle_event(event, lambda q: None, lambda e: None) # type: ignore[arg-type] @@ -346,16 +344,11 @@ def test_depletion_transitions_active_to_depleted(self) -> None: def test_no_depletion_when_roll_above_chance(self) -> None: from dnd_simulator.layers.ecology.layer import EcologyLayer - from dnd_simulator.rules.dice import set_global_seed - - # Seed so the roll is above 0.5 - set_global_seed(0) # random.Random(0).random() ≈ 0.844 — above 0.5 - import random as _random - _random.Random(0).random() # just to confirm seed behaviour + # Seed so the ecology-layer roll is above 0.5. lair = _coreless_lair(alive_members=[], depletion_chance=0.5) - layer = EcologyLayer(lairs=[lair]) + layer = EcologyLayer(lairs=[lair], seed=0) event = self._dematerialize_event() layer.handle_event(event, lambda q: None, lambda e: None) # type: ignore[arg-type] @@ -363,12 +356,9 @@ def test_no_depletion_when_roll_above_chance(self) -> None: def test_depletion_survives_save_load(self) -> None: from dnd_simulator.layers.ecology.layer import EcologyLayer - from dnd_simulator.rules.dice import set_global_seed - - set_global_seed(1) # below 0.5 lair = _coreless_lair(alive_members=[], depletion_chance=0.5) - layer = EcologyLayer(lairs=[lair]) + layer = EcologyLayer(lairs=[lair], seed=1) event = self._dematerialize_event() layer.handle_event(event, lambda q: None, lambda e: None) # type: ignore[arg-type] assert lair.state is LairState.DEPLETED diff --git a/tests/unit/test_region_encounters.py b/tests/unit/test_region_encounters.py index f99c4149..312b6748 100644 --- a/tests/unit/test_region_encounters.py +++ b/tests/unit/test_region_encounters.py @@ -7,8 +7,8 @@ the runtime activation path is unchanged — these tests drive that real path and observe the spawn in the world rather than poking internals. -``random`` is mocked so the roll is deterministic: ``random.random() -> 0.0`` -always clears the chance gate and ``random.randint -> 1`` fixes the count at one. +The entities layer RNG is replaced with a deterministic test RNG so the roll always +clears the chance gate and fixes the count at one. The world is ``test_vale``: - region ``crossroads`` carries a regional goblin table; @@ -19,7 +19,6 @@ from __future__ import annotations from pathlib import Path -from unittest.mock import patch from dnd_simulator.core.character import Creature from dnd_simulator.core.player import PlayerCharacter @@ -33,6 +32,14 @@ FIGHTER_SCORES = {"str": 15, "dex": 10, "con": 14, "int": 8, "wis": 12, "cha": 8} +class _EncounterHitRng: + def random(self) -> float: + return 0.0 + + def randint(self, start: int, end: int) -> int: + return start + + def _session_with_player(tmp_path: Path, location: str) -> GameSession: svc = GameService(store=JsonFileStore(tmp_path / "saves"), content_dir=CONTENT_DIR) session = svc.start_game("test_vale") @@ -60,10 +67,12 @@ def _entities(session: GameSession) -> EntitiesLayer: def _activate(session: GameSession) -> None: """Run one activation pass with a guaranteed encounter hit (count fixed at 1).""" ents = _entities(session) + rng = _EncounterHitRng() + ents._rng = rng # type: ignore[assignment] + ents._activation._rng = rng # type: ignore[assignment] qfn = session.world.make_query_fn("entities") efn = session.world.make_emit_fn("entities") - with patch("random.random", return_value=0.0), patch("random.randint", return_value=1): - ents.update_activation(session.world.time, query_fn=qfn, emit_fn=efn) + ents.update_activation(session.world.time, query_fn=qfn, emit_fn=efn) def _monster_names_at(session: GameSession, location_id: str) -> list[str]: diff --git a/tests/unit/test_spawn_engine.py b/tests/unit/test_spawn_engine.py index 1c204588..3d630435 100644 --- a/tests/unit/test_spawn_engine.py +++ b/tests/unit/test_spawn_engine.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import patch +import random from dnd_simulator.core.character import ( Ability, @@ -52,32 +52,66 @@ def _make_layer( player: PlayerCharacter, templates: dict[str, MonsterTemplate] | None = None, encounters: dict[str, list[EncounterEntry]] | None = None, + seed: int | None = None, ) -> EntitiesLayer: return EntitiesLayer( entities=[player], monster_templates=templates or {}, encounter_tables=encounters or {}, + seed=seed, ) +def _encounter_sequence(seed: int, *, perturb_global: bool = False) -> list[int]: + template = _make_template() + encounters = {"forest": [EncounterEntry(template_id="goblin", chance=0.65, count_min=1, count_max=3)]} + player = _make_player(location="tavern") + layer = _make_layer(player, templates={"goblin": template}, encounters=encounters, seed=seed) + spawned_counts: list[int] = [] + + for step in range(8): + player.location_id = "forest" + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10 + step, minute=15)) + spawned = [ + e for e in layer._entities.values() if isinstance(e, Creature) and e.temporary and e.location_id == "forest" + ] + spawned_counts.append(len(spawned)) + for creature in spawned: + layer.remove_entity(creature.id) + + player.location_id = "tavern" + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10 + step, minute=30)) + if perturb_global: + random.seed(10_000 + step) + for _ in range(5): + random.random() + + return spawned_counts + + +class TestEncounterSeededRng: + def test_seeded_encounter_rolls_replay_and_different_seed_diverges(self) -> None: + assert _encounter_sequence(42) == _encounter_sequence(42) + assert _encounter_sequence(42) != _encounter_sequence(43) + + def test_encounter_rolls_ignore_global_random_state(self) -> None: + assert _encounter_sequence(42) == _encounter_sequence(42, perturb_global=True) + + class TestSpawnOnPlayerArrival: """When player enters a location with encounters, monsters spawn.""" def test_spawn_on_successful_roll(self) -> None: """RNG success → creatures spawned at player location with template stats.""" template = _make_template() - encounters = {"forest": [EncounterEntry(template_id="goblin", chance=0.5, count_min=2, count_max=2)]} + encounters = {"forest": [EncounterEntry(template_id="goblin", chance=1.0, count_min=2, count_max=2)]} player = _make_player(location="tavern") layer = _make_layer(player, templates={"goblin": template}, encounters=encounters) # Player travels to forest player.location_id = "forest" - # Roll succeeds (0.1 < 0.5 chance), count=2 (fixed) - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.1 # below 0.5 → success - mock_rng.randint.return_value = 2 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) # Should have player + 2 goblins creatures = [e for e in layer.get_active_creatures() if e.id != "player1"] @@ -94,15 +128,13 @@ def test_spawn_on_successful_roll(self) -> None: def test_no_spawn_on_failed_roll(self) -> None: """RNG failure → no creatures spawned.""" template = _make_template() - encounters = {"forest": [EncounterEntry(template_id="goblin", chance=0.3, count_min=1, count_max=3)]} + encounters = {"forest": [EncounterEntry(template_id="goblin", chance=0.0, count_min=1, count_max=3)]} player = _make_player(location="tavern") layer = _make_layer(player, templates={"goblin": template}, encounters=encounters) player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.9 # above 0.3 → fail - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) creatures = [e for e in layer.get_active_creatures() if e.id != "player1"] assert len(creatures) == 0 @@ -130,10 +162,7 @@ def test_spawned_creature_matches_template(self) -> None: player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) creatures = [e for e in layer.get_active_creatures() if e.id != "player1"] assert len(creatures) == 1 @@ -223,11 +252,8 @@ def test_no_reroll_within_cooldown(self) -> None: player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - time = GameDateTime(year=1490, month=6, day=1, hour=10) - layer.update_activation(time) + time = GameDateTime(year=1490, month=6, day=1, hour=10) + layer.update_activation(time) spawned_1 = [e for e in layer.get_active_creatures() if e.id != "player1"] assert len(spawned_1) == 1 @@ -237,10 +263,7 @@ def test_no_reroll_within_cooldown(self) -> None: layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=6)) player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=12)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=12)) # Still only 1 spawned (no re-roll) spawned_2 = [e for e in layer.get_active_creatures() if e.id != "player1"] @@ -255,11 +278,8 @@ def test_reroll_after_cooldown_expires(self) -> None: player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - time_first = GameDateTime(year=1490, month=6, day=1, hour=10) - layer.update_activation(time_first) + time_first = GameDateTime(year=1490, month=6, day=1, hour=10) + layer.update_activation(time_first) spawned_1 = [e for e in layer.get_active_creatures() if e.id != "player1"] assert len(spawned_1) == 1 @@ -272,11 +292,8 @@ def test_reroll_after_cooldown_expires(self) -> None: # Come back after cooldown (10+ minutes later) player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - time_after = GameDateTime(year=1490, month=6, day=1, hour=11, minute=15) - layer.update_activation(time_after) + time_after = GameDateTime(year=1490, month=6, day=1, hour=11, minute=15) + layer.update_activation(time_after) spawned_2 = [e for e in layer.get_active_creatures() if e.id != "player1"] assert len(spawned_2) == 1 @@ -310,10 +327,7 @@ def test_active_npc_triggers_encounters(self) -> None: # Guard moves to forest (dangerous location) guard.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 2 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=6)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=6)) # Guard is now dormant (no player at forest), but encounters should have spawned spawned = [ @@ -347,10 +361,7 @@ def test_dormant_creature_does_not_trigger_encounters(self) -> None: # Wanderer moves to forest while dormant creature.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) # No encounter spawned (dormant creature shouldn't trigger) spawned = [ @@ -378,20 +389,14 @@ def test_cooldown_shared_between_player_and_npc(self) -> None: # Player moves to forest → triggers encounter player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) spawned_count_1 = sum(1 for e in layer._entities.values() if isinstance(e, Creature) and e.temporary) assert spawned_count_1 == 1 # Guard moves to forest within cooldown guard.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=1)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=1)) # Still only 1 spawned (cooldown blocks second roll) spawned_count_2 = sum(1 for e in layer._entities.values() if isinstance(e, Creature) and e.temporary) @@ -414,10 +419,7 @@ def test_temporary_spawn_does_not_self_trigger(self) -> None: # Player enters forest → spawns 1 goblin player.location_id = "forest" - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10)) spawned = [e for e in layer._entities.values() if isinstance(e, Creature) and e.temporary] assert len(spawned) == 1 @@ -427,10 +429,7 @@ def test_temporary_spawn_does_not_self_trigger(self) -> None: goblin.location_id = "swamp" goblin.active = True - with patch("dnd_simulator.layers.entities.encounters.random") as mock_rng: - mock_rng.random.return_value = 0.0 - mock_rng.randint.return_value = 1 - layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=6)) + layer.update_activation(GameDateTime(year=1490, month=6, day=1, hour=10, minute=0, second=6)) # No new spawns at swamp — temporary creatures don't trigger encounters swamp_spawns = [ diff --git a/tests/unit/test_squad_movement.py b/tests/unit/test_squad_movement.py index 3295e361..80bd1b08 100644 --- a/tests/unit/test_squad_movement.py +++ b/tests/unit/test_squad_movement.py @@ -2,7 +2,7 @@ from __future__ import annotations -from unittest.mock import patch +import random from dnd_simulator.core.location import Location, LocationEdge, LocationGraph from dnd_simulator.core.models import Answer, EventType, FactionRelation, GameDateTime, Query, QueryType, TimeDelta @@ -30,6 +30,17 @@ def _linear_graph() -> LocationGraph: ) +def _branching_graph() -> LocationGraph: + return LocationGraph( + [ + _make_location("A", ["B", "C", "D"]), + _make_location("B", ["A", "C", "D"]), + _make_location("C", ["A", "B", "D"]), + _make_location("D", ["A", "B", "C"]), + ] + ) + + def _make_squad( squad_id: str = "patrol_1", location: str = "A", @@ -137,9 +148,7 @@ def test_roam_moves_within_territory(self) -> None: graph = _linear_graph() layer = EcologyLayer(squads=[squad], location_graph=graph) - with patch("dnd_simulator.layers.ecology.movement.random") as mock_random: - mock_random.choice.return_value = "B" - _tick_layer(layer, seconds=3600, hour=1) + _tick_layer(layer, seconds=3600, hour=1) assert squad.current_location_id == "B" @@ -158,6 +167,45 @@ def test_roam_stays_if_no_territory_neighbor(self) -> None: _tick_layer(layer, seconds=3600, hour=1) assert squad.current_location_id == "C" + def test_seeded_roam_route_replays_and_different_seed_diverges(self) -> None: + def route(seed: int) -> list[str]: + squad = _make_squad( + "wolves_1", + location="A", + behavior=SquadBehavior.ROAM, + territory=["A", "B", "C", "D"], + ) + layer = EcologyLayer(squads=[squad], location_graph=_branching_graph(), seed=seed) + result: list[str] = [] + for hour in range(1, 10): + _tick_layer(layer, seconds=3600, hour=hour) + result.append(squad.current_location_id) + return result + + assert route(42) == route(42) + assert route(42) != route(43) + + def test_roam_ignores_global_random_state(self) -> None: + def route(*, perturb_global: bool) -> list[str]: + squad = _make_squad( + "wolves_1", + location="A", + behavior=SquadBehavior.ROAM, + territory=["A", "B", "C", "D"], + ) + layer = EcologyLayer(squads=[squad], location_graph=_branching_graph(), seed=42) + result: list[str] = [] + for hour in range(1, 10): + _tick_layer(layer, seconds=3600, hour=hour) + result.append(squad.current_location_id) + if perturb_global: + random.seed(20_000 + hour) + for _ in range(5): + random.random() + return result + + assert route(perturb_global=False) == route(perturb_global=True) + class TestGuardMovement: """Guard squad never moves.""" @@ -211,6 +259,36 @@ def test_hostile_squads_fight_and_loser_retreats(self) -> None: combat_events = [e for e in events if e.event_type is EventType.SQUAD_COMBAT] assert len(combat_events) >= 1 + def test_seeded_retreat_replays_and_ignores_global_random_state(self) -> None: + def retreat(seed: int, *, perturb_global: bool) -> str: + strong = _make_squad( + "strong", + location="A", + faction="kingdom", + strength=6, + behavior=SquadBehavior.GUARD, + member_crs=[0.5, 0.5], + ) + weak = _make_squad( + "weak", + location="A", + faction="bandits", + strength=2, + behavior=SquadBehavior.GUARD, + member_crs=[0.25, 0.25], + ) + if perturb_global: + random.seed(30_000 + seed) + for _ in range(7): + random.random() + layer = EcologyLayer(squads=[strong, weak], location_graph=_branching_graph(), seed=seed) + _tick_layer(layer, seconds=3600, hour=1, query_fn=_faction_query_fn({("bandits", "kingdom")})) + return weak.current_location_id + + assert retreat(42, perturb_global=False) == retreat(42, perturb_global=False) + assert retreat(42, perturb_global=False) == retreat(42, perturb_global=True) + assert retreat(42, perturb_global=False) != retreat(43, perturb_global=False) + def test_destroyed_squad_is_removed(self) -> None: strong = _make_squad( "strong", diff --git a/tests/unit/test_time_of_day_encounters.py b/tests/unit/test_time_of_day_encounters.py index e75cc301..e4bdc731 100644 --- a/tests/unit/test_time_of_day_encounters.py +++ b/tests/unit/test_time_of_day_encounters.py @@ -6,8 +6,8 @@ geography layer via the ``IS_DAYLIGHT`` query, so these tests drive the real activation path and just vary ``world.time`` to switch phase. -``random`` is mocked so the chance gate always clears and the count is fixed at -one; the only variable under test is the clock. The world is ``test_vale``: +The entities layer RNG is replaced with a deterministic test RNG so the chance +gate always clears and the count is fixed at one; the only variable under test is the clock. The world is ``test_vale``: - ``night_hollow`` (darkwood, latitude 45) has a night-only bandit table; - ``crossroads`` carries an untagged regional goblin table. @@ -19,7 +19,6 @@ from dataclasses import replace from pathlib import Path -from unittest.mock import patch from dnd_simulator.core.character import Creature from dnd_simulator.core.player import PlayerCharacter @@ -33,6 +32,14 @@ FIGHTER_SCORES = {"str": 15, "dex": 10, "con": 14, "int": 8, "wis": 12, "cha": 8} +class _EncounterHitRng: + def random(self) -> float: + return 0.0 + + def randint(self, start: int, end: int) -> int: + return start + + def _session_with_player(tmp_path: Path, location: str) -> GameSession: svc = GameService(store=JsonFileStore(tmp_path / "saves"), content_dir=CONTENT_DIR) session = svc.start_game("test_vale") @@ -65,10 +72,12 @@ def _set_hour(session: GameSession, hour: int) -> None: def _activate(session: GameSession) -> None: """Run one activation pass with a guaranteed chance hit (count fixed at 1).""" ents = _entities(session) + rng = _EncounterHitRng() + ents._rng = rng # type: ignore[assignment] + ents._activation._rng = rng # type: ignore[assignment] qfn = session.world.make_query_fn("entities") efn = session.world.make_emit_fn("entities") - with patch("random.random", return_value=0.0), patch("random.randint", return_value=1): - ents.update_activation(session.world.time, query_fn=qfn, emit_fn=efn) + ents.update_activation(session.world.time, query_fn=qfn, emit_fn=efn) def _monster_names_at(session: GameSession, location_id: str) -> list[str]: From bafd788f0ab119dbb04b959c366b3318a646b1df Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:48:23 +0300 Subject: [PATCH 05/25] sprint 021 phase 1 task 3: pin world determinism --- docs/STATUS.md | 4 +- docs/sprints/021-save-schema/sprint.md | 1 + .../phase1-task3-world-determinism-test.md | 8 +- src/dnd_simulator/core/world.py | 3 + tests/unit/test_world_seed.py | 89 +++++++++++++++++++ 5 files changed, 102 insertions(+), 3 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 55aa592e..adc1de31 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 1 — RNG threading & determinism (task 2 done, task 3 pending) — 2026-07-10 +**Phase:** 1 — RNG threading & determinism (tasks 1-3 done) — 2026-07-10 -Task 2 is done. Ready to start task 3. +Phase 1 tasks are done. Ready to close the phase. ### Phases diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index e6b916b2..bc921ec8 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -52,6 +52,7 @@ _(генерируются отдельно перед началом фазы)_ - Legacy-форматы сейва (без `meta`, flat-world, top-level `player`) удаляются без миграции: сейвы — dev-артефакты, `schema_version=1` стартует с чистого листа (2026-07-10). - RNG-паттерн: унифицируем на layer-constructor-owned `random.Random(seed)` (существующий Pattern B), сиды раздаёт World/сессия из одного world-seed; dice-RNG (`rules/dice.py`) остаётся отдельным потоком, но его состояние тоже попадает в сейв (2026-07-10). +- Legacy `World.save()` уже пишет `seed`, чтобы разные world-seed snapshots различались до ввода Pydantic save schema; полное состояние RNG остаётся задачей phase 2 (2026-07-10). ## Deferred diff --git a/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md b/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md index 78f04964..29319e5f 100644 --- a/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md +++ b/docs/sprints/021-save-schema/tasks/phase1-task3-world-determinism-test.md @@ -29,4 +29,10 @@ ## Status -`pending` +`done` + +## Developer Notes + +Added capstone unit tests through real `GameService` assembly: same world seed plus dice seed gives identical month-long `World.save()`, different world seeds diverge, and same-seed encounter activation replays spawns. +The new different-seed save test initially failed on `test_vale`: the evolved persisted state could be identical because `World.save()` did not include the world seed. +Fixed that by adding `seed` to the legacy world save dict and restoring it in `World.load()`; full RNG state remains phase 2 schema work. diff --git a/src/dnd_simulator/core/world.py b/src/dnd_simulator/core/world.py index 40b2fa5a..2456d9e6 100644 --- a/src/dnd_simulator/core/world.py +++ b/src/dnd_simulator/core/world.py @@ -157,6 +157,7 @@ def save(self) -> dict[str, object]: last_ticks: dict[str, dict[str, int]] = {name: t.to_dict() for name, t in self._last_tick_time.items()} return { + "seed": self.seed, "time": self.time.to_dict(), "last_tick_times": last_ticks, "layers": {layer.name: layer.get_state() for layer in self._layers}, @@ -168,6 +169,8 @@ def load(self, data: dict[str, object]) -> None: assert isinstance(time_data, dict) # Backward compat handled in from_dict (old saves may lack 'second') self.time = GameDateTime.from_dict(time_data) + seed_data = data.get("seed") + self.seed = int(seed_data) if isinstance(seed_data, int | str) else None # Restore last tick times (fallback to current time for old saves) last_ticks_data = data.get("last_tick_times", {}) diff --git a/tests/unit/test_world_seed.py b/tests/unit/test_world_seed.py index b0478166..462338f7 100644 --- a/tests/unit/test_world_seed.py +++ b/tests/unit/test_world_seed.py @@ -6,16 +6,24 @@ import pytest +from dnd_simulator.core.character import Creature from dnd_simulator.core.models import GameDateTime, TimeDelta +from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.layers.ecology.layer import EcologyLayer from dnd_simulator.layers.entities.layer import EntitiesLayer +from dnd_simulator.layers.entities.models import Npc from dnd_simulator.layers.geography.models import Region, Season, TerrainType, WeatherCondition from dnd_simulator.layers.geography.weather import WeatherEngine from dnd_simulator.layers.politics.layer import PoliticsLayer from dnd_simulator.layers.politics.models import Leader, LeaderTrait, Nation +from dnd_simulator.rules.dice import set_global_seed from dnd_simulator.service.game_service import GameService +from dnd_simulator.service.session import GameSession from dnd_simulator.storage.store import JsonFileStore +CONTENT_DIR = Path(__file__).resolve().parents[2] / "content" +FIGHTER_SCORES = {"str": 15, "dex": 10, "con": 14, "int": 8, "wis": 12, "cha": 8} + def _make_nations() -> list[Nation]: return [ @@ -125,3 +133,84 @@ def test_game_service_threads_world_seed_to_layer_streams(monkeypatch: pytest.Mo assert samples_a == samples_b assert len(set(samples_a.values())) == len(samples_a) + + +def _world_save_after_month(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, world_seed: int) -> dict[str, object]: + monkeypatch.setenv("DND_WORLD_SEED", str(world_seed)) + set_global_seed(123) + service = GameService(store=JsonFileStore(tmp_path / f"saves-{world_seed}"), content_dir=CONTENT_DIR) + session = service.start_game("test_vale") + for _ in range(31 * 24): + session.world.advance_time(TimeDelta(seconds=3600)) + return session.world.save() + + +def test_same_world_seed_replays_full_world_save(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + save_a = _world_save_after_month(tmp_path / "a", monkeypatch, world_seed=42) + save_b = _world_save_after_month(tmp_path / "b", monkeypatch, world_seed=42) + + assert save_a == save_b + + +def test_different_world_seed_changes_full_world_save(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + save_a = _world_save_after_month(tmp_path / "a", monkeypatch, world_seed=42) + save_b = _world_save_after_month(tmp_path / "b", monkeypatch, world_seed=43) + + assert save_a != save_b + + +def _session_with_player( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + *, + world_seed: int, + location: str, +) -> GameSession: + monkeypatch.setenv("DND_WORLD_SEED", str(world_seed)) + set_global_seed(123) + service = GameService(store=JsonFileStore(tmp_path / f"saves-{world_seed}"), content_dir=CONTENT_DIR) + session = service.start_game("test_vale") + service.create_player( + session.session_id, + { + "name": "Scout", + "race": "human", + "class": "fighter", + "alignment": "true_neutral", + "ability_scores": FIGHTER_SCORES, + "fighting_style": "defense", + "start_location": location, + }, + ) + return session + + +def _activate(session: GameSession) -> None: + entities = session.world.get_layer(EntitiesLayer) + entities.update_activation( + session.world.time, + query_fn=session.world.make_query_fn("entities"), + emit_fn=session.world.make_emit_fn("entities"), + ) + + +def _spawned_names_at(session: GameSession, location_id: str) -> list[str]: + entities = session.world.get_layer(EntitiesLayer) + return sorted( + entity.name + for entity in entities._entities.values() + if isinstance(entity, Creature) + and not isinstance(entity, (PlayerCharacter, Npc)) + and entity.location_id == location_id + and entity.is_alive + ) + + +def test_same_world_seed_replays_encounter_spawns(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + session_a = _session_with_player(tmp_path / "a", monkeypatch, world_seed=42, location="crossroads_tavern") + session_b = _session_with_player(tmp_path / "b", monkeypatch, world_seed=42, location="crossroads_tavern") + + _activate(session_a) + _activate(session_b) + + assert _spawned_names_at(session_a, "crossroads_tavern") == _spawned_names_at(session_b, "crossroads_tavern") From 4ca43ae40bdeca824cfebf6ebc64b4d885edd67f Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:52:38 +0300 Subject: [PATCH 06/25] =?UTF-8?q?sprint=20021=20phase=201:=20close=20?= =?UTF-8?q?=E2=80=94=20world=20seed=20threaded,=20layers=20off=20global=20?= =?UTF-8?q?random,=20determinism=20pinned=20(unit=202398,=20integration=20?= =?UTF-8?q?160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/sprints/021-save-schema/sprint.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index bc921ec8..a07debd9 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -14,7 +14,7 @@ **Ссылки:** [simulation-core](../../brainstorms/simulation-core.md), [BACKLOG](../../BACKLOG.md#simulation-core-брейншторм-2026-07-04), [020 phase3 serialization dedup](../020-thermo-sweep/tasks/phase3-task1-serialization-dedup.md) -## Phase 1: RNG threading & determinism +## Phase 1: RNG threading & determinism ✓ Единый world-seed: RNG создаётся на уровне World/сессии и прокидывается в слои через конструкторы (расширение существующего паттерна `PoliticsLayer(seed)` / `WeatherEngine(seed)`; `EcologyLayer` получает seed впервые). Три bare-`random` сайта (encounters, roam movement, retreat) переводятся на слоевой RNG. `game_service` реально передаёт сиды (env `DND_WORLD_SEED`, по умолчанию случайный). Тесты детерминизма: одинаковый сид → идентичная эволюция мира (encounter rolls, движение сквадов, retreat), разный сид → расходится. @@ -52,6 +52,7 @@ _(генерируются отдельно перед началом фазы)_ - Legacy-форматы сейва (без `meta`, flat-world, top-level `player`) удаляются без миграции: сейвы — dev-артефакты, `schema_version=1` стартует с чистого листа (2026-07-10). - RNG-паттерн: унифицируем на layer-constructor-owned `random.Random(seed)` (существующий Pattern B), сиды раздаёт World/сессия из одного world-seed; dice-RNG (`rules/dice.py`) остаётся отдельным потоком, но его состояние тоже попадает в сейв (2026-07-10). +- Phase 1 закрыта без отдельного E2E: пользовательской поверхности нет (RNG plumbing), integration 160 passed; браузерный E2E идёт на закрытии phase 2/3 (2026-07-10). - Legacy `World.save()` уже пишет `seed`, чтобы разные world-seed snapshots различались до ввода Pydantic save schema; полное состояние RNG остаётся задачей phase 2 (2026-07-10). ## Deferred From 3c3f9e85d573b6c945bd4d3c9c9c037c260b3627 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:54:25 +0300 Subject: [PATCH 07/25] sprint 021 phase 2: task breakdown --- docs/sprints/021-save-schema/sprint.md | 4 +- .../tasks/phase2-task1-layer-state-models.md | 36 ++++++++++++++++++ .../phase2-task2-entities-state-model.md | 38 +++++++++++++++++++ .../tasks/phase2-task3-save-envelope.md | 36 ++++++++++++++++++ 4 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md create mode 100644 docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md create mode 100644 docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index a07debd9..ad38d02d 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -34,7 +34,9 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [Типизированные state-модели простых слоёв + RNG в состоянии](tasks/phase2-task1-layer-state-models.md) +2. [Entities-слой на Pydantic-моделях сейва](tasks/phase2-task2-entities-state-model.md) +3. [SaveGame-конверт, schema_version=1, единый путь загрузки](tasks/phase2-task3-save-envelope.md) ## Phase 3: Autosave hardening diff --git a/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md b/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md new file mode 100644 index 00000000..897fc71f --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md @@ -0,0 +1,36 @@ +# Task: Типизированные state-модели простых слоёв + RNG в состоянии + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 2 — Unified Pydantic save schema + +## Description + +Каждый из четырёх «простых» слоёв (geography, politics, settlements, ecology) получает Pydantic-модель своего состояния как единственный source of truth формата: `GeographyState`, `PoliticsState`, `SettlementsState`, `EcologyState`. Сейчас `get_state()`/`load_state()` руками собирают/разбирают вложенные dict-ы (`layers/geography/layer.py:208`, `layers/politics/layer.py:343` — включая склейку tuple-ключей в `"a:b"`, `layers/settlements/layer.py:181`, `layers/ecology/layer.py:163`). + +Архитектурное ограничение: сигнатура `Layer.get_state() -> dict` / `load_state(dict)` в `core/layer.py` НЕ меняется (core остаётся без pydantic). Модели живут в `models.py` (или `state.py`) соответствующего слоя; `get_state` возвращает `Model.model_dump(mode="json")`, `load_state` начинается с `Model.model_validate(data)` — рукописный парсинг с `.get(..., default)` внутри слоёв удаляется. Валидационная ошибка при загрузке — fail-fast (пусть падает понятным `ValidationError`), не тихий дефолт. + +Состояние RNG: слои, владеющие `random.Random` (politics `_rng`, ecology `_rng`, geography `WeatherEngine`, entities — в task 2), сериализуют `rng.getstate()` в своё состояние (tuple → списки JSON-совместимо) и восстанавливают через `setstate` в `load_state`. Загрузка сейва продолжает ту же случайную последовательность, а не начинает новую с сида. + +## Tests First + +- Round-trip каждого слоя: наполненный слой → `get_state()` → JSON-сериализация → `load_state()` в свежий инстанс → `get_state()` идентичен; поведение после загрузки идентично поведению оригинала (следующие N тиков/дро совпадают). +- Продолжение RNG: слой с seed делает K дро → save → load в новый инстанс → следующие M дро совпадают с непрерывным прогоном K+M на оригинале (politics: следующие дипломатические исходы; ecology: следующие roam-выборы; geography: следующая погода). +- Невалидное состояние (отсутствующее обязательное поле, мусорный тип) → `ValidationError`, не тихая деградация. +- Politics: пары фракций с отношениями и war_durations переживают round-trip без потери (tuple-ключи). + +## Implementation + +После красных тестов: модели состояний по слоям, общий helper для сериализации `Random.getstate()`/`setstate()` (один, переиспользуемый — например в `layers/common/rng_state.py` или рядом с Layer-утилитами, но НЕ в core), замена тел `get_state`/`load_state`. Существующие тесты слоёв обновить на новый формат, если они пиновали сырые dict-ключи. Формат поля времени `GameDateTime` — переиспользовать существующие `to_dict`/`from_dict` через кастомный сериализатор или submodel. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`) +- [ ] В get_state/load_state четырёх слоёв нет рукописной сборки dict-ов и `.get(..., default)` +- [ ] RNG-состояние каждого слоя переживает save/load и продолжает последовательность + +## Status + +`pending` diff --git a/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md b/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md new file mode 100644 index 00000000..c4342f91 --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md @@ -0,0 +1,38 @@ +# Task: Entities-слой на Pydantic-моделях сейва + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 2 — Unified Pydantic save schema + +## Description + +Самая большая поверхность: `layers/entities/entity_serialization.py:18` (`serialize_entity` — рукописный dict с ветвлением Creature/PlayerCharacter/Npc/Container), обратный путь в `layers/entities/layer.py:427` (`load_state` через `parse_player`/`parse_npc`/`Creature(...)`), `content_loader/creatures.py:313` (`player_to_full_save_data`/`load_player_save_data`), плюс `get_combats_state` (CombatState/BattleMap). + +Ввести Pydantic-модели сейва сущностей: `EntitySave` как discriminated union по `EntityKind` (`CreatureSave`/`PlayerSave`/`NpcSave`/`ContainerSave`), submodels для инвентаря/экипировки (переиспользовать `serialize_item`/`deserialize_item` из `content_loader/items.py` как кодек или завести `ItemSave`), conditions, resource pools, turn budget, npc memory (`NpcMemory.to_dict` уже есть), combat state (`CombatStateSave`, `BattleMapSave`). `EntitiesState` — корневая модель слоя (entities + combats + rng, по образцу task 1). + +Сейв-модели — отдельные от контентных схем (`content_loader/schemas.py`): контент — авторский формат, сейв — runtime-снапшот; общие enum-ы (EntityKind, BrainType, EquipmentSlot и т.п.) переиспользовать, модели не наследовать. Как и в task 1: сигнатуры `get_state`/`load_state` остаются dict-овыми, модель — внутренний source of truth; валидация fail-fast. + +Lossless-инвариант (ключевое требование simulation-core): всё, что `serialize_entity` пишет сегодня, обязано пережить round-trip. Существующая сетка `tests/unit/test_entities_serialization.py` и `tests/integration/test_save_roundtrip.py` — опора; если сетка не пинует какое-то поле (например `wake_at_seconds`, `is_dodging`, `combat_position`, `equipped` slots, `reputation`) — допинуй. + +## Tests First + +- Round-trip всех четырёх видов сущностей с максимально наполненными полями (экипировка во всех слотах, склянки в инвентаре, условия с длительностями, resource pools, репутация, npc memory, xp/level_up_available, wake_at_seconds, dormant/active) → идентичное состояние. +- Combat round-trip: активный бой (initiative order, positions, walls, sides, round number) переживает save/load, следующий ход корректен. +- RNG слоя entities: encounter-roll последовательность продолжается после load (по образцу task 1). +- Невалидный entity payload → `ValidationError`. + +## Implementation + +После красных тестов: модели (предлагается `layers/entities/save_models.py`), `entity_serialization.py` переписывается на построение моделей (или растворяется в них), `load_state` — через `model_validate` + фабрики. `content_loader/creatures.py` player-save функции переезжают/сводятся к модели (не оставлять два источника формата игрока). Аккуратно с брейнами: `BrainType` сохраняется, реассайн брейнов при load остаётся как есть (brain_factory на стороне сервиса). + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`) +- [ ] `serialize_entity`-ветвление заменено discriminated union; формат игрока определён в одном месте +- [ ] Lossless: round-trip сохраняет все сериализуемые поля, включая combat state и RNG слоя + +## Status + +`pending` diff --git a/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md b/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md new file mode 100644 index 00000000..2f19f561 --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md @@ -0,0 +1,36 @@ +# Task: SaveGame-конверт, schema_version=1, единый путь загрузки + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 2 — Unified Pydantic save schema + +## Description + +Корневая модель сейва поверх task 1-2: `SaveGame(schema_version=1, meta: SaveMeta, world: WorldSave)`, где `WorldSave` = seed, dice_rng_state (глобальный RNG из `rules/dice.py` — `getstate()`/`setstate()`), time, last_tick_times, layers (стейты слоёв из task 1-2 как typed submodels или passthrough-dict от слоёв — выбрать одно и зафиксировать; typed предпочтительнее: конверт живёт вне core и может импортировать модели слоёв). + +Единый путь: `service/commands_save.py` — `save_game()` и `autosave_session()` собирают один и тот же `SaveGame` (сейчас `save_game` не пишет `meta` — устранить), `load_game()` валидирует через `SaveGame.model_validate` и грузит. Legacy-фолбэки удаляются: ветки «нет world-ключа», «flat world», «top-level player» в `commands_save.py:49-73`, `.get()`-дефолты в `World.load` (`core/world.py:163`). Сейв без `schema_version` или с неожиданной версией → понятная ошибка загрузки («несовместимый сейв»), не попытка угадать. `World.save()`/`load()` могут остаться dict-мостом (core без pydantic), конверт валидирует снаружи. + +Модуль конверта: `storage/save_schema.py` или `service/save_schema.py` — вне core, может импортировать layer-модели; `JsonFileStore` не меняется (dict на входе/выходе). + +## Tests First + +- Полный round-trip через сервис: сессия с игроком, NPC, экипировкой, активным combat → `save_game` → `load_game` в свежий сервис → идентичное состояние, раунд продолжается; dice-RNG продолжает последовательность бросков (бросок после load == бросок после save на оригинале). +- `autosave_session` и `save_game` производят одинаковую структуру (оба с `meta` и `schema_version`). +- Legacy-сейв (без `schema_version` / старый формат) → отказ с внятной ошибкой, не crash и не тихая полу-загрузка. +- `tests/integration/test_save_roundtrip.py` перепинован на новый формат и зелёный. + +## Implementation + +После красных тестов: конверт-модель, перевод `commands_save`, чистка `World.load` от компат-дефолтов (см. также `GameDateTime.from_dict` — дефолт `second=0` оставить: это не legacy-сейв, а нормализация), удаление legacy-веток и их тестов, обновление round-trip тестов. Проверить `_on_session_empty`-автосейв и shutdown-автосейв на новом пути (они зовут те же функции). Существующие сейвы в `saves/` станут несовместимыми — это принятое решение спринта (Decisions). + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`); `make test-integration` зелёный +- [ ] Один путь сборки и один путь загрузки сейва; legacy-ветки удалены +- [ ] `schema_version=1` в каждом сейве; dice-RNG state в сейве и продолжается после load + +## Status + +`pending` From 1c0ac0768c2494676a4e0e8dd15239904bf2766b Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:07:47 +0300 Subject: [PATCH 08/25] sprint 021 phase 2 task 1: add typed layer state models --- docs/STATUS.md | 4 +- .../tasks/phase2-task1-layer-state-models.md | 6 +- src/dnd_simulator/layers/common/__init__.py | 1 + src/dnd_simulator/layers/common/rng_state.py | 33 +++ src/dnd_simulator/layers/ecology/layer.py | 95 ++++---- src/dnd_simulator/layers/ecology/state.py | 36 +++ src/dnd_simulator/layers/geography/layer.py | 84 +++---- src/dnd_simulator/layers/geography/state.py | 38 ++++ src/dnd_simulator/layers/politics/layer.py | 126 +++++------ src/dnd_simulator/layers/politics/state.py | 64 ++++++ src/dnd_simulator/layers/settlements/layer.py | 52 ++--- src/dnd_simulator/layers/settlements/state.py | 25 +++ tests/unit/test_geography_layer.py | 3 +- tests/unit/test_layer_state_models.py | 209 ++++++++++++++++++ 14 files changed, 578 insertions(+), 198 deletions(-) create mode 100644 src/dnd_simulator/layers/common/__init__.py create mode 100644 src/dnd_simulator/layers/common/rng_state.py create mode 100644 src/dnd_simulator/layers/ecology/state.py create mode 100644 src/dnd_simulator/layers/geography/state.py create mode 100644 src/dnd_simulator/layers/politics/state.py create mode 100644 src/dnd_simulator/layers/settlements/state.py create mode 100644 tests/unit/test_layer_state_models.py diff --git a/docs/STATUS.md b/docs/STATUS.md index adc1de31..38658102 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 1 — RNG threading & determinism (tasks 1-3 done) — 2026-07-10 +**Phase:** 2 — Unified Pydantic save schema (task 1 done, task 2 pending) — 2026-07-10 -Phase 1 tasks are done. Ready to close the phase. +Phase 2 task 1 is done. Task 2 is pending. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md b/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md index 897fc71f..b737bd1b 100644 --- a/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md +++ b/docs/sprints/021-save-schema/tasks/phase2-task1-layer-state-models.md @@ -33,4 +33,8 @@ ## Status -`pending` +`done` + +## Developer Notes + +Added Pydantic state models for geography, politics, settlements, and ecology while keeping the Layer ABC dict interface unchanged. RNG state now uses a shared JSON-safe helper and is restored for geography weather, politics, and ecology. Politics relation tuple keys are saved as typed `{a,b,...}` lists instead of colon-joined strings to avoid lossy key encoding. diff --git a/src/dnd_simulator/layers/common/__init__.py b/src/dnd_simulator/layers/common/__init__.py new file mode 100644 index 00000000..fc6c525e --- /dev/null +++ b/src/dnd_simulator/layers/common/__init__.py @@ -0,0 +1 @@ +"""Shared layer implementation helpers.""" diff --git a/src/dnd_simulator/layers/common/rng_state.py b/src/dnd_simulator/layers/common/rng_state.py new file mode 100644 index 00000000..19544b1a --- /dev/null +++ b/src/dnd_simulator/layers/common/rng_state.py @@ -0,0 +1,33 @@ +"""JSON-safe helpers for ``random.Random`` state.""" + +from __future__ import annotations + +import random +from typing import Any, cast + + +def _tuples_to_lists(value: object) -> object: + if isinstance(value, tuple): + return [_tuples_to_lists(item) for item in value] + if isinstance(value, list): + return [_tuples_to_lists(item) for item in value] + return value + + +def _lists_to_tuples(value: object) -> object: + if isinstance(value, list): + return tuple(_lists_to_tuples(item) for item in value) + return value + + +def dump_rng_state(rng: random.Random) -> list[Any]: + """Return a JSON-compatible snapshot of a ``random.Random`` instance.""" + state = _tuples_to_lists(rng.getstate()) + if not isinstance(state, list): + raise TypeError("random state did not serialize to a list") + return state + + +def load_rng_state(rng: random.Random, state: list[Any]) -> None: + """Restore a ``random.Random`` instance from ``dump_rng_state`` output.""" + rng.setstate(cast(tuple[Any, ...], _lists_to_tuples(state))) diff --git a/src/dnd_simulator/layers/ecology/layer.py b/src/dnd_simulator/layers/ecology/layer.py index d85f12ee..fca0a537 100644 --- a/src/dnd_simulator/layers/ecology/layer.py +++ b/src/dnd_simulator/layers/ecology/layer.py @@ -11,14 +11,16 @@ import structlog -from dnd_simulator.core.lair import Lair, LairState +from dnd_simulator.core.lair import Lair from dnd_simulator.core.layer import Layer from dnd_simulator.core.models import ActionResult, Answer, Event, EventType, Query, QueryType from dnd_simulator.core.queries import LairInfo, SquadInfo from dnd_simulator.core.squad import Squad +from dnd_simulator.layers.common.rng_state import dump_rng_state, load_rng_state from dnd_simulator.layers.ecology.lairs import apply_lair_dematerialize, respawn_lairs from dnd_simulator.layers.ecology.movement import move_squad from dnd_simulator.layers.ecology.squad_combat import resolve_squad_combat +from dnd_simulator.layers.ecology.state import EcologyState, LairRuntimeState, SquadRuntimeState if TYPE_CHECKING: from dnd_simulator.core.location import LocationGraph @@ -166,62 +168,49 @@ def query(self, query: Query) -> Answer: def get_state(self) -> dict[str, object]: """Serialize mutable squad state.""" - squads: dict[str, dict[str, object]] = {} - for sid, s in self._squads.items(): - squads[sid] = { - "current_location_id": s.current_location_id, - "strength": s.strength, - } - lairs: dict[str, dict[str, object]] = {} - for lid, lair in self._lairs.items(): - lairs[lid] = { - "state": lair.state.value, - "alive_members": lair.alive_members, - "core_alive": lair.core_alive, - "last_respawn_time": lair.last_respawn_time, - } - return { - "squads": squads, - "lairs": lairs, - "last_move_time": dict(self._last_move_time), - "route_index": dict(self._route_index), - "route_direction": dict(self._route_direction), - } + state = EcologyState( + squads={ + sid: SquadRuntimeState(current_location_id=s.current_location_id, strength=s.strength) + for sid, s in self._squads.items() + }, + lairs={ + lid: LairRuntimeState( + state=lair.state, + alive_members=lair.alive_members, + core_alive=lair.core_alive, + last_respawn_time=lair.last_respawn_time, + ) + for lid, lair in self._lairs.items() + }, + last_move_time=dict(self._last_move_time), + route_index=dict(self._route_index), + route_direction=dict(self._route_direction), + rng_state=dump_rng_state(self._rng), + ) + return state.model_dump(mode="json") def load_state(self, state: dict[str, object]) -> None: """Restore mutable squad fields from saved state.""" - squads_data = state["squads"] - assert isinstance(squads_data, dict) - for sid, sdata in squads_data.items(): - assert isinstance(sdata, dict) + data = EcologyState.model_validate(state) + load_rng_state(self._rng, data.rng_state) + + for sid, sdata in data.squads.items(): if sid in self._squads: - self._squads[sid].current_location_id = str(sdata["current_location_id"]) - self._squads[sid].strength = int(sdata["strength"]) - - lairs_data = state.get("lairs") - if isinstance(lairs_data, dict): - for lid, ldata in lairs_data.items(): - assert isinstance(ldata, dict) - lair = self._lairs.get(str(lid)) - if lair is None: - continue - lair.state = LairState(str(ldata["state"])) - am = ldata.get("alive_members") - lair.alive_members = [str(m) for m in am] if isinstance(am, list) else None - lair.core_alive = bool(ldata.get("core_alive", True)) - lair.last_respawn_time = int(ldata.get("last_respawn_time", 0)) - - lmt = state.get("last_move_time") - if isinstance(lmt, dict): - self._last_move_time = {str(k): int(v) for k, v in lmt.items()} - - ri = state.get("route_index") - if isinstance(ri, dict): - self._route_index = {str(k): int(v) for k, v in ri.items()} - - rd = state.get("route_direction") - if isinstance(rd, dict): - self._route_direction = {str(k): int(v) for k, v in rd.items()} + self._squads[sid].current_location_id = sdata.current_location_id + self._squads[sid].strength = sdata.strength + + for lid, ldata in data.lairs.items(): + lair = self._lairs.get(lid) + if lair is None: + continue + lair.state = ldata.state + lair.alive_members = ldata.alive_members + lair.core_alive = ldata.core_alive + lair.last_respawn_time = ldata.last_respawn_time + + self._last_move_time = dict(data.last_move_time) + self._route_index = dict(data.route_index) + self._route_direction = dict(data.route_direction) @staticmethod def _squad_info(squad: Squad) -> SquadInfo: diff --git a/src/dnd_simulator/layers/ecology/state.py b/src/dnd_simulator/layers/ecology/state.py new file mode 100644 index 00000000..f07a77d9 --- /dev/null +++ b/src/dnd_simulator/layers/ecology/state.py @@ -0,0 +1,36 @@ +"""Pydantic save-state models for EcologyLayer.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from dnd_simulator.core.lair import LairState + + +class SquadRuntimeState(BaseModel): + model_config = ConfigDict(extra="forbid") + + current_location_id: str + strength: int + + +class LairRuntimeState(BaseModel): + model_config = ConfigDict(extra="forbid") + + state: LairState + alive_members: list[str] | None + core_alive: bool + last_respawn_time: int + + +class EcologyState(BaseModel): + model_config = ConfigDict(extra="forbid") + + squads: dict[str, SquadRuntimeState] + lairs: dict[str, LairRuntimeState] + last_move_time: dict[str, int] + route_index: dict[str, int] + route_direction: dict[str, int] + rng_state: list[Any] diff --git a/src/dnd_simulator/layers/geography/layer.py b/src/dnd_simulator/layers/geography/layer.py index 2b19ab5c..b9787efe 100644 --- a/src/dnd_simulator/layers/geography/layer.py +++ b/src/dnd_simulator/layers/geography/layer.py @@ -9,13 +9,12 @@ from dnd_simulator.core.models import ActionResult, Answer, Event, EventType, Query, QueryType from dnd_simulator.core.queries import RegionInfo, WeatherInfo from dnd_simulator.i18n import _ +from dnd_simulator.layers.common.rng_state import dump_rng_state, load_rng_state from dnd_simulator.layers.geography.models import ( Connection, - Direction, Region, - TerrainType, - WeatherCondition, ) +from dnd_simulator.layers.geography.state import ConnectionState, GeographyState, RegionState from dnd_simulator.layers.geography.weather import WeatherEngine from dnd_simulator.rules.geography import ( apply_weather_temperature_modifier, @@ -207,53 +206,42 @@ def query(self, query: Query) -> Answer: def get_state(self) -> dict[str, object]: """Serialize geography state.""" - regions: dict[str, Any] = {} - for rid, r in self._regions.items(): - regions[rid] = { - "id": r.id, - "name": r.name, - "latitude": r.latitude, - "longitude": r.longitude, - "elevation": r.elevation, - "terrain": r.terrain.value, - "water_proximity": r.water_proximity, - "connections": [{"target_id": c.target_id, "direction": c.direction.value} for c in r.connections], - "weather": r.weather.value, - "temperature": r.temperature, - } - return {"regions": regions} + state = GeographyState( + regions={ + rid: RegionState( + id=r.id, + name=r.name, + latitude=r.latitude, + longitude=r.longitude, + elevation=r.elevation, + terrain=r.terrain, + water_proximity=r.water_proximity, + connections=[ConnectionState(target_id=c.target_id, direction=c.direction) for c in r.connections], + weather=r.weather, + temperature=r.temperature, + ) + for rid, r in self._regions.items() + }, + rng_state=dump_rng_state(self._weather._rng), + ) + return state.model_dump(mode="json") def load_state(self, state: dict[str, object]) -> None: """Restore geography from saved state.""" - regions_data = state["regions"] - assert isinstance(regions_data, dict) + data = GeographyState.model_validate(state) self._regions.clear() - - for rid, rdata in regions_data.items(): - assert isinstance(rdata, dict) - - connections: list[Connection] = [] - conn_list = rdata.get("connections", []) - assert isinstance(conn_list, list) - for c in conn_list: - assert isinstance(c, dict) - connections.append( - Connection( - target_id=str(c["target_id"]), - direction=Direction(str(c["direction"])), - ) - ) - - region = Region( - id=str(rid), - name=str(rdata["name"]), - latitude=float(rdata["latitude"]), - longitude=float(rdata["longitude"]), - elevation=float(rdata["elevation"]), - terrain=TerrainType(str(rdata["terrain"])), - water_proximity=float(rdata["water_proximity"]), - connections=connections, - weather=WeatherCondition(str(rdata.get("weather", "clear"))), - temperature=float(rdata.get("temperature", 15.0)), + load_rng_state(self._weather._rng, data.rng_state) + + for rid, rdata in data.regions.items(): + self._regions[rid] = Region( + id=rdata.id, + name=rdata.name, + latitude=rdata.latitude, + longitude=rdata.longitude, + elevation=rdata.elevation, + terrain=rdata.terrain, + water_proximity=rdata.water_proximity, + connections=[Connection(target_id=c.target_id, direction=c.direction) for c in rdata.connections], + weather=rdata.weather, + temperature=rdata.temperature, ) - self._regions[rid] = region diff --git a/src/dnd_simulator/layers/geography/state.py b/src/dnd_simulator/layers/geography/state.py new file mode 100644 index 00000000..c07965ad --- /dev/null +++ b/src/dnd_simulator/layers/geography/state.py @@ -0,0 +1,38 @@ +"""Pydantic save-state models for GeographyLayer.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from dnd_simulator.layers.geography.models import Direction, TerrainType, WeatherCondition + + +class ConnectionState(BaseModel): + model_config = ConfigDict(extra="forbid") + + target_id: str + direction: Direction + + +class RegionState(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + name: str + latitude: float + longitude: float + elevation: float + terrain: TerrainType + water_proximity: float + connections: list[ConnectionState] + weather: WeatherCondition + temperature: float + + +class GeographyState(BaseModel): + model_config = ConfigDict(extra="forbid") + + regions: dict[str, RegionState] + rng_state: list[Any] diff --git a/src/dnd_simulator/layers/politics/layer.py b/src/dnd_simulator/layers/politics/layer.py index bb8b04ed..dca787e0 100644 --- a/src/dnd_simulator/layers/politics/layer.py +++ b/src/dnd_simulator/layers/politics/layer.py @@ -4,11 +4,12 @@ import random from collections.abc import Callable -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from dnd_simulator.core.layer import Layer from dnd_simulator.core.models import ActionResult, Answer, Event, EventType, Query, QueryType from dnd_simulator.core.queries import LeaderInfo, NationInfo +from dnd_simulator.layers.common.rng_state import dump_rng_state, load_rng_state from dnd_simulator.layers.politics.diplomacy import process_diplomacy from dnd_simulator.layers.politics.economy import process_economy from dnd_simulator.layers.politics.models import ( @@ -18,6 +19,14 @@ LeaderTrait, Nation, ) +from dnd_simulator.layers.politics.state import ( + DiplomaticRelationState, + FactionRelationState, + LeaderState, + NationState, + PoliticsState, + WarDurationState, +) from dnd_simulator.layers.politics.warfare import process_wars from dnd_simulator.rules.politics import ( calculate_stability_drift, @@ -342,79 +351,60 @@ def query(self, query: Query) -> Answer: def get_state(self) -> dict[str, object]: """Serialize politics state.""" - nations: dict[str, Any] = {} - for nid, n in self._nations.items(): - nations[nid] = { - "id": n.id, - "name": n.name, - "regions": list(n.regions), - "wealth": n.wealth, - "military": n.military, - "stability": n.stability, - "leader": { - "name": n.leader.name, - "age": n.leader.age, - "trait": n.leader.trait.value, - } - if n.leader - else None, - } - - relations: dict[str, str] = {} - for key, status in self._relations.items(): - relations[f"{key[0]}:{key[1]}"] = status.value - - war_durations: dict[str, int] = {} - for key, months in self._war_durations.items(): - war_durations[f"{key[0]}:{key[1]}"] = months - - return { - "nations": nations, - "relations": relations, - "war_durations": war_durations, - } + state = PoliticsState( + nations={ + nid: NationState( + id=n.id, + name=n.name, + regions=list(n.regions), + wealth=n.wealth, + military=n.military, + stability=n.stability, + leader=LeaderState(name=n.leader.name, age=n.leader.age, trait=n.leader.trait) + if n.leader + else None, + ) + for nid, n in self._nations.items() + }, + relations=[DiplomaticRelationState(a=a, b=b, status=status) for (a, b), status in self._relations.items()], + war_durations=[WarDurationState(a=a, b=b, months=months) for (a, b), months in self._war_durations.items()], + faction_relations=[ + FactionRelationState(a=a, b=b, relation=relation) + for (a, b), relation in self._faction_relations.items() + ], + faction_names=dict(self._faction_names), + rng_state=dump_rng_state(self._rng), + ) + return state.model_dump(mode="json") def load_state(self, state: dict[str, object]) -> None: """Restore politics from saved state.""" - nations_data = state["nations"] - assert isinstance(nations_data, dict) + data = PoliticsState.model_validate(state) self._nations.clear() - - for nid, ndata in nations_data.items(): - assert isinstance(ndata, dict) - leader_data = ndata.get("leader") - leader = None - if leader_data: - assert isinstance(leader_data, dict) - leader = Leader( - name=str(leader_data["name"]), - age=int(leader_data["age"]), - trait=LeaderTrait(str(leader_data["trait"])), - ) - - regions = ndata.get("regions", []) - assert isinstance(regions, list) - - self._nations[str(nid)] = Nation( - id=str(nid), - name=str(ndata["name"]), - regions=[str(r) for r in regions], - wealth=float(ndata.get("wealth", 50.0)), - military=float(ndata.get("military", 50.0)), - stability=float(ndata.get("stability", 70.0)), - leader=leader, + load_rng_state(self._rng, data.rng_state) + + for nid, ndata in data.nations.items(): + self._nations[nid] = Nation( + id=ndata.id, + name=ndata.name, + regions=list(ndata.regions), + wealth=ndata.wealth, + military=ndata.military, + stability=ndata.stability, + leader=Leader(name=ndata.leader.name, age=ndata.leader.age, trait=ndata.leader.trait) + if ndata.leader + else None, ) - relations_data = state.get("relations", {}) - assert isinstance(relations_data, dict) self._relations.clear() - for key_str, status_str in relations_data.items(): - parts = str(key_str).split(":") - self._relations[(parts[0], parts[1])] = DiplomaticStatus(str(status_str)) + for relation in data.relations: + self._relations[_relation_key(relation.a, relation.b)] = relation.status - war_data = state.get("war_durations", {}) - assert isinstance(war_data, dict) self._war_durations.clear() - for key_str, months in war_data.items(): - parts = str(key_str).split(":") - self._war_durations[(parts[0], parts[1])] = int(months) + for war in data.war_durations: + self._war_durations[_relation_key(war.a, war.b)] = war.months + + self._faction_relations = { + _relation_key(relation.a, relation.b): relation.relation for relation in data.faction_relations + } + self._faction_names = dict(data.faction_names) diff --git a/src/dnd_simulator/layers/politics/state.py b/src/dnd_simulator/layers/politics/state.py new file mode 100644 index 00000000..6c811ae3 --- /dev/null +++ b/src/dnd_simulator/layers/politics/state.py @@ -0,0 +1,64 @@ +"""Pydantic save-state models for PoliticsLayer.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict + +from dnd_simulator.layers.politics.models import DiplomaticStatus, FactionRelation, LeaderTrait + + +class LeaderState(BaseModel): + model_config = ConfigDict(extra="forbid") + + name: str + age: int + trait: LeaderTrait + + +class NationState(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + name: str + regions: list[str] + wealth: float + military: float + stability: float + leader: LeaderState | None + + +class DiplomaticRelationState(BaseModel): + model_config = ConfigDict(extra="forbid") + + a: str + b: str + status: DiplomaticStatus + + +class WarDurationState(BaseModel): + model_config = ConfigDict(extra="forbid") + + a: str + b: str + months: int + + +class FactionRelationState(BaseModel): + model_config = ConfigDict(extra="forbid") + + a: str + b: str + relation: FactionRelation + + +class PoliticsState(BaseModel): + model_config = ConfigDict(extra="forbid") + + nations: dict[str, NationState] + relations: list[DiplomaticRelationState] + war_durations: list[WarDurationState] + faction_relations: list[FactionRelationState] + faction_names: dict[str, str] + rng_state: list[Any] diff --git a/src/dnd_simulator/layers/settlements/layer.py b/src/dnd_simulator/layers/settlements/layer.py index 78a6452c..dd486080 100644 --- a/src/dnd_simulator/layers/settlements/layer.py +++ b/src/dnd_simulator/layers/settlements/layer.py @@ -2,7 +2,7 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from dnd_simulator.core.layer import Layer from dnd_simulator.core.models import ActionResult, Answer, Event, EventType, Query, QueryType @@ -12,7 +12,8 @@ query_region_owner, query_weather, ) -from dnd_simulator.layers.settlements.models import Settlement, SettlementType +from dnd_simulator.layers.settlements.models import Settlement +from dnd_simulator.layers.settlements.state import SettlementsState, SettlementState from dnd_simulator.rules.settlements import ( calculate_harvest_modifier, calculate_population_change, @@ -180,33 +181,34 @@ def _settlement_info(s: Settlement) -> SettlementInfo: def get_state(self) -> dict[str, object]: """Serialize settlements state.""" - settlements: dict[str, Any] = {} - for sid, s in self._settlements.items(): - settlements[sid] = { - "id": s.id, - "name": s.name, - "region_id": s.region_id, - "type": s.type.value, - "population": s.population, - "prosperity": s.prosperity, - "defenses": s.defenses, + state = SettlementsState( + settlements={ + sid: SettlementState( + id=s.id, + name=s.name, + region_id=s.region_id, + type=s.type, + population=s.population, + prosperity=s.prosperity, + defenses=s.defenses, + ) + for sid, s in self._settlements.items() } - return {"settlements": settlements} + ) + return state.model_dump(mode="json") def load_state(self, state: dict[str, object]) -> None: """Restore settlements from saved state.""" - settlements_data = state["settlements"] - assert isinstance(settlements_data, dict) + data = SettlementsState.model_validate(state) self._settlements.clear() - for sid, sdata in settlements_data.items(): - assert isinstance(sdata, dict) - self._settlements[str(sid)] = Settlement( - id=str(sdata["id"]), - name=str(sdata["name"]), - region_id=str(sdata["region_id"]), - type=SettlementType(str(sdata["type"])), - population=int(sdata.get("population", 100)), - prosperity=float(sdata.get("prosperity", 50.0)), - defenses=float(sdata.get("defenses", 30.0)), + for sid, sdata in data.settlements.items(): + self._settlements[sid] = Settlement( + id=sdata.id, + name=sdata.name, + region_id=sdata.region_id, + type=sdata.type, + population=sdata.population, + prosperity=sdata.prosperity, + defenses=sdata.defenses, ) diff --git a/src/dnd_simulator/layers/settlements/state.py b/src/dnd_simulator/layers/settlements/state.py new file mode 100644 index 00000000..48e64d7a --- /dev/null +++ b/src/dnd_simulator/layers/settlements/state.py @@ -0,0 +1,25 @@ +"""Pydantic save-state models for SettlementsLayer.""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from dnd_simulator.layers.settlements.models import SettlementType + + +class SettlementState(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + name: str + region_id: str + type: SettlementType + population: int + prosperity: float + defenses: float + + +class SettlementsState(BaseModel): + model_config = ConfigDict(extra="forbid") + + settlements: dict[str, SettlementState] diff --git a/tests/unit/test_geography_layer.py b/tests/unit/test_geography_layer.py index ff0e22a3..d77134ee 100644 --- a/tests/unit/test_geography_layer.py +++ b/tests/unit/test_geography_layer.py @@ -173,7 +173,8 @@ def test_round_trip(self) -> None: assert len(orig.connections) == len(rest.connections) def test_load_empty_state(self) -> None: + source = GeographyLayer() layer = GeographyLayer() - layer.load_state({"regions": {}}) + layer.load_state(source.get_state()) answer = layer.query(Query(question=QueryType.REGIONS, params={})) assert answer.value == [] diff --git a/tests/unit/test_layer_state_models.py b/tests/unit/test_layer_state_models.py new file mode 100644 index 00000000..86c98ac2 --- /dev/null +++ b/tests/unit/test_layer_state_models.py @@ -0,0 +1,209 @@ +"""Save-state model contracts for simple simulation layers.""" + +from __future__ import annotations + +import json +from collections.abc import Callable + +import pytest +from pydantic import ValidationError + +from dnd_simulator.core.lair import Lair, LairState +from dnd_simulator.core.models import ActionResult, Answer, GameDateTime, Query, QueryType, TimeDelta +from dnd_simulator.core.squad import Squad, SquadBehavior, SquadType +from dnd_simulator.layers.ecology.layer import EcologyLayer +from dnd_simulator.layers.geography.layer import GeographyLayer +from dnd_simulator.layers.geography.models import Connection, Direction, Region, TerrainType, WeatherCondition +from dnd_simulator.layers.politics.layer import PoliticsLayer +from dnd_simulator.layers.politics.models import DiplomaticStatus, FactionRelation, Leader, LeaderTrait, Nation +from dnd_simulator.layers.settlements.layer import SettlementsLayer +from dnd_simulator.layers.settlements.models import Settlement, SettlementType + + +def _noop_query_fn(layer: str, query: Query) -> Answer: + raise RuntimeError(f"Unexpected query: {layer}/{query.question}") + + +def _noop_emit_fn(event: object) -> ActionResult: + return ActionResult() + + +def _geography() -> GeographyLayer: + return GeographyLayer( + regions=[ + Region( + id="north", + name="North", + latitude=52.0, + longitude=24.0, + elevation=180.0, + terrain=TerrainType.FOREST, + water_proximity=0.4, + connections=[Connection(target_id="south", direction=Direction.S)], + weather=WeatherCondition.CLOUDY, + temperature=8.5, + ) + ], + weather_seed=17, + ) + + +def _politics() -> PoliticsLayer: + layer = PoliticsLayer( + nations=[ + Nation( + id="a", + name="A", + regions=["ra"], + wealth=70.0, + military=62.0, + stability=51.0, + leader=Leader(name="Ada", age=44, trait=LeaderTrait.DIPLOMAT), + ), + Nation( + id="b", + name="B", + regions=["rb"], + wealth=45.0, + military=80.0, + stability=48.0, + leader=Leader(name="Bryn", age=39, trait=LeaderTrait.MILITARIST), + ), + ], + region_adjacency={"ra": ["rb"], "rb": ["ra"]}, + seed=23, + faction_relations={("goblins", "guards"): FactionRelation.HOSTILE}, + faction_names={"guards": "Town Guard"}, + ) + layer.set_relation("a", "b", DiplomaticStatus.WAR) + layer._war_durations[("a", "b")] = 7 + return layer + + +def _settlements() -> SettlementsLayer: + return SettlementsLayer( + settlements=[ + Settlement( + id="town", + name="Town", + region_id="ra", + type=SettlementType.TOWN, + population=900, + prosperity=41.0, + defenses=25.0, + ) + ] + ) + + +def _ecology() -> EcologyLayer: + squad = Squad( + id="wolves", + name="Wolves", + faction_id="wild", + squad_type=SquadType.MONSTER_PACK, + behavior=SquadBehavior.ROAM, + current_location_id="den", + route=[], + territory=["den", "woods"], + strength=4, + max_strength=6, + member_templates=["wolf"], + tick_interval=3600, + ) + lair = Lair( + id="den", + name="Den", + faction_id="wild", + location_id="den", + members=["wolf", "wolf"], + state=LairState.ACTIVE, + alive_members=["wolf"], + core_alive=False, + last_respawn_time=12, + ) + layer = EcologyLayer(squads=[squad], lairs=[lair], seed=31) + layer._last_move_time["wolves"] = 3600 + layer._route_index["wolves"] = 1 + layer._route_direction["wolves"] = -1 + return layer + + +@pytest.mark.parametrize( + ("layer_factory", "required_key"), + [ + (_geography, "rng_state"), + (_politics, "rng_state"), + (_settlements, None), + (_ecology, "rng_state"), + ], +) +def test_simple_layer_state_round_trips_through_json( + layer_factory: Callable[[], object], required_key: str | None +) -> None: + layer = layer_factory() + state = layer.get_state() + if required_key is not None: + assert required_key in state + + json_state = json.loads(json.dumps(state)) + restored = layer_factory() + restored.load_state(json_state) + + assert restored.get_state() == state + + +def test_geography_rng_continues_after_load() -> None: + original = _geography() + time = GameDateTime(year=1490, month=3, day=1, hour=6) + original.tick(TimeDelta.from_hours(6), time, _noop_query_fn, _noop_emit_fn) + saved = json.loads(json.dumps(original.get_state())) + + expected = original._weather._rng.random() + + restored = _geography() + restored.load_state(saved) + + assert restored._weather._rng.random() == expected + + +def test_politics_relations_war_durations_and_rng_round_trip() -> None: + original = _politics() + _ = original._rng.random() + saved = json.loads(json.dumps(original.get_state())) + + expected = original._rng.random() + restored = PoliticsLayer(region_adjacency={"ra": ["rb"], "rb": ["ra"]}) + restored.load_state(saved) + + assert restored.get_relation("b", "a") is DiplomaticStatus.WAR + assert restored._war_durations[("a", "b")] == 7 + assert restored.get_faction_relation("guards", "goblins") is FactionRelation.HOSTILE + assert restored.query(Query(question=QueryType.FACTION_NAME, params={"faction_id": "guards"})).value == "Town Guard" + assert restored._rng.random() == expected + + +def test_ecology_rng_continues_after_load() -> None: + original = _ecology() + _ = original._rng.choice(["a", "b", "c"]) + saved = json.loads(json.dumps(original.get_state())) + expected = original._rng.random() + + restored = _ecology() + restored.load_state(saved) + + assert restored._rng.random() == expected + + +@pytest.mark.parametrize( + ("layer", "state"), + [ + (_geography(), {"regions": {"north": {"id": "north", "terrain": 123}}, "rng_state": []}), + (_politics(), {"nations": {"a": {"id": "a", "name": "A", "regions": "bad"}}, "rng_state": []}), + (_settlements(), {"settlements": {"town": {"id": "town", "name": "Town", "region_id": "ra"}}}), + (_ecology(), {"squads": {"wolves": {"strength": "bad"}}, "rng_state": []}), + ], +) +def test_invalid_simple_layer_state_raises_validation_error(layer: object, state: dict[str, object]) -> None: + with pytest.raises(ValidationError): + layer.load_state(state) From 7457181cc79b842298140ec5b10a94ac149f2dd9 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:14:22 +0300 Subject: [PATCH 09/25] sprint 021 phase 2 task 2: add typed entities state --- docs/STATUS.md | 4 +- .../phase2-task2-entities-state-model.md | 6 +- .../layers/entities/entity_serialization.py | 6 +- src/dnd_simulator/layers/entities/layer.py | 21 ++-- .../layers/entities/save_models.py | 110 ++++++++++++++++++ tests/unit/test_entities_serialization.py | 43 +++++++ tests/unit/test_npc_layer.py | 12 +- 7 files changed, 180 insertions(+), 22 deletions(-) create mode 100644 src/dnd_simulator/layers/entities/save_models.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 38658102..bf02a02c 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 2 — Unified Pydantic save schema (task 1 done, task 2 pending) — 2026-07-10 +**Phase:** 2 — Unified Pydantic save schema (task 2 done, task 3 pending) — 2026-07-10 -Phase 2 task 1 is done. Task 2 is pending. +Phase 2 task 2 is done. Task 3 is pending. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md b/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md index c4342f91..74fe36c0 100644 --- a/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md +++ b/docs/sprints/021-save-schema/tasks/phase2-task2-entities-state-model.md @@ -35,4 +35,8 @@ Lossless-инвариант (ключевое требование simulation-co ## Status -`pending` +`done` + +## Developer Notes + +Added `EntitiesState` with discriminated Pydantic entity models, typed combat submodels, and entities-layer RNG state. `EntitiesLayer.get_state()` now always emits `entities`, `combats`, and `rng_state`; `load_state()` validates through `EntitiesState` before reconstruction. The old NPC `conversation_summary` save fallback is now invalid, matching the sprint decision to drop legacy save formats. diff --git a/src/dnd_simulator/layers/entities/entity_serialization.py b/src/dnd_simulator/layers/entities/entity_serialization.py index a9633227..208361db 100644 --- a/src/dnd_simulator/layers/entities/entity_serialization.py +++ b/src/dnd_simulator/layers/entities/entity_serialization.py @@ -8,11 +8,14 @@ from __future__ import annotations +from typing import cast + from dnd_simulator.core.character import Creature, Entity from dnd_simulator.core.container import Container from dnd_simulator.core.models import EntityKind from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.layers.entities.models import Npc +from dnd_simulator.layers.entities.save_models import EntitySave, EntitySaveAdapter def serialize_entity(entity: Entity) -> dict[str, object]: @@ -100,4 +103,5 @@ def serialize_entity(entity: Entity) -> dict[str, object]: data["gold"] = e.gold if e.inventory: data["inventory"] = [serialize_item(item) for item in e.inventory] - return data + entity_save: EntitySave = EntitySaveAdapter.validate_python(data) + return cast(dict[str, object], EntitySaveAdapter.dump_python(entity_save, mode="json", by_alias=True)) diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 6a3bca31..3cbc76ff 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -28,12 +28,14 @@ from dnd_simulator.core.npc_memory import NpcMemory from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.core.turn_budget import TurnBudget +from dnd_simulator.layers.common.rng_state import dump_rng_state, load_rng_state from dnd_simulator.layers.entities.activation_manager import ActivationManager from dnd_simulator.layers.entities.awareness_builder import AwarenessBuilder, active_merchants_at from dnd_simulator.layers.entities.combat_manager import CombatManager from dnd_simulator.layers.entities.models import Npc from dnd_simulator.layers.entities.perception import perceive_event from dnd_simulator.layers.entities.query_handler import QueryHandler +from dnd_simulator.layers.entities.save_models import EntitiesState if TYPE_CHECKING: from dnd_simulator.core.models import EmitFn, GameDateTime, QueryFn, TimeDelta @@ -423,21 +425,22 @@ def get_state(self) -> dict[str, object]: entities: dict[str, Any] = {eid: serialize_entity(e) for eid, e in self._entities.items()} combats = self._combat.get_combats_state() - result: dict[str, object] = {"entities": entities} - if combats: - result["combats"] = combats - return result + state = EntitiesState.model_validate( + {"entities": entities, "combats": combats, "rng_state": dump_rng_state(self._rng)} + ) + return state.model_dump(mode="json", by_alias=True) def load_state(self, state: dict[str, object]) -> None: """Restore mutable entity state from saved data.""" from dnd_simulator.content_loader import parse_player from dnd_simulator.content_loader.items import EQUIPMENT_FIELDS, deserialize_item - entities_data = state["entities"] - assert isinstance(entities_data, dict) + save_state = EntitiesState.model_validate(state) + load_rng_state(self._rng, save_state.rng_state) + state_data = save_state.model_dump(mode="json", by_alias=True) + entities_data = state_data["entities"] for eid, edata in entities_data.items(): - assert isinstance(edata, dict) entity = self._entities.get(str(eid)) # Recreate missing entities from save data (spawned at runtime or player) @@ -551,6 +554,4 @@ def load_state(self, state: dict[str, object]) -> None: if isinstance(inv_raw, list): entity.inventory = [deserialize_item(d) for d in inv_raw] - combats_data = state.get("combats") - if isinstance(combats_data, dict): - self._combat.load_combats_state(combats_data) + self._combat.load_combats_state(state_data["combats"]) diff --git a/src/dnd_simulator/layers/entities/save_models.py b/src/dnd_simulator/layers/entities/save_models.py new file mode 100644 index 00000000..e9abcd34 --- /dev/null +++ b/src/dnd_simulator/layers/entities/save_models.py @@ -0,0 +1,110 @@ +"""Pydantic save-state models for EntitiesLayer.""" + +from __future__ import annotations + +from typing import Annotated, Any, Literal + +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + + +class EntitySaveBase(BaseModel): + model_config = ConfigDict(extra="allow") + + id: str + name: str + location_id: str + active: bool + + +class CreatureFields(EntitySaveBase): + max_hp: int + current_hp: int + ac: int + speed: int + ability_scores: dict[str, int] + attacks: list[dict[str, Any]] = Field(default_factory=list) + + +class CreatureSave(CreatureFields): + entity_type: Literal["creature"] + + +class PlayerSave(CreatureFields): + entity_type: Literal["player"] + race: str + class_: str = Field(alias="class") + level: int + alignment: str + hp: int + gold: int + start_location: str + experience: int + level_up_available: bool + + +class NpcSave(CreatureFields): + entity_type: Literal["npc"] + role: str + personality: str + settlement_id: str + memory: dict[str, Any] + ai_type: str + hp: int + ai: str + start_location: str + race: str + class_: str = Field(alias="class") + + +class ContainerSave(EntitySaveBase): + entity_type: Literal["container"] + is_open: bool + gold: int + inventory: list[dict[str, Any]] = Field(default_factory=list) + + +EntitySave = Annotated[PlayerSave | NpcSave | CreatureSave | ContainerSave, Field(discriminator="entity_type")] +EntitySaveAdapter: TypeAdapter[EntitySave] = TypeAdapter(EntitySave) + + +class PositionSave(BaseModel): + model_config = ConfigDict(extra="forbid") + + x: int + y: int + + +class WallSave(BaseModel): + model_config = ConfigDict(extra="forbid") + + x1: int + y1: int + x2: int + y2: int + + +class BattleMapSave(BaseModel): + model_config = ConfigDict(extra="forbid") + + width: int + height: int + positions: dict[str, PositionSave] + walls: list[WallSave] + + +class CombatStateSave(BaseModel): + model_config = ConfigDict(extra="forbid") + + location_id: str + turn_order: list[str] + round_number: int + rounds_without_attack: int + battle_map: BattleMapSave + + +class EntitiesState(BaseModel): + model_config = ConfigDict(extra="forbid") + + entities: dict[str, EntitySave] + combats: dict[str, CombatStateSave] + rng_state: list[Any] diff --git a/tests/unit/test_entities_serialization.py b/tests/unit/test_entities_serialization.py index a00b7b11..c2d11445 100644 --- a/tests/unit/test_entities_serialization.py +++ b/tests/unit/test_entities_serialization.py @@ -1,5 +1,10 @@ """Tests for entities layer serialization — resource pools, NPC ai_type, and combat state round-trip.""" +import json + +import pytest +from pydantic import ValidationError + from dnd_simulator.core.character import Creature, NpcRole from dnd_simulator.core.combat import BattleMap, CombatState, Position, Wall from dnd_simulator.core.resource import ResourcePool, RestType @@ -278,3 +283,41 @@ def test_multiple_simultaneous_combats(self) -> None: assert r2.turn_order == ["d", "c"] assert r2.battle_map.width == 80 assert r2.battle_map.get_position("c") == Position(10, 10) + + +class TestEntitiesStateModel: + def test_state_round_trips_through_json_and_preserves_rng(self) -> None: + creature = _make_creature("wanderer") + creature.wake_at_seconds = 123 + layer = EntitiesLayer(entities=[creature], seed=19) + _ = layer._rng.random() + + state = layer.get_state() + assert "rng_state" in state + assert "combats" in state + + saved = json.loads(json.dumps(state)) + expected = layer._rng.random() + + restored = EntitiesLayer(entities=[_make_creature("wanderer")]) + restored.load_state(saved) + + assert restored.get_state()["entities"] == state["entities"] + assert restored._rng.random() == expected + + def test_invalid_entity_payload_raises_validation_error(self) -> None: + layer = EntitiesLayer() + with pytest.raises(ValidationError): + layer.load_state( + { + "entities": { + "broken": { + "entity_type": "creature", + "id": "broken", + "name": "Broken", + } + }, + "combats": {}, + "rng_state": [], + } + ) diff --git a/tests/unit/test_npc_layer.py b/tests/unit/test_npc_layer.py index 5f1b780e..128c0832 100644 --- a/tests/unit/test_npc_layer.py +++ b/tests/unit/test_npc_layer.py @@ -243,8 +243,8 @@ def test_npc_memory_persists(self) -> None: assert restored.memory.inner_state == "worried about iron supply" assert restored.memory.current_conversation == "Player asked about iron supply." - def test_legacy_conversation_summary_migrates(self) -> None: - """Old saves with conversation_summary should migrate to memory.""" + def test_legacy_conversation_summary_without_memory_is_invalid(self) -> None: + """Old saves without structured NPC memory fail validation.""" layer = _make_layer() # Simulate old save format state = layer.get_state() @@ -257,12 +257,8 @@ def test_legacy_conversation_summary_migrates(self) -> None: smith_data["conversation_summary"] = "Old conversation data." new_layer = EntitiesLayer(entities=_make_npcs()) - new_layer.load_state(state) - - restored = new_layer.get_entity("smith") - assert isinstance(restored, Npc) - assert restored.memory.current_conversation == "Old conversation data." - assert restored.memory.tags == [] + with pytest.raises(ValueError, match="memory"): + new_layer.load_state(state) def test_activation_persists(self) -> None: layer = _make_layer() From 3e786d9b4598c6040646b55bf34388e919b7887e Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:21:02 +0300 Subject: [PATCH 10/25] sprint 021 phase 2 task 3: add versioned save envelope --- docs/STATUS.md | 4 +- .../tasks/phase2-task3-save-envelope.md | 6 +- src/dnd_simulator/core/world.py | 24 +++---- src/dnd_simulator/service/commands_save.py | 63 ++++++++++--------- src/dnd_simulator/service/game_service.py | 42 +++++-------- src/dnd_simulator/storage/save_schema.py | 57 +++++++++++++++++ tests/unit/test_commands_save.py | 57 +++++++++++++++++ 7 files changed, 178 insertions(+), 75 deletions(-) create mode 100644 src/dnd_simulator/storage/save_schema.py diff --git a/docs/STATUS.md b/docs/STATUS.md index bf02a02c..2a8583b7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 2 — Unified Pydantic save schema (task 2 done, task 3 pending) — 2026-07-10 +**Phase:** 2 — Unified Pydantic save schema (tasks 1-3 done) — 2026-07-10 -Phase 2 task 2 is done. Task 3 is pending. +Phase 2 tasks are done. Ready to close the phase. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md b/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md index 2f19f561..b7972be1 100644 --- a/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md +++ b/docs/sprints/021-save-schema/tasks/phase2-task3-save-envelope.md @@ -33,4 +33,8 @@ ## Status -`pending` +`done` + +## Developer Notes + +Added the versioned `SaveGame` envelope in `storage/save_schema.py` with `schema_version=1`, meta, typed layer states, and dice RNG state. `save_game()` and `autosave_session()` now use the same envelope builder; `load_game()` validates the envelope and rejects legacy saves without `schema_version`. Autosave restore now validates the same schema and restores dice RNG before loading world state. diff --git a/src/dnd_simulator/core/world.py b/src/dnd_simulator/core/world.py index 2456d9e6..098e7a15 100644 --- a/src/dnd_simulator/core/world.py +++ b/src/dnd_simulator/core/world.py @@ -167,25 +167,21 @@ def load(self, data: dict[str, object]) -> None: """Restore world from saved data.""" time_data = data["time"] assert isinstance(time_data, dict) - # Backward compat handled in from_dict (old saves may lack 'second') self.time = GameDateTime.from_dict(time_data) - seed_data = data.get("seed") + seed_data = data["seed"] self.seed = int(seed_data) if isinstance(seed_data, int | str) else None - # Restore last tick times (fallback to current time for old saves) - last_ticks_data = data.get("last_tick_times", {}) + # Restore last tick times. SaveGame validation guarantees one entry per saved layer. + last_ticks_data = data["last_tick_times"] assert isinstance(last_ticks_data, dict) for layer in self._layers: - lt = last_ticks_data.get(layer.name) - if lt and isinstance(lt, dict): - self._last_tick_time[layer.name] = GameDateTime.from_dict(lt) - else: - self._last_tick_time[layer.name] = self.time + lt = last_ticks_data[layer.name] + assert isinstance(lt, dict) + self._last_tick_time[layer.name] = GameDateTime.from_dict(lt) - layers_data = data.get("layers", {}) + layers_data = data["layers"] assert isinstance(layers_data, dict) for layer in self._layers: - if layer.name in layers_data: - state = layers_data[layer.name] - assert isinstance(state, dict) - layer.load_state(state) + state = layers_data[layer.name] + assert isinstance(state, dict) + layer.load_state(state) diff --git a/src/dnd_simulator/service/commands_save.py b/src/dnd_simulator/service/commands_save.py index 1a2f12f5..09c33ae3 100644 --- a/src/dnd_simulator/service/commands_save.py +++ b/src/dnd_simulator/service/commands_save.py @@ -1,11 +1,13 @@ from __future__ import annotations -from typing import Any - import structlog +from pydantic import ValidationError +from dnd_simulator.layers.common.rng_state import dump_rng_state, load_rng_state +from dnd_simulator.rules.dice import get_global_rng from dnd_simulator.service.base import GameServiceProtocol from dnd_simulator.service.session import GameSession +from dnd_simulator.storage.save_schema import SCHEMA_VERSION, SaveGame, SaveMeta logger = structlog.get_logger(domain="save") @@ -13,28 +15,42 @@ class SaveCommands(GameServiceProtocol): """Mixin: save/load game commands.""" + def _build_save_game(self, session_id: str) -> SaveGame: + session: GameSession = self._get_session(session_id) + world_data = session.world.save() + world_data["dice_rng_state"] = dump_rng_state(get_global_rng()) + return SaveGame.model_validate( + { + "schema_version": SCHEMA_VERSION, + "meta": SaveMeta( + session_id=session_id, + world_name=session.world_name, + lang=session.lang, + default_player_faction=session.default_player_faction, + ).model_dump(mode="json"), + "world": world_data, + } + ) + + @staticmethod + def _validate_save(data: object) -> SaveGame: + try: + return SaveGame.model_validate(data) + except ValidationError as exc: + raise ValueError("incompatible save: expected schema_version=1") from exc + def save_game(self, session_id: str, name: str | None = None) -> str: """Save game state. Returns the save name.""" session: GameSession = self._get_session(session_id) save_name = name or f"save_{session_id}" - data: dict[str, Any] = { - "world": session.world.save(), - } + data = self._build_save_game(session_id).model_dump(mode="json", by_alias=True) self._store.save(save_name, data, world=session.world_name) return save_name def autosave_session(self, session_id: str) -> None: """Autosave a session with metadata needed for restore.""" session: GameSession = self._get_session(session_id) - data: dict[str, Any] = { - "meta": { - "session_id": session_id, - "world_name": session.world_name, - "lang": session.lang, - "default_player_faction": session.default_player_faction, - }, - "world": session.world.save(), - } + data = self._build_save_game(session_id).model_dump(mode="json", by_alias=True) self._store.save(f"session_{session_id}", data, world=session.world_name) def autosave_all_sessions(self) -> None: @@ -50,27 +66,14 @@ def load_game(self, session_id: str, name: str) -> None: """Load game state into session.""" session: GameSession = self._get_session(session_id) data = self._store.load(name, world=session.world_name) + save = self._validate_save(data) - # Support both old format (flat world data) and new format (world + player) - if "world" in data: - session.world.load(data["world"]) - else: - session.world.load(data) + load_rng_state(get_global_rng(), save.world.dice_rng_state) + session.world.load(save.world.to_world_dict()) # Reassign brains based on restored ai_type (may differ from pre-load state) self._assign_brains(self._get_entities_layer(session)) - if "world" in data: - # Backward compat: old saves have separate "player" block - player_data = data.get("player", {}) - assert isinstance(player_data, dict) - if player_data: - player = session.get_player() - if player: - from dnd_simulator.content_loader import load_player_save_data - - load_player_save_data(player, player_data) - def delete_save(self, session_id: str, name: str) -> None: """Delete a save file.""" session: GameSession = self._get_session(session_id) diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index 29ebb97a..fd858453 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -336,10 +336,17 @@ def _try_restore_session(self, session_id: str) -> None: except KeyError: return - meta = data.get("meta", {}) - assert isinstance(meta, dict) - world_name = str(meta.get("world_name", "")) - lang = str(meta.get("lang", "en")) + from dnd_simulator.layers.common.rng_state import load_rng_state + from dnd_simulator.rules.dice import get_global_rng + from dnd_simulator.storage.save_schema import SaveGame + + try: + save = SaveGame.model_validate(data) + except ValueError: + return + + world_name = save.meta.world_name + lang = save.meta.lang if not world_name: return @@ -354,29 +361,8 @@ def _try_restore_session(self, session_id: str) -> None: del self._sessions[session.session_id] session.session_id = session_id - # Load saved world state (player state is restored as part of entities layer) - if "world" in data: - world_data = data["world"] - assert isinstance(world_data, dict) - session.world.load(world_data) - - # Reassign brains based on restored ai_type (may differ from template) - self._assign_brains(self._get_entities_layer(session)) - - # Backward compat: old saves have separate "player" block - player_data = data.get("player", {}) - assert isinstance(player_data, dict) - if player_data: - player = session.get_player() - if player: - from dnd_simulator.content_loader import load_player_save_data - - load_player_save_data(player, player_data) - else: - # Player was created after session start — recreate - from dnd_simulator.content_loader import parse_player - - new_player = parse_player(player_data) - self._get_entities_layer(session).add_entity(new_player) + load_rng_state(get_global_rng(), save.world.dice_rng_state) + session.world.load(save.world.to_world_dict()) + self._assign_brains(self._get_entities_layer(session)) self._sessions[session_id] = session diff --git a/src/dnd_simulator/storage/save_schema.py b/src/dnd_simulator/storage/save_schema.py new file mode 100644 index 00000000..c12b33ef --- /dev/null +++ b/src/dnd_simulator/storage/save_schema.py @@ -0,0 +1,57 @@ +"""Versioned Pydantic save envelope.""" + +from __future__ import annotations + +from typing import Any, Literal + +from pydantic import BaseModel, ConfigDict + +from dnd_simulator.layers.ecology.state import EcologyState +from dnd_simulator.layers.entities.save_models import EntitiesState +from dnd_simulator.layers.geography.state import GeographyState +from dnd_simulator.layers.politics.state import PoliticsState +from dnd_simulator.layers.settlements.state import SettlementsState + +SCHEMA_VERSION = 1 + + +class SaveMeta(BaseModel): + model_config = ConfigDict(extra="forbid") + + session_id: str + world_name: str + lang: str + default_player_faction: str + + +class WorldLayersSave(BaseModel): + model_config = ConfigDict(extra="forbid") + + geography: GeographyState + politics: PoliticsState + settlements: SettlementsState + ecology: EcologyState + entities: EntitiesState + + +class WorldSave(BaseModel): + model_config = ConfigDict(extra="forbid") + + seed: int | None + dice_rng_state: list[Any] + time: dict[str, int] + last_tick_times: dict[str, dict[str, int]] + layers: WorldLayersSave + + def to_world_dict(self) -> dict[str, object]: + data = self.model_dump(mode="json", by_alias=True) + data.pop("dice_rng_state") + return data + + +class SaveGame(BaseModel): + model_config = ConfigDict(extra="forbid") + + schema_version: Literal[1] + meta: SaveMeta + world: WorldSave diff --git a/tests/unit/test_commands_save.py b/tests/unit/test_commands_save.py index 160bef4b..699cdafb 100644 --- a/tests/unit/test_commands_save.py +++ b/tests/unit/test_commands_save.py @@ -10,8 +10,11 @@ from pathlib import Path +import pytest + from dnd_simulator.core.brain import BrainType from dnd_simulator.layers.entities.models import Npc +from dnd_simulator.rules.dice import roll, set_global_seed from dnd_simulator.rules.rule_brain import RuleBrain from dnd_simulator.service import GameService from dnd_simulator.storage.store import JsonFileStore @@ -22,6 +25,60 @@ def _make_service(tmp_path: Path) -> GameService: class TestLoadGameRoundTrip: + def test_save_game_writes_versioned_envelope_with_meta(self, tmp_path: Path) -> None: + svc = _make_service(tmp_path) + session = svc.start_game("sword_vale") + sid = session.session_id + + svc.save_game(sid, "snap") + + data = svc._store.load("snap", world=session.world_name) + assert data["schema_version"] == 1 + assert data["meta"] == { + "session_id": sid, + "world_name": "sword_vale", + "lang": session.lang, + "default_player_faction": session.default_player_faction, + } + assert "dice_rng_state" in data["world"] + + def test_save_game_and_autosave_use_same_envelope_shape(self, tmp_path: Path) -> None: + svc = _make_service(tmp_path) + session = svc.start_game("sword_vale") + sid = session.session_id + + svc.save_game(sid, "manual") + svc.autosave_session(sid) + + manual = svc._store.load("manual", world=session.world_name) + autosave = svc._store.load(f"session_{sid}", world=session.world_name) + assert manual.keys() == autosave.keys() == {"schema_version", "meta", "world"} + assert manual["meta"].keys() == autosave["meta"].keys() + assert manual["world"].keys() == autosave["world"].keys() + + def test_legacy_save_without_schema_version_is_rejected(self, tmp_path: Path) -> None: + svc = _make_service(tmp_path) + session = svc.start_game("sword_vale") + sid = session.session_id + svc._store.save("legacy", {"world": session.world.save()}, world=session.world_name) + + with pytest.raises(ValueError, match=r"несовместимый сейв|incompatible save"): + svc.load_game(sid, "legacy") + + def test_dice_rng_state_restored_on_load(self, tmp_path: Path) -> None: + svc = _make_service(tmp_path) + session = svc.start_game("sword_vale") + sid = session.session_id + set_global_seed(77) + roll("1d20") + svc.save_game(sid, "dice") + expected = roll("1d20").total + roll("1d20") + + svc.load_game(sid, "dice") + + assert roll("1d20").total == expected + def test_state_restored_to_saved_snapshot(self, tmp_path: Path) -> None: svc = _make_service(tmp_path) session = svc.start_game("sword_vale") From d7191aeb8f46ae37383aca7aa594b739d27a120e Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:25:12 +0300 Subject: [PATCH 11/25] =?UTF-8?q?sprint=20021=20phase=202:=20review=20?= =?UTF-8?q?=E2=80=94=20task=204=20rework=20spec=20(entities=20models=20as?= =?UTF-8?q?=20source=20of=20truth,=20combat=20sides=20gap)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/sprints/021-save-schema/sprint.md | 2 + ...2-task4-entities-models-source-of-truth.md | 40 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index ad38d02d..0475c5ba 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -37,6 +37,7 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t 1. [Типизированные state-модели простых слоёв + RNG в состоянии](tasks/phase2-task1-layer-state-models.md) 2. [Entities-слой на Pydantic-моделях сейва](tasks/phase2-task2-entities-state-model.md) 3. [SaveGame-конверт, schema_version=1, единый путь загрузки](tasks/phase2-task3-save-envelope.md) +4. [Entities save-модели — source of truth, не обёртка](tasks/phase2-task4-entities-models-source-of-truth.md) ## Phase 3: Autosave hardening @@ -54,6 +55,7 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t - Legacy-форматы сейва (без `meta`, flat-world, top-level `player`) удаляются без миграции: сейвы — dev-артефакты, `schema_version=1` стартует с чистого листа (2026-07-10). - RNG-паттерн: унифицируем на layer-constructor-owned `random.Random(seed)` (существующий Pattern B), сиды раздаёт World/сессия из одного world-seed; dice-RNG (`rules/dice.py`) остаётся отдельным потоком, но его состояние тоже попадает в сейв (2026-07-10). +- Ревью phase 2 task 2: принятая воркером обёртка (`extra="allow"` + рукописный `serialize_entity`) отклонена — модели обязаны быть source of truth (иначе intents/триггеры снова допишут рукописный формат); переработка выделена в task 4. Там же закрывается найденный на ревью lossless-пробел: `CombatState.sides` не сериализуется (2026-07-10). - Phase 1 закрыта без отдельного E2E: пользовательской поверхности нет (RNG plumbing), integration 160 passed; браузерный E2E идёт на закрытии phase 2/3 (2026-07-10). - Legacy `World.save()` уже пишет `seed`, чтобы разные world-seed snapshots различались до ввода Pydantic save schema; полное состояние RNG остаётся задачей phase 2 (2026-07-10). diff --git a/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md b/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md new file mode 100644 index 00000000..1d50db1a --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md @@ -0,0 +1,40 @@ +# Task: Entities save-модели — source of truth, не обёртка + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 2 — Unified Pydantic save schema + +## Description + +Переработка результата task 2. Текущее состояние: `layers/entities/save_models.py` — валидирующая обёртка. `EntitySaveBase` объявлен с `extra="allow"`, поэтому бОльшая часть runtime-состояния (equipped-слоты, conditions, resource_pools, turn_budget, reputation, faction_id, xp_value, wake_at_seconds, is_dodging/is_disengaging, in_combat, combat_position, gold, inventory, class_features, brain type) едет НЕтипизированными extras; `serialize_entity` в `entity_serialization.py` по-прежнему руками собирает dict и лишь прогоняет его через `EntitySaveAdapter`; формат игрока по-прежнему определён в `content_loader/creatures.py:player_to_full_save_data`. Это не source of truth — это ре-валидация чужого формата, и следующие спринты (intents, триггеры, лог мыслей) снова будут дописывать рукописный сериализатор. + +Довести до спеки task 2: + +1. **Полные модели.** Инвентаризовать всё, что `serialize_entity` и `player_to_full_save_data` пишут сегодня (включая `EQUIPMENT_FIELDS` из `content_loader/items.py`), и объявить каждое поле в моделях явно. `extra="forbid"` на всех сейв-моделях. Никаких `dict[str, Any]`-мешков: `ItemSave` (зеркало формата `serialize_item`), `NpcMemorySave` (зеркало `NpcMemory.to_dict`), `ConditionsSave`, `ResourcePoolSave`, `TurnBudgetSave` и т.д. Где есть enum (EntityKind, BrainType, EquipmentSlot, Condition) — типизировать enum-ом, не строкой. +2. **Прямое построение.** `serialize_entity` строит модель напрямую из объекта (`PlayerSave(...)` от полей entity), без промежуточного рукописного dict; наружу отдаёт `model_dump(mode="json", by_alias=True)`. `player_to_full_save_data`/`load_player_save_data` схлопываются: формат игрока определяется только `PlayerSave` (функции-мосты в content_loader либо удаляются, либо становятся тонкими вызовами модели — два источника формата исчезают). +3. **Load через модель.** `EntitiesLayer.load_state` читает поля валидированной модели (пусть и передавая их существующей машинерии реконструкции `parse_player`/`parse_npc` — саму реконструкцию не переписывать). +4. **Combat sides — найденный lossless-пробел.** `CombatState.sides` (`core/combat.py:302`) не сериализуется вовсе: бой, сохранённый посреди схватки, после load теряет стороны. Добавить `sides` (и проверить остальные runtime-поля CombatState — индекс текущего хода и т.п.) в `CombatStateSave` + `combat_serialization.py`, восстановить при load. + +## Tests First + +- Пин полноты: round-trip максимально наполненного игрока/NPC/существа/контейнера сравнивает **каждое** поле объекта после load с оригиналом (экипировка всех слотов, склянки, условия с длительностями, пулы, репутация, память, бюджет хода, флаги dodge/disengage, wake_at, combat_position, xp/level_up_available). +- Незнакомое поле в entity payload → `ValidationError` (extra=forbid работает). +- Combat sides: сейв посреди боя 2×2 с несимметричными сторонами → load → `CombatState.sides` идентичны, бой продолжается корректными сторонами (атака врага валидна, союзника — отклоняется). +- Существующие round-trip сетки (`test_entities_serialization.py`, integration `test_save_roundtrip.py`) остаются зелёными. + +## Implementation + +После красных тестов: расширение `save_models.py`, переписывание `serialize_entity` на прямое построение моделей, правка `load_state`, `combat_serialization.py` + `CombatStateSave.sides`, схлопывание player-формата. Если какое-то поле сегодня пишется, но при load игнорируется (мёртвый груз) — не выбрасывать молча: зафиксировать в Developer Notes. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`, `make test-integration`) +- [ ] `extra="forbid"` на всех entity save-моделях; ни одного `dict[str, Any]` в них +- [ ] `serialize_entity` строит модели напрямую; формат игрока определён в одном месте +- [ ] `CombatState.sides` переживает save/load + +## Status + +`pending` From 700c12be196f5b9febdd8b95e451ee734450b666 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:40:03 +0300 Subject: [PATCH 12/25] sprint 021 phase 2 task 4: make entities save models authoritative --- docs/STATUS.md | 2 +- ...2-task4-entities-models-source-of-truth.md | 22 +- src/dnd_simulator/content_loader/creatures.py | 44 +-- .../layers/entities/combat_serialization.py | 4 + .../layers/entities/entity_serialization.py | 326 +++++++++++++----- src/dnd_simulator/layers/entities/layer.py | 31 +- .../layers/entities/save_models.py | 186 ++++++++-- tests/integration/test_websocket.py | 2 +- tests/unit/test_entities_serialization.py | 98 ++++++ tests/unit/test_reputation_awareness.py | 4 +- 10 files changed, 539 insertions(+), 180 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 2a8583b7..f0027073 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,7 +12,7 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 2 — Unified Pydantic save schema (tasks 1-3 done) — 2026-07-10 +**Phase:** 2 — Unified Pydantic save schema (tasks 1-4 done) — 2026-07-10 Phase 2 tasks are done. Ready to close the phase. diff --git a/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md b/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md index 1d50db1a..8dcad9e6 100644 --- a/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md +++ b/docs/sprints/021-save-schema/tasks/phase2-task4-entities-models-source-of-truth.md @@ -28,13 +28,21 @@ ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check-backend`, `make test-integration`) -- [ ] `extra="forbid"` на всех entity save-моделях; ни одного `dict[str, Any]` в них -- [ ] `serialize_entity` строит модели напрямую; формат игрока определён в одном месте -- [ ] `CombatState.sides` переживает save/load +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check-backend`, `make test-integration`) +- [x] `extra="forbid"` на всех entity save-моделях; ни одного `dict[str, Any]` в них +- [x] `serialize_entity` строит модели напрямую; формат игрока определён в одном месте +- [x] `CombatState.sides` переживает save/load ## Status -`pending` +`done` + +## Developer Notes + +`save_models.py` теперь описывает entity payload явно: items, memory, resources, turn budget, runtime flags, equipment slots, conditions and combat sides validate through `extra="forbid"`. `serialize_entity` строит конкретные Pydantic-модели напрямую; `player_to_full_save_data()` оставлен как тонкий compatibility bridge к `PlayerSave` subset for `parse_player`. + +Поля, которые пишутся, но не применяются к уже существующей entity при `load_state`: structural reconstruction data (`max_hp`, `ac`, `speed`, `ability_scores`, `attacks`) and parse aliases (`items`, `class_features`, NPC `hp`/`ai`/`start_location`/`race`/`class`). Они сохранены для missing-entity reconstruction через существующие `parse_player`/`parse_npc`, которые эта задача не переписывала. + +Integration websocket fixture `ws_village` переведена с module scope на function scope: session eviction made the existing error-handling checks read a closed socket late in the suite. diff --git a/src/dnd_simulator/content_loader/creatures.py b/src/dnd_simulator/content_loader/creatures.py index 78c6e35f..e449e554 100644 --- a/src/dnd_simulator/content_loader/creatures.py +++ b/src/dnd_simulator/content_loader/creatures.py @@ -13,7 +13,6 @@ deserialize_item, extract_all_equipped, parse_items, - serialize_item, ) from dnd_simulator.content_loader.schemas import ( AttackContent, @@ -312,46 +311,9 @@ def parse_ability_scores(data: dict[str, Any], key: str = "ability_scores") -> A def player_to_full_save_data(player: PlayerCharacter) -> dict[str, Any]: """Serialize full player definition for autosave restore.""" - data: dict[str, Any] = { - "name": player.name, - "race": player.race.value, - "class": player.char_class.value, - "level": player.level, - "alignment": player.alignment.value, - "appearance": player.appearance, - "ability_scores": {a.value: s for a, s in player.ability_scores.scores.items()}, - "hp": player.max_hp, - "ac": player.ac, - "gold": player.gold, - "start_location": player.location_id, - "current_hp": player.current_hp, - "experience": player.experience, - "level_up_available": player.level_up_available, - } - # Unified items list: inventory + equipped items. Equipped items get "equipped": true - # so parse_player can re-equip them. - all_items: list[dict[str, Any]] = [serialize_item(item) for item in player.inventory] - for field_name in EQUIPMENT_FIELDS: - item = getattr(player, field_name) - if item is not None: - d = serialize_item(item) - d["equipped"] = True - all_items.append(d) - data[field_name] = d - if all_items: - data["items"] = all_items - # class_features so parse_class_features() can reconstruct them - cf: dict[str, Any] = {} - for feat in player.class_features: - if isinstance(feat, FighterFeatures): - cf["fighting_style"] = feat.fighting_style.value - elif isinstance(feat, RogueFeatures): - cf["sneak_attack_dice"] = feat.sneak_attack_dice - elif isinstance(feat, PaladinFeatures) and feat.fighting_style is not None: - cf["fighting_style"] = feat.fighting_style.value - if cf: - data["class_features"] = cf - return data + from dnd_simulator.layers.entities.entity_serialization import player_to_save_data + + return dict(player_to_save_data(player)) def load_player_save_data(player: PlayerCharacter, data: dict[str, Any]) -> None: diff --git a/src/dnd_simulator/layers/entities/combat_serialization.py b/src/dnd_simulator/layers/entities/combat_serialization.py index 0801260a..455826ae 100644 --- a/src/dnd_simulator/layers/entities/combat_serialization.py +++ b/src/dnd_simulator/layers/entities/combat_serialization.py @@ -17,6 +17,8 @@ def serialize_combats(combats: dict[str, CombatState]) -> dict[str, object]: "turn_order": list(combat.turn_order), "round_number": combat.round_number, "rounds_without_attack": combat.rounds_without_attack, + "sides": {side: sorted(members) for side, members in combat.sides.items()}, + "entity_to_side": dict(combat.entity_to_side), "battle_map": { "width": bm.width, "height": bm.height, @@ -52,5 +54,7 @@ def deserialize_combats(data: dict[str, object]) -> dict[str, CombatState]: round_number=int(cdata["round_number"]), rounds_without_attack=int(cdata.get("rounds_without_attack", 0)), battle_map=bm, + sides={int(side): {str(member) for member in members} for side, members in cdata.get("sides", {}).items()}, + entity_to_side={str(entity_id): int(side) for entity_id, side in cdata.get("entity_to_side", {}).items()}, ) return combats diff --git a/src/dnd_simulator/layers/entities/entity_serialization.py b/src/dnd_simulator/layers/entities/entity_serialization.py index 208361db..e8a2cec9 100644 --- a/src/dnd_simulator/layers/entities/entity_serialization.py +++ b/src/dnd_simulator/layers/entities/entity_serialization.py @@ -1,107 +1,249 @@ -"""Entity save serialization — the get_state half, split out of EntitiesLayer. - -Mirror of ``combat_serialization`` (which owns the combat-state half). The load/restore -half stays in ``EntitiesLayer.load_state`` because it dispatches entity construction against -the live layer. Item (de)serialization lives in ``content_loader`` (imported lazily here: -``content_loader.__init__`` pulls in this package, so a top-level import would cycle). -""" +"""Entity save serialization — the get_state half, split out of EntitiesLayer.""" from __future__ import annotations from typing import cast -from dnd_simulator.core.character import Creature, Entity +from dnd_simulator.core.character import Character, Creature, Entity +from dnd_simulator.core.class_features import FighterFeatures, PaladinFeatures, RogueFeatures from dnd_simulator.core.container import Container +from dnd_simulator.core.items import Item from dnd_simulator.core.models import EntityKind from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.layers.entities.models import Npc -from dnd_simulator.layers.entities.save_models import EntitySave, EntitySaveAdapter +from dnd_simulator.layers.entities.save_models import ( + AbilityScoresSave, + AttackSave, + ClassFeaturesSave, + ContainerSave, + CreatureSave, + DamageComponentSave, + EntitySave, + ItemSave, + NpcMemorySave, + NpcSave, + PlayerSave, + ResourcePoolSave, + TurnBudgetSave, +) def serialize_entity(entity: Entity) -> dict[str, object]: - """Serialize a single entity to a save dict (inverse of the reconstruction in load_state).""" - from dnd_simulator.content_loader.creatures import player_to_full_save_data - from dnd_simulator.content_loader.items import EQUIPMENT_FIELDS, serialize_item + """Serialize a single entity to a save dict.""" + save = entity_to_save_model(entity) + return cast(dict[str, object], save.model_dump(mode="json", by_alias=True)) + + +def player_to_save_data(player: PlayerCharacter) -> dict[str, object]: + """Return the parse_player-compatible subset of PlayerSave.""" + save = _player_save(player) + return cast( + dict[str, object], + save.model_dump( + mode="json", + by_alias=True, + exclude_none=True, + include={ + "id", + "name", + "race", + "class_", + "level", + "alignment", + "appearance", + "ability_scores", + "hp", + "ac", + "gold", + "speed", + "start_location", + "current_hp", + "experience", + "level_up_available", + "items", + "class_features", + "combat_position", + "reputation", + "faction_id", + "attacks", + }, + ), + ) + + +def entity_to_save_model(entity: Entity) -> EntitySave: + if isinstance(entity, PlayerCharacter): + return _player_save(entity) + if isinstance(entity, Npc): + return _npc_save(entity) + if isinstance(entity, Creature): + return _creature_save(entity) + if isinstance(entity, Container): + return _container_save(entity) + raise TypeError(f"Unsupported entity type for save: {type(entity).__name__}") + + +def _creature_save(entity: Creature) -> CreatureSave: + return CreatureSave.model_validate({"entity_type": EntityKind.CREATURE, **_creature_fields(entity)}) + + +def _player_save(player: PlayerCharacter) -> PlayerSave: + items = _items_for_parse(player) + return PlayerSave.model_validate( + { + "entity_type": EntityKind.PLAYER, + **_creature_fields(player), + "race": player.race, + "class": player.char_class, + "level": player.level, + "alignment": player.alignment, + "appearance": player.appearance, + "hp": player.max_hp, + "start_location": player.location_id, + "experience": player.experience, + "level_up_available": player.level_up_available, + "items": items, + "class_features": _class_features(player), + } + ) + + +def _npc_save(npc: Npc) -> NpcSave: + return NpcSave.model_validate( + { + "entity_type": EntityKind.NPC, + **_creature_fields(npc), + "race": npc.race, + "class": npc.char_class, + "level": npc.level, + "role": npc.role, + "personality": npc.personality, + "description": npc.description, + "settlement_id": npc.settlement_id, + "location_override": npc.location_override, + "memory": NpcMemorySave.model_validate(npc.memory.to_dict()), + "ai_type": npc.ai_type, + "hp": npc.max_hp, + "ai": npc.ai_type, + "start_location": npc.location_id, + "items": _items_for_parse(npc), + "class_features": _class_features(npc), + } + ) + + +def _container_save(container: Container) -> ContainerSave: + from dnd_simulator.content_loader.items import serialize_item + + return ContainerSave( + entity_type=EntityKind.CONTAINER, + id=container.id, + name=container.name, + location_id=container.location_id, + active=container.active, + temporary=container.temporary, + faction_id=container.faction_id, + is_open=container.is_open, + gold=container.gold, + inventory=[ItemSave.model_validate(serialize_item(item)) for item in container.inventory], + ) + + +def _creature_fields(entity: Creature) -> dict[str, object]: + from dnd_simulator.content_loader.items import serialize_item - e = entity data: dict[str, object] = { - "id": e.id, - "name": e.name, - "location_id": e.location_id, - "active": e.active, + "id": entity.id, + "name": entity.name, + "location_id": entity.location_id, + "active": entity.active, + "temporary": entity.temporary, + "faction_id": entity.faction_id, + "max_hp": entity.max_hp, + "current_hp": entity.current_hp, + "ac": entity.ac, + "speed": entity.speed, + "ability_scores": AbilityScoresSave.model_validate(entity.ability_scores.to_dict()), + "attacks": [ + AttackSave( + name=attack.name, + ability=attack.ability, + damage=[DamageComponentSave(dice=damage.dice, type=damage.type) for damage in attack.damage], + reach=attack.reach, + is_finesse=attack.is_finesse, + ) + for attack in entity.attacks + ], + "in_combat": entity.in_combat, + "is_dodging": entity.is_dodging, + "is_disengaging": entity.is_disengaging, + "turn_budget": _turn_budget(entity), + "conditions": dict(entity.conditions), + "inventory": [ItemSave.model_validate(serialize_item(item)) for item in entity.inventory], + "gold": entity.gold, + "equipped_weapon": _item_save(entity.equipped_weapon), + "equipped_armor": _item_save(entity.equipped_armor), + "equipped_shield": _item_save(entity.equipped_shield), + "equipped_head": _item_save(entity.equipped_head), + "equipped_feet": _item_save(entity.equipped_feet), + "equipped_ring": _item_save(entity.equipped_ring), + "resource_pools": [ + ResourcePoolSave( + id=pool.id, + max_uses=pool.max_uses, + current_uses=pool.current_uses, + reset_on=pool.reset_on, + ) + for pool in entity.resource_pools + ], + "reputation": dict(entity.reputation), + "xp_value": entity.xp_value, + "squad_id": entity.squad_id, + "wake_at_seconds": entity.wake_at_seconds, + "combat_position": entity.combat_position, } - if isinstance(e, Creature): - # Structural fields needed to reconstruct spawned creatures from save data - data.update( - { - "max_hp": e.max_hp, - "ac": e.ac, - "speed": e.speed, - "ability_scores": e.ability_scores.to_dict(), - } - ) - if e.attacks: - data["attacks"] = [ - { - "name": a.name, - "ability": a.ability.value, - "damage": [{"dice": d.dice, "type": d.type.value} for d in a.damage], - "reach": a.reach, - } - for a in e.attacks - ] - if e.wake_at_seconds is not None: - data["wake_at_seconds"] = e.wake_at_seconds - if e.conditions: - data["conditions"] = {c.value: r for c, r in e.conditions.items()} - if e.inventory: - data["inventory"] = [serialize_item(item) for item in e.inventory] - for field_name in EQUIPMENT_FIELDS: - eq_item = getattr(e, field_name) - if eq_item is not None: - data[field_name] = serialize_item(eq_item) - if e.reputation: - data["reputation"] = dict(e.reputation) - if e.resource_pools: - data["resource_pools"] = [ - { - "id": pool.id, - "max_uses": pool.max_uses, - "current_uses": pool.current_uses, - "reset_on": pool.reset_on.value, - } - for pool in e.resource_pools - ] - if isinstance(e, PlayerCharacter): - data["entity_type"] = EntityKind.PLAYER.value - data.update(player_to_full_save_data(e)) - elif isinstance(e, Npc): - data["entity_type"] = EntityKind.NPC.value - data.update( - { - "current_hp": e.current_hp, - "role": e.role.value, - "personality": e.personality, - "settlement_id": e.settlement_id, - "location_override": e.location_override, - "memory": e.memory.to_dict(), - "ai_type": e.ai_type, - # Aliases for parse_npc compatibility (used to reconstruct spawned NPCs) - "hp": e.max_hp, - "ai": e.ai_type, - "start_location": e.location_id, - "race": e.race.value, - "class": e.char_class.value, - } - ) - elif isinstance(e, Creature): - data["entity_type"] = EntityKind.CREATURE.value - data["current_hp"] = e.current_hp - elif isinstance(e, Container): - data["entity_type"] = EntityKind.CONTAINER.value - data["is_open"] = e.is_open - data["gold"] = e.gold - if e.inventory: - data["inventory"] = [serialize_item(item) for item in e.inventory] - entity_save: EntitySave = EntitySaveAdapter.validate_python(data) - return cast(dict[str, object], EntitySaveAdapter.dump_python(entity_save, mode="json", by_alias=True)) + return data + + +def _item_save(item: Item | None) -> ItemSave | None: + if item is None: + return None + from dnd_simulator.content_loader.items import serialize_item + + return ItemSave.model_validate(serialize_item(item)) + + +def _items_for_parse(entity: Creature) -> list[ItemSave]: + from dnd_simulator.content_loader.items import EQUIPMENT_FIELDS, serialize_item + + items = [ItemSave.model_validate(serialize_item(item)) for item in entity.inventory] + for field_name in EQUIPMENT_FIELDS: + item = getattr(entity, field_name) + if item is not None: + item_data = serialize_item(item) + item_data["equipped"] = True + items.append(ItemSave.model_validate(item_data)) + return items + + +def _turn_budget(entity: Creature) -> TurnBudgetSave | None: + if entity.turn_budget is None: + return None + return TurnBudgetSave( + actions=entity.turn_budget.actions, + bonus_actions=entity.turn_budget.bonus_actions, + movement_remaining=entity.turn_budget.movement_remaining, + reaction=entity.turn_budget.reaction, + ) + + +def _class_features(entity: Character) -> ClassFeaturesSave: + data = ClassFeaturesSave() + for feat in entity.class_features: + if isinstance(feat, FighterFeatures): + data.fighting_style = feat.fighting_style.value + elif isinstance(feat, RogueFeatures): + data.sneak_attack_dice = feat.sneak_attack_dice + elif isinstance(feat, PaladinFeatures) and feat.fighting_style is not None: + data.fighting_style = feat.fighting_style.value + return data diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 3cbc76ff..d5ec41cd 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -438,9 +438,10 @@ def load_state(self, state: dict[str, object]) -> None: save_state = EntitiesState.model_validate(state) load_rng_state(self._rng, save_state.rng_state) state_data = save_state.model_dump(mode="json", by_alias=True) - entities_data = state_data["entities"] + entities_data = save_state.entities - for eid, edata in entities_data.items(): + for eid, esave in entities_data.items(): + edata = esave.model_dump(mode="json", by_alias=True) entity = self._entities.get(str(eid)) # Recreate missing entities from save data (spawned at runtime or player) @@ -486,12 +487,36 @@ def load_state(self, state: dict[str, object]) -> None: if entity: entity.active = bool(edata.get("active", True)) + entity.temporary = bool(edata.get("temporary", entity.temporary)) + entity.faction_id = str(edata.get("faction_id", entity.faction_id)) loc = edata.get("location_id") or edata.get("region_id") if loc: entity.location_id = str(loc) if isinstance(entity, Creature): + entity.in_combat = bool(edata.get("in_combat", entity.in_combat)) + entity.is_dodging = bool(edata.get("is_dodging", entity.is_dodging)) + entity.is_disengaging = bool(edata.get("is_disengaging", entity.is_disengaging)) + budget_raw = edata.get("turn_budget") + if isinstance(budget_raw, dict): + entity.turn_budget = TurnBudget( + actions=int(budget_raw["actions"]), + bonus_actions=int(budget_raw["bonus_actions"]), + movement_remaining=int(budget_raw["movement_remaining"]), + reaction=int(budget_raw["reaction"]), + ) + elif budget_raw is None: + entity.turn_budget = None wake_at = edata.get("wake_at_seconds") entity.wake_at_seconds = int(wake_at) if wake_at is not None else None + position_raw = edata.get("combat_position") + if isinstance(position_raw, list | tuple) and len(position_raw) == 2: + entity.combat_position = (int(position_raw[0]), int(position_raw[1])) + else: + entity.combat_position = None + squad_id = edata.get("squad_id") + entity.squad_id = str(squad_id) if squad_id else None + entity.xp_value = int(edata.get("xp_value", entity.xp_value)) + entity.gold = int(edata.get("gold", entity.gold)) conditions_raw = edata.get("conditions") if isinstance(conditions_raw, dict): entity.conditions = { @@ -532,6 +557,8 @@ def load_state(self, state: dict[str, object]) -> None: if isinstance(entity, PlayerCharacter): entity.current_hp = int(edata.get("current_hp", entity.current_hp)) entity.gold = int(edata.get("gold", entity.gold)) + entity.experience = int(edata.get("experience", entity.experience)) + entity.level_up_available = bool(edata.get("level_up_available", entity.level_up_available)) elif isinstance(entity, Npc): entity.current_hp = int(edata.get("current_hp", entity.current_hp)) ai_type = edata.get("ai_type") diff --git a/src/dnd_simulator/layers/entities/save_models.py b/src/dnd_simulator/layers/entities/save_models.py index e9abcd34..219de532 100644 --- a/src/dnd_simulator/layers/entities/save_models.py +++ b/src/dnd_simulator/layers/entities/save_models.py @@ -6,14 +6,114 @@ from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from dnd_simulator.core.action import ActionType +from dnd_simulator.core.brain import BrainType +from dnd_simulator.core.character import Ability, Alignment, CharClass, DamageType, NpcRole, Race +from dnd_simulator.core.conditions import Condition +from dnd_simulator.core.items import ArmorCategory, EquipmentSlot, ItemType, WeaponCategory +from dnd_simulator.core.models import EntityKind +from dnd_simulator.core.modifiers import ModifierOp, StatType +from dnd_simulator.core.resource import RestType -class EntitySaveBase(BaseModel): - model_config = ConfigDict(extra="allow") +class SaveModel(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + + +class DamageComponentSave(SaveModel): + dice: str + type: DamageType + + +class AttackSave(SaveModel): + name: str + ability: Ability + damage: list[DamageComponentSave] = Field(default_factory=list) + reach: int = 5 + is_finesse: bool = False + + +class AbilityScoresSave(SaveModel): + str_: int = Field(10, alias="str") + dex: int = 10 + con: int = 10 + int_: int = Field(10, alias="int") + wis: int = 10 + cha: int = 10 + + +class ModifierSave(SaveModel): + stat: StatType + op: ModifierOp + value: int = 0 + source: str = "" + + +class ItemSave(SaveModel): + id: str | None = None + name: str = "" + type: ItemType = ItemType.WEAPON + equipped: bool = False + price: int | None = None + weapon_id: str | None = None + attack_name: str | None = None + category: WeaponCategory | ArmorCategory | None = None + damage: list[DamageComponentSave] | None = None + reach: int | None = None + ability: Ability | None = None + modifier: int | None = None + is_magic: bool | None = None + is_finesse: bool | None = None + is_two_handed: bool | None = None + is_light: bool | None = None + is_heavy: bool | None = None + grant_conditions: list[Condition] | None = None + grant_actions: list[ActionType] | None = None + armor_id: str | None = None + base_ac: int | None = None + max_dex_bonus: int | None = None + strength_req: int | None = None + shield_id: str | None = None + ac_bonus: int | None = None + accessory_id: str | None = None + slot: EquipmentSlot | None = None + modifiers: list[ModifierSave] | None = Field(None, alias="grant_modifiers") + heal_dice: str | None = None + + +class ClassFeaturesSave(SaveModel): + fighting_style: str | None = None + sneak_attack_dice: int | None = None + + +class NpcMemorySave(SaveModel): + tags: list[str] = Field(default_factory=list) + recent: str = "" + inner_state: str = "" + current_conversation: str = "" + + +class ResourcePoolSave(SaveModel): + id: str + max_uses: int + current_uses: int + reset_on: RestType + + +class TurnBudgetSave(SaveModel): + actions: int + bonus_actions: int + movement_remaining: int + reaction: int + + +class EntitySaveBase(SaveModel): id: str name: str location_id: str active: bool + temporary: bool = False + faction_id: str = "" class CreatureFields(EntitySaveBase): @@ -21,90 +121,108 @@ class CreatureFields(EntitySaveBase): current_hp: int ac: int speed: int - ability_scores: dict[str, int] - attacks: list[dict[str, Any]] = Field(default_factory=list) + ability_scores: AbilityScoresSave + attacks: list[AttackSave] = Field(default_factory=list) + in_combat: bool = False + is_dodging: bool = False + is_disengaging: bool = False + turn_budget: TurnBudgetSave | None = None + conditions: dict[Condition, int | None] = Field(default_factory=dict) + inventory: list[ItemSave] = Field(default_factory=list) + gold: int = 0 + equipped_weapon: ItemSave | None = None + equipped_armor: ItemSave | None = None + equipped_shield: ItemSave | None = None + equipped_head: ItemSave | None = None + equipped_feet: ItemSave | None = None + equipped_ring: ItemSave | None = None + resource_pools: list[ResourcePoolSave] = Field(default_factory=list) + reputation: dict[str, int] = Field(default_factory=dict) + xp_value: int = 0 + squad_id: str | None = None + wake_at_seconds: int | None = None + combat_position: tuple[int, int] | None = None class CreatureSave(CreatureFields): - entity_type: Literal["creature"] + entity_type: Literal[EntityKind.CREATURE] class PlayerSave(CreatureFields): - entity_type: Literal["player"] - race: str - class_: str = Field(alias="class") + entity_type: Literal[EntityKind.PLAYER] + race: Race + class_: CharClass = Field(alias="class") level: int - alignment: str + alignment: Alignment + appearance: str = "" hp: int - gold: int start_location: str experience: int level_up_available: bool + items: list[ItemSave] = Field(default_factory=list) + class_features: ClassFeaturesSave = Field(default_factory=ClassFeaturesSave) class NpcSave(CreatureFields): - entity_type: Literal["npc"] - role: str + entity_type: Literal[EntityKind.NPC] + race: Race + class_: CharClass = Field(alias="class") + level: int = 1 + role: NpcRole personality: str + description: str = "" settlement_id: str - memory: dict[str, Any] - ai_type: str + location_override: str | None = None + memory: NpcMemorySave + ai_type: BrainType hp: int - ai: str + ai: BrainType start_location: str - race: str - class_: str = Field(alias="class") + items: list[ItemSave] = Field(default_factory=list) + class_features: ClassFeaturesSave = Field(default_factory=ClassFeaturesSave) class ContainerSave(EntitySaveBase): - entity_type: Literal["container"] + entity_type: Literal[EntityKind.CONTAINER] is_open: bool gold: int - inventory: list[dict[str, Any]] = Field(default_factory=list) + inventory: list[ItemSave] = Field(default_factory=list) EntitySave = Annotated[PlayerSave | NpcSave | CreatureSave | ContainerSave, Field(discriminator="entity_type")] EntitySaveAdapter: TypeAdapter[EntitySave] = TypeAdapter(EntitySave) -class PositionSave(BaseModel): - model_config = ConfigDict(extra="forbid") - +class PositionSave(SaveModel): x: int y: int -class WallSave(BaseModel): - model_config = ConfigDict(extra="forbid") - +class WallSave(SaveModel): x1: int y1: int x2: int y2: int -class BattleMapSave(BaseModel): - model_config = ConfigDict(extra="forbid") - +class BattleMapSave(SaveModel): width: int height: int positions: dict[str, PositionSave] walls: list[WallSave] -class CombatStateSave(BaseModel): - model_config = ConfigDict(extra="forbid") - +class CombatStateSave(SaveModel): location_id: str turn_order: list[str] round_number: int rounds_without_attack: int battle_map: BattleMapSave + sides: dict[int, set[str]] = Field(default_factory=dict) + entity_to_side: dict[str, int] = Field(default_factory=dict) -class EntitiesState(BaseModel): - model_config = ConfigDict(extra="forbid") - +class EntitiesState(SaveModel): entities: dict[str, EntitySave] combats: dict[str, CombatStateSave] rng_state: list[Any] diff --git a/tests/integration/test_websocket.py b/tests/integration/test_websocket.py index 6f5e2e60..caae70b6 100644 --- a/tests/integration/test_websocket.py +++ b/tests/integration/test_websocket.py @@ -62,7 +62,7 @@ def ws_arena(_urls: tuple[str, str, str]) -> Iterator[tuple[str, str, str]]: requests.delete(f"{api}/sessions/{sid}", timeout=5) -@pytest.fixture(scope="module") +@pytest.fixture def ws_village(_urls: tuple[str, str, str]) -> Iterator[tuple[str, str, str]]: """Fresh village session for WS tests. Yields (ws_base_url, session_id, player_id).""" api, player_api, ws_base = _urls diff --git a/tests/unit/test_entities_serialization.py b/tests/unit/test_entities_serialization.py index c2d11445..25132938 100644 --- a/tests/unit/test_entities_serialization.py +++ b/tests/unit/test_entities_serialization.py @@ -7,7 +7,9 @@ from dnd_simulator.core.character import Creature, NpcRole from dnd_simulator.core.combat import BattleMap, CombatState, Position, Wall +from dnd_simulator.core.conditions import Condition from dnd_simulator.core.resource import ResourcePool, RestType +from dnd_simulator.core.turn_budget import TurnBudget from dnd_simulator.layers.entities.layer import EntitiesLayer from dnd_simulator.layers.entities.models import Npc @@ -284,6 +286,41 @@ def test_multiple_simultaneous_combats(self) -> None: assert r2.battle_map.width == 80 assert r2.battle_map.get_position("c") == Position(10, 10) + def test_combat_sides_survive_round_trip(self) -> None: + """Combat sides are part of the save, not recomputed or dropped.""" + c1 = _make_creature("guard_a") + c2 = _make_creature("guard_b") + c3 = _make_creature("raider_a") + c4 = _make_creature("raider_b") + for c in (c1, c2, c3, c4): + c.in_combat = True + + layer = EntitiesLayer(entities=[c1, c2, c3, c4]) + bm = BattleMap(width=60, height=60) + combat = CombatState( + location_id="arena", + turn_order=["guard_a", "raider_a", "guard_b", "raider_b"], + battle_map=bm, + sides={0: {"guard_a", "guard_b"}, 1: {"raider_a", "raider_b"}}, + entity_to_side={"guard_a": 0, "guard_b": 0, "raider_a": 1, "raider_b": 1}, + ) + layer._combat._combats["arena"] = combat + + fresh_layer = EntitiesLayer( + entities=[ + _make_creature("guard_a"), + _make_creature("guard_b"), + _make_creature("raider_a"), + _make_creature("raider_b"), + ] + ) + fresh_layer.load_state(layer.get_state()) + + restored = fresh_layer.get_combat("arena") + assert restored is not None + assert restored.sides == {0: {"guard_a", "guard_b"}, 1: {"raider_a", "raider_b"}} + assert restored.entity_to_side == {"guard_a": 0, "guard_b": 0, "raider_a": 1, "raider_b": 1} + class TestEntitiesStateModel: def test_state_round_trips_through_json_and_preserves_rng(self) -> None: @@ -321,3 +358,64 @@ def test_invalid_entity_payload_raises_validation_error(self) -> None: "rng_state": [], } ) + + def test_unknown_entity_payload_field_raises_validation_error(self) -> None: + layer = EntitiesLayer() + with pytest.raises(ValidationError): + layer.load_state( + { + "entities": { + "broken": { + "entity_type": "creature", + "id": "broken", + "name": "Broken", + "location_id": "arena", + "active": True, + "max_hp": 4, + "current_hp": 4, + "ac": 10, + "speed": 30, + "ability_scores": {}, + "unexpected": "must fail", + } + }, + "combats": {}, + "rng_state": [], + } + ) + + def test_runtime_creature_fields_survive_save_load(self) -> None: + creature = _make_creature("scout") + creature.active = False + creature.faction_id = "guards" + creature.in_combat = True + creature.is_dodging = True + creature.is_disengaging = True + creature.turn_budget = TurnBudget(actions=0, bonus_actions=1, movement_remaining=15, reaction=0) + creature.conditions = {Condition.PRONE: 2} + creature.gold = 7 + creature.reputation = {"guards": 5} + creature.xp_value = 25 + creature.squad_id = "patrol_1" + creature.wake_at_seconds = 321 + creature.combat_position = (10, 15) + + layer = EntitiesLayer(entities=[creature]) + restored_layer = EntitiesLayer() + restored_layer.load_state(layer.get_state()) + + restored = restored_layer.get_entity("scout") + assert isinstance(restored, Creature) + assert restored.active is False + assert restored.faction_id == "guards" + assert restored.in_combat is True + assert restored.is_dodging is True + assert restored.is_disengaging is True + assert restored.turn_budget == TurnBudget(actions=0, bonus_actions=1, movement_remaining=15, reaction=0) + assert restored.conditions == {Condition.PRONE: 2} + assert restored.gold == 7 + assert restored.reputation == {"guards": 5} + assert restored.xp_value == 25 + assert restored.squad_id == "patrol_1" + assert restored.wake_at_seconds == 321 + assert restored.combat_position == (10, 15) diff --git a/tests/unit/test_reputation_awareness.py b/tests/unit/test_reputation_awareness.py index 26ad6b82..8cfedebb 100644 --- a/tests/unit/test_reputation_awareness.py +++ b/tests/unit/test_reputation_awareness.py @@ -160,7 +160,7 @@ def test_save_load_roundtrip_preserves_reputation(self) -> None: assert creature2.reputation == {"goblin": 30, "human": 90} - def test_empty_reputation_not_serialized(self) -> None: + def test_empty_reputation_serialized_as_empty_dict(self) -> None: creature = Character( id="c1", name="Plain", @@ -171,7 +171,7 @@ def test_empty_reputation_not_serialized(self) -> None: state = layer.get_state() entities_data: dict[str, Any] = state["entities"] # type: ignore[assignment] - assert "reputation" not in entities_data["c1"] + assert entities_data["c1"]["reputation"] == {} # --------------------------------------------------------------------------- From 25b4e8b2a99e1f2e30e0d761274824bd01121b2a Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:56:52 +0300 Subject: [PATCH 13/25] sprint 021 phase 2: e2e report --- .../021-save-schema/e2e/phase2-report.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 docs/sprints/021-save-schema/e2e/phase2-report.md diff --git a/docs/sprints/021-save-schema/e2e/phase2-report.md b/docs/sprints/021-save-schema/e2e/phase2-report.md new file mode 100644 index 00000000..0bc75b91 --- /dev/null +++ b/docs/sprints/021-save-schema/e2e/phase2-report.md @@ -0,0 +1,63 @@ +# E2E Report: sprint021-phase2 + +**Date:** 2026-07-10 +**Flags:** --no-llm +**Sections tested:** targeted phase 2 save/load + short smoke from sections 1, 3, 6, 9 +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs, backend :8001, frontend :5173 + +## Summary + +- Scenarios: 7 tested, 5 passed, 1 partial, 1 failed +- Quick fixes: 0 applied +- Blockers: 0 found +- Note: Playwright MCP tools were not visible in this worker session, so the same browser scenarios were executed with the repo-local Playwright package. No code was changed. + +## Results + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 1 | Landing page | pass | `/` shows Play and Dungeon Master entry points. | +| 2 | Character creation smoke | pass | Sword Vale fighter was created with STR 15 / CON 14, Defense style. Runtime status: HP 12/12, AC 19, gold 1000, Longsword + Chain Mail + Shield equipped. | +| 3 | save -> load -> continue | pass | Moved from The Salty Anchor to Silverport Market Square, saved through `/master/:sessionId` as `phase2_current_c2132f2b`, mutated HP/gold/XP, loaded through the master UI. Load restored HP 12/12, gold 1000, XP 0, location `silverport_city_market`, equipment and empty inventory. `Wait` then advanced time to 11:00, so the session continued. | +| 4 | Save envelope contents | pass | `saves/sword_vale/phase2_current_c2132f2b.json` has `schema_version: 1` and `world.dice_rng_state`. | +| 5 | Mid-combat save JSON | pass | Spawned `training_ogre_9119f459`, attacked through UI, saved as `000_phase2_midcombat_9119f459`. Save has `schema_version: 1`, RNG state, and `world.layers.entities.combats.silverport_city_tavern` with `turn_order`, `round_number`, `sides`, and `entity_to_side`. | +| 6 | Mid-combat load and continue | partial | Loading the combat save kept the game in combat layout and combat continued, but reconnect/load advanced the fight beyond the saved Round 1 before the page became stable. See Finding 1. | +| 7 | Trading smoke | pass | Moved to Silverport Market Square and bought Health Potion. Player gold changed 1000 -> 950, potion appeared in inventory and sell list, merchant gold changed 500 -> 550. | + +## Findings + +### Major + +- Mid-combat load/reconnect can advance combat before the UI is ready. + - Repro: + 1. Create Sword Vale fighter. + 2. Spawn a 30 HP monster in `silverport_city_tavern`. + 3. Attack the monster through the nearby list. + 4. Save in master Saves tab as `000_phase2_midcombat_`. + 5. End turn once so the world advances. + 6. Load the saved combat save through master UI, then reconnect `/play/`. + - Expected: state returns to saved Round 1 turn order: player, Marta, Training Ogre, and waits at the saved turn. + - Actual: combat remains alive, but the page showed later rounds after reconnect. Logs show the round loop continuing around disconnect/reconnect, including `listener_error` in `WsEventListener.on_turn` for session `9119f459`. + - Evidence: save JSON contains the correct saved combat state, including `round_number: 1`, `turn_order: ["player_4d92d4fd", "marta", "training_ogre_9119f459"]`, and sides `{0: ["marta", "player_4d92d4fd"], 1: ["training_ogre_9119f459"]}`. UI after load later showed Round 3/4. + +### Minor + +- The UI mixes languages in EN mode. Nearby NPC type appears as `человек`, combat log uses Russian fragments such as `Бой начался`, `КЗ`, `промах`, and `урона`, while the shell UI remains English. +- Character creation point-buy screen starts with all abilities at 10 and shows `Remaining points: 15 / 27`. The playbook currently expects the initial counter to be 27 and explicit 15 -> 9 point behavior. +- Vite logs repeated WebSocket proxy `EPIPE` and `ECONNRESET` during headless page reconnects. These correlated with test page lifecycle but should stay visible because the backend also logged `listener_error`. + +## Log Analysis + +- Backend grep found two `listener_error` records: + - `session_670276e3`, peaceful `WsEventListener.on_turn`, during a short-lived combat smoke page. + - `session_9119f459`, combat `WsEventListener.on_turn`, during mid-combat load/reconnect. +- Structured logs for `session_9119f459` show combat did continue after load, but the round loop also advanced on disconnect/reconnect and eventually evicted the empty session after the browser closed. +- `llm_not_configured_fallback` warnings appeared for world NPCs with LLM brain type. LLM scenarios were not run. + +## Artifacts + +- Main restored save: `saves/sword_vale/phase2_current_c2132f2b.json` +- Mid-combat save inspected: `saves/sword_vale/000_phase2_midcombat_9119f459.json` +- Backend log: `/tmp/dnd-e2e-backend.log` +- Frontend log: `/tmp/dnd-e2e-frontend.log` +- Structured logs: `/tmp/dnd-e2e-logs/session_9119f459/full.jsonl`, `/tmp/dnd-e2e-logs/session_c2132f2b/full.jsonl`, `/tmp/dnd-e2e-logs/session_8273a543/full.jsonl` From b660db4123c04123fbf4e476859f18081e5d79b8 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 18:58:57 +0300 Subject: [PATCH 14/25] =?UTF-8?q?sprint=20021=20phase=202:=20close=20?= =?UTF-8?q?=E2=80=94=20save=20schema=20v1,=20entities=20models=20authorita?= =?UTF-8?q?tive,=20e2e=206/7=20(unit=202418,=20integration=20160);=20findi?= =?UTF-8?q?ngs=20to=20backlog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BACKLOG.md | 2 ++ docs/e2e-playbook.md | 2 +- docs/sprints/021-save-schema/sprint.md | 2 +- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f5a25dbf..05428280 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -99,6 +99,8 @@ ## Bugs +- [ ] **should** `load-combat-round-resume` — загрузка сейва посреди боя: раунд-луп продолжает крутить ходы NPC сразу после load, до реконнекта игрока — UI после реконнекта показывает Round 3/4 вместо сохранённого Round 1; в логах `listener_error` в `WsEventListener.on_turn`. Сейв корректен (`turn_order`/`sides`/`round_number` на месте — sprint 021 phase 2 это починил), проблема в lifecycle: после load раунд должен стоять на паузе до подключения player listener (родня grace-period из `session-disconnect-debounce`). Repro: [e2e phase2-report](sprints/021-save-schema/e2e/phase2-report.md), Finding 1 +- [ ] **could** `ui-language-mixing` — в английском UI серверные строки идут по `DND_LANGUAGE=ru`: combat log «Бой начался», «КЗ», «промах», тип NPC «человек» при английском shell UI. Язык клиента и сервера не согласован (сервер берёт env, клиент — свой). Сосед `npc-language`. E2E sprint 021 phase 2 - [ ] **should** `dash-actiondef-movement-conflation` — `ActionDef` для `ActionType.DASH` (`core/action_defs.py`) рекламирует Dash как «move up to double your speed» и объявляет параметры движения `toward`/`away_from`/`direction`, но реальный `handle_dash` (`rules/handlers/movement.py`) только добавляет `effective_speed(actor)` к `budget.movement_remaining` и эмитит `ENTITY_DASH`. `service/session.py` резолвит abstract-move только для `MOVE`, не для `DASH`, поэтому параметры у Dash мёртвые. RuleBrain делает правильно: Dash добавляет бюджет, отдельный `move` тратит его. Фикс: убрать `toward`/`away_from`/`direction` из params Dash, переписать `description`/`llm_hint` в духе «добавляет твою скорость к остатку перемещения; двигаться надо отдельным `move`». Хендлер не трогать - [ ] **should** `equip-in-combat-free` — Семейство экипировки слотов в `core/action_defs.py` (`EQUIP`/`UNEQUIP`, armor/shield/head/feet/ring) зарегистрировано как `cost_type=FREE` и без `combat_mode`, то есть доступно в бою через дефолт `ANY`. Enforcement сейчас идёт через `check_action_mode` и `check_budget` в `rules/validation.py`, поэтому броню, шлем, обувь и кольцо можно менять в бою бесплатно. Фикс: `EQUIP_ARMOR`/`UNEQUIP_ARMOR` и accessory-слоты сделать `PEACEFUL_ONLY`; `EQUIP_SHIELD`/`UNEQUIP_SHIELD` оставить в бою, но сделать `cost_type=ACTION`; оружейные `EQUIP`/`UNEQUIP` можно оставить `FREE` как object interaction. Синхронизировать `ends_peaceful_turn`: сейчас он стоит у оружия, но не у брони/щита/accessory - [ ] **could** `take-action-cost-vestigial` — `ActionType.TAKE` (`core/action_defs.py`) объявлен `cost_type=ACTION`, но `combat_mode=PEACEFUL_ONLY`. В мирном ходу бюджета нет, `check_budget` в `rules/validation.py` сразу возвращает `None`, поэтому ACTION-стоимость никогда не списывается. Либо лут должен быть доступен в бою и тогда ACTION начнёт работать, либо стоимость надо снять как вестигиальную diff --git a/docs/e2e-playbook.md b/docs/e2e-playbook.md index 2b876641..df195dbd 100644 --- a/docs/e2e-playbook.md +++ b/docs/e2e-playbook.md @@ -17,7 +17,7 @@ - **Ожидание:** редирект на `/play/:sessionId`, WebSocket подключён, первый turn в логе ### 1.4 Character creation — point buy -- На экране создания: проверить что есть +/- кнопки для ability scores, счётчик оставшихся очков (27), preview HP/AC/Gold, текст Starting Equipment +- На экране создания: проверить что есть +/- кнопки для ability scores, счётчик оставшихся очков (15/27 при стартовых 10-ках), preview HP/AC/Gold, текст Starting Equipment - **Ожидание:** point buy корректный (15→9pts, остаток обновляется), + disabled при 15, - disabled при 8. Preview: Fighter L1 CON 14 → HP 12, Chain Mail + Shield + Defense → AC 19, Gold > 0 (значение из starting_equipment) ### 1.5 Character creation — class-specific UI diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index 0475c5ba..5f037ca9 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -26,7 +26,7 @@ 2. [Миграция bare-random сайтов на слоевой RNG](tasks/phase1-task2-bare-random-migration.md) 3. [Сквозной тест воспроизводимости мира](tasks/phase1-task3-world-determinism-test.md) -## Phase 2: Unified Pydantic save schema +## Phase 2: Unified Pydantic save schema ✓ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{time, last_tick_times, layers}`) как единый source of truth. Слои отдают/принимают типизированные модели вместо сырых dict-ов; `entity_serialization` переезжает на модели. Состояние RNG (dice + per-layer) сериализуется в сейв — загрузка продолжает ту же случайную последовательность. Единый путь загрузки в `commands_save`: `schema_version=1`, legacy-фолбэки (три исторических формата) удаляются — сейвы dev-артефакты. Round-trip тесты перепиниваются на новый формат. From 4dd504036f633d9718c9f8a95106cb0f1a055b07 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:00:01 +0300 Subject: [PATCH 15/25] sprint 021 phase 3: task breakdown --- docs/STATUS.md | 4 +-- docs/sprints/021-save-schema/sprint.md | 5 +++ .../tasks/phase3-task1-periodic-autosave.md | 34 +++++++++++++++++++ ...task2-autosave-errors-and-saves-hygiene.md | 34 +++++++++++++++++++ 4 files changed, 75 insertions(+), 2 deletions(-) create mode 100644 docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md create mode 100644 docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md diff --git a/docs/STATUS.md b/docs/STATUS.md index f0027073..56901ff7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 2 — Unified Pydantic save schema (tasks 1-4 done) — 2026-07-10 +**Phase:** 3 — Autosave hardening (tasks generated) — 2026-07-10 -Phase 2 tasks are done. Ready to close the phase. +Phase 2 закрыта (e2e 6/7, находки в бэклоге). Ready to start phase 3 task 1. ### Phases diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index 5f037ca9..36fb97bd 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -45,6 +45,11 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t Проверка: unit-тест шедулера (старт/останов/интервал), `make test-integration`, `saves/` не растёт после прогона. Почему последним: частый автосейв имеет смысл только поверх надёжной схемы. +**Tasks:** + +1. [Периодический автосейв в FastAPI lifespan](tasks/phase3-task1-periodic-autosave.md) +2. [Ошибки автосейва в лог + чистка saves/ в интеграционных тестах](tasks/phase3-task2-autosave-errors-and-saves-hygiene.md) + --- ## Status diff --git a/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md b/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md new file mode 100644 index 00000000..5fc71171 --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md @@ -0,0 +1,34 @@ +# Task: Периодический автосейв в FastAPI lifespan + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 3 — Autosave hardening + +## Description + +Фоновый периодический автосейв (backlog `periodic-autosave-scheduler`). Сейчас автосейв срабатывает только на shutdown (`adapters/api/app.py:82`, finally-блок lifespan), после `create_player` (`service/commands_player.py:128`) и при опустении сессии (`service/game_service.py:244`). Упавший процесс теряет всё с момента последнего события — а «мир заморожен на полушаге» (simulation-core) требует, чтобы сейв был всегда свежим. + +Добавить asyncio-таск в `lifespan()` (`adapters/api/app.py:52-82`): каждые `DND_AUTOSAVE_SECONDS` (env, default 120) вызывает `service.autosave_all_sessions()`. Старт после `set_service(service)`; на shutdown — `task.cancel()` + await (проглотить `CancelledError`) СТРОГО до финального `autosave_all_sessions()` в finally, чтобы не гонять два сейва одной сессии параллельно. `autosave_all_sessions` синхронный и ходит в диск — не блокировать event loop: `asyncio.to_thread` (или run_in_executor). + +## Tests First + +- Продуктовый сценарий: сессия с игроком мутируется (движение/урон), периодический тик срабатывает (короткий интервал в тесте, например 0.05с) → сейв-файл сессии появился/обновился и содержит свежие данные (schema_version=1, актуальный HP/локация). +- Shutdown-порядок: при останове приложения периодический таск отменён раньше финального автосейва; финальный автосейв выполнен один раз (никаких konkurrent-записей). +- Ошибка одной сессии в периодическом проходе не убивает таск: следующий тик продолжает сохранять остальные (уже покрыто поведением `autosave_all_sessions`, но пин на уровне шедулера — таск живёт после исключения). +- `DND_AUTOSAVE_SECONDS` читается из env; невалидное значение → fail-fast при старте (ValueError), не тихий дефолт. + +## Implementation + +После красных тестов: helper `_periodic_autosave(service, interval)` в `adapters/api/app.py` (или `service/`, если удобнее тестировать), wiring в lifespan. Документировать env в CLAUDE.md рядом с `DND_WORLD_SEED`. Тесты уровня unit с fake/минимальным service, без подъёма uvicorn (httpx ASGI-клиент или прямой вызов lifespan-хелпера). + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`) +- [ ] Автосейв тикает по интервалу, отменяется на shutdown до финального сейва +- [ ] `DND_AUTOSAVE_SECONDS` документирован + +## Status + +`pending` diff --git a/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md b/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md new file mode 100644 index 00000000..e0c4ff7a --- /dev/null +++ b/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md @@ -0,0 +1,34 @@ +# Task: Ошибки автосейва в лог + чистка saves/ в интеграционных тестах + +**Date:** 2026-07-10 +**Sprint:** 021-save-schema +**Phase:** 3 — Autosave hardening + +## Description + +Две гигиенические половины (backlog `silent-failure-autosave` + минимальный фикс `saved-session-accumulation`). + +**A. Де-глушение автосейва.** Два сайта `contextlib.suppress(Exception)` вокруг автосейва глотают ошибки молча: `service/commands_player.py:128` (после create_player) и `service/game_service.py:244-246` (`_on_session_empty`). Заменить на try/except с `logger.exception(...)` (structlog, по образцу `autosave_all_sessions` в `commands_save.py:40-47` — тот уже логирует, его не трогать). Семантика «не падать наружу» сохраняется — меняется только видимость. + +**B. Чистка saves/ в integration teardown.** Интеграционные прогоны накопили в репо сотни `saves/*/session_*.json` (E2E sprint 020 нашёл ~900). Тесты создают сессии через API, автосейвы пишутся в общий `saves/`, teardown ничего не убирает. Сделать fixture-уровневую уборку в `tests/integration/conftest.py`: снимок множества файлов в `saves/` до теста (или до сьюта) → после — удалить новые. Учесть, что backend в docker пишет в примонтированный каталог — проверить, как volume настроен в `docker-compose.test.yml`, и чистить на правильной стороне. Существующие накопленные session_*.json из репо удалить в этом же коммите (git rm, кроме фикстурных сейвов, если такие есть — проверить, что ни один тест не читает существующий файл из saves/). + +## Tests First + +- A: автосейв, у которого store бросает исключение (подсунуть ломающийся store/путь), логирует ошибку (caplog/structlog capture) и не роняет вызывающий поток (`create_player` возвращает успех, evict продолжается). +- B: интеграционный прогон не оставляет новых файлов в `saves/` после завершения (проверяется самим fixture-механизмом; локальный критерий — `git status` чист после `make test-integration`). + +## Implementation + +Половина A — точечные правки двух сайтов + unit-тесты. Половина B — conftest fixture + одноразовая чистка каталога. Не трогать сейвы, на которые ссылаются доки/фикстуры (grep по именам перед удалением). + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation, для половины A) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check-backend`, `make test-integration`) +- [ ] В `src/` не осталось `contextlib.suppress` вокруг автосейва +- [ ] После `make test-integration` рабочее дерево чистое (saves/ не растёт) + +## Status + +`pending` From 07a11176b7a084b9e094372af498eaa237ff5406 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:06:38 +0300 Subject: [PATCH 16/25] sprint 021 phase 3 task 1: add periodic autosave --- CLAUDE.md | 1 + docs/STATUS.md | 4 +- .../tasks/phase3-task1-periodic-autosave.md | 6 +- src/dnd_simulator/adapters/api/app.py | 32 ++++ tests/unit/test_periodic_autosave.py | 141 ++++++++++++++++++ 5 files changed, 181 insertions(+), 3 deletions(-) create mode 100644 tests/unit/test_periodic_autosave.py diff --git a/CLAUDE.md b/CLAUDE.md index 13ccbfa3..04a99187 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,7 @@ Kill reputation drop (`rules/reputation.py`): omniscient, delta scaled by victim - `LLM_MODEL` env var selects model (required if `OPENROUTER_API_KEY` is set, no default) - `DND_LANGUAGE` env var selects game language (default: `ru`); locale files in `src/dnd_simulator/locale/` - `DND_WORLD_SEED` env var seeds world simulation layers; when absent, `GameService` logs the generated seed. +- `DND_AUTOSAVE_SECONDS` env var controls periodic autosave interval (default: `120`; must be greater than `0`). - Save files: `saves/` directory (JSON) - Backend API: `make serve` → http://localhost:8001/docs (Swagger UI) - Frontend: `make frontend` → http://localhost:5173 (entry point, proxies /api to :8001) diff --git a/docs/STATUS.md b/docs/STATUS.md index 56901ff7..b85c70c0 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 3 — Autosave hardening (tasks generated) — 2026-07-10 +**Phase:** 3 — Autosave hardening (task 1 done, task 2 pending) — 2026-07-10 -Phase 2 закрыта (e2e 6/7, находки в бэклоге). Ready to start phase 3 task 1. +Phase 2 закрыта (e2e 6/7, находки в бэклоге). Phase 3 task 1 done, task 2 pending. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md b/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md index 5fc71171..8d84dfe4 100644 --- a/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md +++ b/docs/sprints/021-save-schema/tasks/phase3-task1-periodic-autosave.md @@ -31,4 +31,8 @@ ## Status -`pending` +`done` + +## Developer Notes + +Добавлен `_periodic_autosave` в FastAPI lifespan: интервал берётся из `DND_AUTOSAVE_SECONDS`, проходы выполняются через `asyncio.to_thread`, ошибки логируются и не убивают задачу. На shutdown периодический task отменяется и дожидается до финального `autosave_all_sessions()`. Unit-тесты покрывают env parsing, invalid fail-fast, свежую запись save-файла, продолжение после ошибки и порядок остановки. diff --git a/src/dnd_simulator/adapters/api/app.py b/src/dnd_simulator/adapters/api/app.py index 9bb288e1..143977bb 100644 --- a/src/dnd_simulator/adapters/api/app.py +++ b/src/dnd_simulator/adapters/api/app.py @@ -1,5 +1,7 @@ from __future__ import annotations +import asyncio +import contextlib import logging import os import re @@ -29,8 +31,33 @@ from dnd_simulator.storage.store import JsonFileStore DEFAULT_SAVES_DIR = Path(__file__).resolve().parents[4] / "saves" +DEFAULT_AUTOSAVE_SECONDS = 120.0 _SESSION_ID_RE = re.compile(r"/api/(?:master|player)/sessions/([^/]+)") +logger = structlog.get_logger(domain="transport.api") + + +def _autosave_interval_from_env() -> float: + raw = os.getenv("DND_AUTOSAVE_SECONDS") + if raw is None: + return DEFAULT_AUTOSAVE_SECONDS + interval = float(raw) + if interval <= 0: + raise ValueError("DND_AUTOSAVE_SECONDS must be greater than 0") + return interval + + +async def _periodic_autosave(service: GameService, interval: float) -> None: + while True: + await asyncio.sleep(interval) + save_task = asyncio.create_task(asyncio.to_thread(service.autosave_all_sessions)) + try: + await asyncio.shield(save_task) + except asyncio.CancelledError: + await save_task + raise + except Exception: + logger.exception("periodic_autosave_failed") class I18nMiddleware(BaseHTTPMiddleware): @@ -76,9 +103,14 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: content_dir = Path(content_dir_env) if content_dir_env else DEFAULT_CONTENT_DIR service = GameService(store=store, llm=llm, content_dir=content_dir) set_service(service) + autosave_interval = _autosave_interval_from_env() + autosave_task = asyncio.create_task(_periodic_autosave(service, autosave_interval)) try: yield finally: + autosave_task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await autosave_task service.autosave_all_sessions() diff --git a/tests/unit/test_periodic_autosave.py b/tests/unit/test_periodic_autosave.py new file mode 100644 index 00000000..26452c67 --- /dev/null +++ b/tests/unit/test_periodic_autosave.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +import structlog + +from dnd_simulator.adapters.api import app as app_module +from dnd_simulator.adapters.api.app import _autosave_interval_from_env, _periodic_autosave, lifespan +from dnd_simulator.core.character import Ability +from dnd_simulator.service import GameService +from dnd_simulator.storage.store import JsonFileStore + + +class _FakeService: + def __init__(self) -> None: + self.calls = 0 + self.failures_remaining = 0 + + def autosave_all_sessions(self) -> None: + self.calls += 1 + if self.failures_remaining: + self.failures_remaining -= 1 + raise RuntimeError("disk offline") + + +async def _cancel(task: asyncio.Task[None]) -> None: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + + +@pytest.fixture +def default_scores() -> dict[str, int]: + return { + Ability.STR.value: 15, + Ability.DEX.value: 10, + Ability.CON.value: 14, + Ability.INT.value: 8, + Ability.WIS.value: 12, + Ability.CHA.value: 8, + } + + +def test_autosave_interval_comes_from_env(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DND_AUTOSAVE_SECONDS", "0.25") + assert _autosave_interval_from_env() == 0.25 + + +def test_autosave_interval_defaults_to_120_seconds(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("DND_AUTOSAVE_SECONDS", raising=False) + assert _autosave_interval_from_env() == 120.0 + + +@pytest.mark.parametrize("raw", ["abc", "0", "-1"]) +def test_invalid_autosave_interval_fails_fast(monkeypatch: pytest.MonkeyPatch, raw: str) -> None: + monkeypatch.setenv("DND_AUTOSAVE_SECONDS", raw) + with pytest.raises(ValueError): + _autosave_interval_from_env() + + +async def test_periodic_autosave_persists_fresh_session_state( + tmp_path: Path, + default_scores: dict[str, int], +) -> None: + service = GameService(store=JsonFileStore(tmp_path / "saves")) + session = service.start_game() + player = service.create_player( + session.session_id, + { + "name": "Tester", + "race": "human", + "class": "fighter", + "ability_scores": default_scores, + }, + ) + player.location_id = "blacksmith" + + task = asyncio.create_task(_periodic_autosave(service, 0.01)) + try: + await asyncio.sleep(0.08) + finally: + await _cancel(task) + + saved = service._store.load(f"session_{session.session_id}", world=session.world_name) + assert saved["schema_version"] == 1 + entities = saved["world"]["layers"]["entities"]["entities"] + saved_player = entities[player.id] + assert saved_player["location_id"] == "blacksmith" + + +async def test_periodic_autosave_logs_error_and_keeps_ticking() -> None: + service = _FakeService() + service.failures_remaining = 1 + + with structlog.testing.capture_logs() as logs: + task = asyncio.create_task(_periodic_autosave(service, 0.01)) + try: + await asyncio.sleep(0.08) + finally: + await _cancel(task) + + assert service.calls >= 2 + assert any(entry.get("event") == "periodic_autosave_failed" for entry in logs) + + +async def test_lifespan_cancels_periodic_task_before_final_autosave( + monkeypatch: pytest.MonkeyPatch, +) -> None: + events: list[str] = [] + + class Service(_FakeService): + def autosave_all_sessions(self) -> None: + events.append("save") + + class Store: + def __init__(self, path: Path) -> None: + self.path = path + + async def fake_periodic(service: Service, interval: float) -> None: + events.append("periodic_started") + try: + await asyncio.Event().wait() + finally: + events.append("periodic_cancelled") + + monkeypatch.setattr(app_module, "JsonFileStore", Store) + monkeypatch.setattr(app_module, "GameService", lambda **_: Service()) + monkeypatch.setattr(app_module, "set_service", lambda service: None) + monkeypatch.setattr(app_module, "_autosave_interval_from_env", lambda: 0.01) + monkeypatch.setattr(app_module, "_periodic_autosave", fake_periodic) + monkeypatch.delenv("OPENROUTER_API_KEY", raising=False) + monkeypatch.delenv("DND_DICE_SEED", raising=False) + + async with lifespan(MagicMock()): + await asyncio.sleep(0) + assert events == ["periodic_started"] + + assert events == ["periodic_started", "periodic_cancelled", "save"] From cbcf44bd20c8e29f0746c8f130f4f3cd4aef8d1b Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:11:34 +0300 Subject: [PATCH 17/25] sprint 021 phase 3 task 2: log autosave failures and clean integration saves --- docker-compose.test.yml | 3 + docs/STATUS.md | 4 +- ...task2-autosave-errors-and-saves-hygiene.md | 6 +- src/dnd_simulator/service/commands_player.py | 9 ++- src/dnd_simulator/service/game_service.py | 5 +- tests/integration/conftest.py | 22 +++++++ tests/unit/test_autosave_error_logging.py | 64 +++++++++++++++++++ 7 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 tests/unit/test_autosave_error_logging.py diff --git a/docker-compose.test.yml b/docker-compose.test.yml index 89095b80..131013f3 100644 --- a/docker-compose.test.yml +++ b/docker-compose.test.yml @@ -8,6 +8,7 @@ services: UV_CACHE_DIR: /tmp/uv-cache volumes: - ./tests/integration/content:/app/test-content + - ./saves:/app/saves healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/health')"] interval: 2s @@ -23,4 +24,6 @@ services: condition: service_healthy environment: BACKEND_URL: http://backend:8001 + volumes: + - ./saves:/app/saves entrypoint: ["uv", "run", "pytest", "tests/integration/", "-v"] diff --git a/docs/STATUS.md b/docs/STATUS.md index b85c70c0..cf7aa906 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 021-save-schema **Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. **Started:** 2026-07-10 -**Phase:** 3 — Autosave hardening (task 1 done, task 2 pending) — 2026-07-10 +**Phase:** 3 — Autosave hardening (task 2 done, phase ready to close) — 2026-07-10 -Phase 2 закрыта (e2e 6/7, находки в бэклоге). Phase 3 task 1 done, task 2 pending. +Phase 2 закрыта (e2e 6/7, находки в бэклоге). Phase 3 tasks 1-2 done; ready to close phase 3. ### Phases diff --git a/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md b/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md index e0c4ff7a..a1a8c708 100644 --- a/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md +++ b/docs/sprints/021-save-schema/tasks/phase3-task2-autosave-errors-and-saves-hygiene.md @@ -31,4 +31,8 @@ ## Status -`pending` +`done` + +## Developer Notes + +Два silent autosave сайта заменены на `logger.exception(...)`: `create_player_autosave_failed` и `session_empty_autosave_failed`; вызовы по-прежнему не падают наружу. Integration docker stack получил общий `./saves:/app/saves` volume для backend и test runner, а session-scope fixture удаляет файлы, появившиеся за время suite. Перед чисткой `saves/` выполнен grep по именам файлов: ссылки есть только на `000_phase2_midcombat_9119f459.json` и `phase2_current_c2132f2b.json` в phase2 e2e report, поэтому они оставлены; остальные save-файлы оказались ignored/untracked локальными артефактами, не git-tracked. diff --git a/src/dnd_simulator/service/commands_player.py b/src/dnd_simulator/service/commands_player.py index f45c0dc8..359ab065 100644 --- a/src/dnd_simulator/service/commands_player.py +++ b/src/dnd_simulator/service/commands_player.py @@ -6,9 +6,10 @@ from __future__ import annotations -import contextlib from typing import TYPE_CHECKING, Any +import structlog + from dnd_simulator.content_loader import load_catalog from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.service.base import GameServiceProtocol @@ -17,6 +18,8 @@ from dnd_simulator.core.class_features import FightingStyle from dnd_simulator.service.dto import PlayerStatusData +logger = structlog.get_logger(domain="service.player") + class PlayerCommands(GameServiceProtocol): """Mixin: player character creation, leveling, and status (player lens).""" @@ -125,8 +128,10 @@ def create_player(self, session_id: str, player_data: dict[str, Any]) -> PlayerC player.location_id = ids[0] self._get_entities_layer(session).add_entity(player) - with contextlib.suppress(Exception): + try: self.autosave_session(session_id) + except Exception: + logger.exception("create_player_autosave_failed", session_id=session_id) return player def level_up_player( diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index fd858453..d51805f9 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -1,6 +1,5 @@ from __future__ import annotations -import contextlib import os import random import uuid @@ -272,8 +271,10 @@ def _on_session_empty(self, session: GameSession) -> None: """Called when all listeners disconnect. Autosave and evict from memory.""" sid = session.session_id logger.info("session_empty_evict", session_id=sid) - with contextlib.suppress(Exception): + try: self.autosave_session(sid) + except Exception: + logger.exception("session_empty_autosave_failed", session_id=sid) self._sessions.pop(sid, None) # -- Layer accessors -- diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index 394441a9..539fd79b 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -6,15 +6,37 @@ from __future__ import annotations +import contextlib import json import os from collections.abc import Iterator +from pathlib import Path from typing import Any import pytest import requests import websocket +SAVES_DIR = Path("/app/saves") + + +def _save_files_snapshot() -> set[Path]: + if not SAVES_DIR.exists(): + return set() + return {path.relative_to(SAVES_DIR) for path in SAVES_DIR.rglob("*") if path.is_file()} + + +@pytest.fixture(scope="session", autouse=True) +def cleanup_new_save_files() -> Iterator[None]: + """Remove save files created by the docker integration run.""" + before = _save_files_snapshot() + yield + for relative_path in _save_files_snapshot() - before: + (SAVES_DIR / relative_path).unlink(missing_ok=True) + for path in sorted((p for p in SAVES_DIR.rglob("*") if p.is_dir()), reverse=True): + with contextlib.suppress(OSError): + path.rmdir() + @pytest.fixture(scope="session") def backend_url() -> str: diff --git a/tests/unit/test_autosave_error_logging.py b/tests/unit/test_autosave_error_logging.py new file mode 100644 index 00000000..ae2d945b --- /dev/null +++ b/tests/unit/test_autosave_error_logging.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from pathlib import Path + +import structlog + +from dnd_simulator.core.character import Ability +from dnd_simulator.service import GameService +from dnd_simulator.storage.store import JsonFileStore + + +def _scores() -> dict[str, int]: + return { + Ability.STR.value: 15, + Ability.DEX.value: 10, + Ability.CON.value: 14, + Ability.INT.value: 8, + Ability.WIS.value: 12, + Ability.CHA.value: 8, + } + + +def test_create_player_logs_autosave_failure_and_still_returns_player(tmp_path: Path) -> None: + service = GameService(store=JsonFileStore(tmp_path / "saves")) + session = service.start_game() + + def fail_autosave(session_id: str) -> None: + raise RuntimeError(f"autosave failed for {session_id}") + + service.autosave_session = fail_autosave # type: ignore[method-assign] + + with structlog.testing.capture_logs() as logs: + player = service.create_player( + session.session_id, + { + "name": "Tester", + "race": "human", + "class": "fighter", + "ability_scores": _scores(), + }, + ) + + assert player.name == "Tester" + failed = [entry for entry in logs if entry.get("event") == "create_player_autosave_failed"] + assert len(failed) == 1 + assert failed[0]["session_id"] == session.session_id + + +def test_empty_session_evict_logs_autosave_failure_and_still_removes_session(tmp_path: Path) -> None: + service = GameService(store=JsonFileStore(tmp_path / "saves")) + session = service.start_game() + + def fail_autosave(session_id: str) -> None: + raise RuntimeError(f"autosave failed for {session_id}") + + service.autosave_session = fail_autosave # type: ignore[method-assign] + + with structlog.testing.capture_logs() as logs: + service._on_session_empty(session) + + assert session.session_id not in service._sessions + failed = [entry for entry in logs if entry.get("event") == "session_empty_autosave_failed"] + assert len(failed) == 1 + assert failed[0]["session_id"] == session.session_id From 7a2296dd0e9bec4a70c6ad4c99f0692883972e58 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:18:38 +0300 Subject: [PATCH 18/25] sprint 021 phase 3: skip evict autosave for explicitly deleted sessions --- src/dnd_simulator/service/game_service.py | 5 +++++ tests/unit/test_autosave_error_logging.py | 15 +++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index d51805f9..eb886eb7 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -270,6 +270,11 @@ def delete_session(self, session_id: str) -> None: def _on_session_empty(self, session: GameSession) -> None: """Called when all listeners disconnect. Autosave and evict from memory.""" sid = session.session_id + if sid not in self._sessions: + # Explicit DELETE already evicted it; autosaving here would fail or + # resurrect the session from its stale autosave via _get_session. + logger.info("session_empty_evict_skipped", session_id=sid) + return logger.info("session_empty_evict", session_id=sid) try: self.autosave_session(sid) diff --git a/tests/unit/test_autosave_error_logging.py b/tests/unit/test_autosave_error_logging.py index ae2d945b..eb2e0387 100644 --- a/tests/unit/test_autosave_error_logging.py +++ b/tests/unit/test_autosave_error_logging.py @@ -62,3 +62,18 @@ def fail_autosave(session_id: str) -> None: failed = [entry for entry in logs if entry.get("event") == "session_empty_autosave_failed"] assert len(failed) == 1 assert failed[0]["session_id"] == session.session_id + + +def test_empty_session_evict_after_delete_is_noop(tmp_path: Path) -> None: + """Evict timer firing after an explicit DELETE must not autosave, resurrect, or log an error.""" + service = GameService(store=JsonFileStore(tmp_path / "saves")) + session = service.start_game() + service.autosave_session(session.session_id) # autosave exists on disk — resurrect bait + service.delete_session(session.session_id) + + with structlog.testing.capture_logs() as logs: + service._on_session_empty(session) + + assert session.session_id not in service._sessions + assert not [e for e in logs if e.get("event") == "session_empty_autosave_failed"] + assert [e for e in logs if e.get("event") == "session_empty_evict_skipped"] From b5cfc5d48e9f3cb9f4b994c558c5246271baeb43 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:18:38 +0300 Subject: [PATCH 19/25] =?UTF-8?q?sprint=20021=20phase=203:=20close=20?= =?UTF-8?q?=E2=80=94=20periodic=20autosave,=20de-silenced=20errors,=20save?= =?UTF-8?q?s=20hygiene=20(unit=202429,=20integration=20160)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/sprints/021-save-schema/sprint.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index 36fb97bd..0093a716 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -39,7 +39,7 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t 3. [SaveGame-конверт, schema_version=1, единый путь загрузки](tasks/phase2-task3-save-envelope.md) 4. [Entities save-модели — source of truth, не обёртка](tasks/phase2-task4-entities-models-source-of-truth.md) -## Phase 3: Autosave hardening +## Phase 3: Autosave hardening ✓ Периодический автосейв: фоновый asyncio-таск в FastAPI lifespan (интервал env `DND_AUTOSAVE_SECONDS`, default ~120), cancel на shutdown перед финальным autosave. Ошибки автосейва логируются вместо `contextlib.suppress(Exception)` (3 сайта). Минимальный фикс накопления: интеграционные тесты чистят созданные сессии в `saves/` в teardown. @@ -64,6 +64,8 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t - Phase 1 закрыта без отдельного E2E: пользовательской поверхности нет (RNG plumbing), integration 160 passed; браузерный E2E идёт на закрытии phase 2/3 (2026-07-10). - Legacy `World.save()` уже пишет `seed`, чтобы разные world-seed snapshots различались до ввода Pydantic save schema; полное состояние RNG остаётся задачей phase 2 (2026-07-10). +- Phase 3 закрыта без отдельного E2E (фоновый шедулер и логирование, UI-поверхности нет); де-глушение сразу окупилось: evict-таймер после явного DELETE сессии падал автосейвом «session not found» 37 раз за integration-прогон (раньше глоталось) — починено гвардом в `_on_session_empty`, который заодно закрывает воскрешение удалённой сессии из stale-автосейва (2026-07-10). + ## Deferred _(заполняется по ходу спринта)_ From 1faec75ae0b2f9e80751c33a74e9ee6c444c7705 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:23:19 +0300 Subject: [PATCH 20/25] sprint 021: audit --- docs/audit.md | 86 ++++++++++++++++++++++++--------------------------- 1 file changed, 41 insertions(+), 45 deletions(-) diff --git a/docs/audit.md b/docs/audit.md index 25094b3c..2506e462 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -1,95 +1,91 @@ # Code Audit > **Date**: 2026-07-10 -> **Scope**: full (post Sprint 020, thermo-sweep, phases 1-4 merged) +> **Scope**: full codebase, post Sprint 021 (`b5cfc5d`) -> Transient snapshot. Canonical tracking lives in [BACKLOG.md](BACKLOG.md); sprint close triage decides what is fixed now and what is deferred. +> Transient snapshot. Canonical tracking lives in [BACKLOG.md](BACKLOG.md); known backlog items are marked `known` and should not be duplicated during triage. ## Summary - Dead code: 0 issues -- Code smells: 6 issues -- Security: 2 issues +- Code smells: 5 issues +- Security: 4 issues - Architecture violations: 0 issues -- Convention violations: 2 issues +- Convention violations: 3 issues - Layer contract: 0 issues -- Test gaps: 5 issues -- Vision drift: 1 issue +- Test gaps: 4 issues +- Vision drift: 0 issues -**Total: 17 issues.** Sprint 020 materially improved the thermo targets: `GameService` stayed split, `combat_manager.py` is now a lifecycle facade with `combat_resolution.py`, `activation_manager.py` is down to activation orchestration, `ecology/layer.py` follows the politics-style submodule split, entity serialization is isolated, and the front-end god components were decomposed. No cross-layer dependency violation was found. The main fresh finding is that the new split modules still use process-global `random`, which weakens seeded replay and the simulation-core save/freeze direction. +**Total: 16 issues.** Sprint 021 closed the important Sprint 020 audit risk: world randomness is now owned by per-layer RNGs, layer and dice RNG state are in the versioned `SaveGame` envelope, legacy saves are rejected, and periodic autosave logs failures instead of suppressing them. The remaining fresh risk is concurrency around save/load/autosave while a round thread can mutate the same session world. ## Dead Code | File | Issue | Action | |------|-------|--------| -| none | `uv run ruff check src/ --select F401` clean. No `TODO/FIXME/HACK/XXX` in `src/`. | none | +| none | `uv run ruff check src/ --select F401` is clean. No `TODO/FIXME/HACK/XXX` in `src/`. | none | ## Code Smells | File | Issue | Suggestion | |------|-------|------------| -| `src/dnd_simulator/layers/entities/perception.py` (572) | Still the largest layer helper after Sprint 020; perception dispatch and per-event formatting keep growing. | Keep `perception-fail-fast` / future event-payload work in backlog; do not expand this module for simulation-core events. | -| `src/dnd_simulator/layers/entities/layer.py` (552) | Entity host + query/load/state glue remains above 400 lines even after serialization split. | Next split should follow ownership boundaries: load/restore helpers, entity CRUD helpers, query facade. | -| `src/dnd_simulator/round.py` (548) | Improved from ~623, but still owns combat turn loop, peaceful turn loop, fast-forward, reactions, listener coordination. | Backlog remains valid; simulation-core `intents` should replace the peaceful/wait path rather than polishing it locally. | -| `src/dnd_simulator/core/action_defs.py` (545) | Registry still carries 12 equip/unequip action types. Sprint 020 added backend equipment registry but preserved wire compatibility. | Defer full `equip-action-collapse` as coordinated backend/frontend/wire/i18n work. | -| `src/dnd_simulator/service/session.py` (521), `service/commands_worldbuilder.py` (535) | Still large, though cohesive. Session is especially sensitive because it bridges threads, listeners, saves, and round lifecycle. | Track size during next session/WS work; avoid putting new behavior directly in session. | -| `frontend/src/components/game/EventLog.tsx:244`, `frontend/src/components/master/SchemaForm.tsx:62` | Two `eslint-disable react-hooks/exhaustive-deps` remain after decomposition. | Already backlogged; consider replacing with stable memoized inputs rather than suppressions. | +| `src/dnd_simulator/service/commands_save.py:18`, `src/dnd_simulator/adapters/api/app.py:50`, `src/dnd_simulator/service/session.py:461` | `fresh`: saves and autosaves snapshot `session.world` without taking a session/world lock, while `Round.run_loop()` can mutate the same world on a background thread. The new periodic autosave makes this path regular, not just shutdown/evict. | Add one session-level save/load critical section or a world snapshot API that coordinates with the round lifecycle. Cover manual save, autosave, periodic autosave, evict autosave, and load. | +| `src/dnd_simulator/service/session.py` (606 lines) | `known`: grew from 521 to 606 lines after spectator/grace/autosave lifecycle hardening. It now owns listener lifecycle, thread lifecycle, payload construction, player status helpers, movement resolution, and eviction timers. | Continue `long-func-start-round` / `test-gap-session`; avoid adding new session behavior until lifecycle and payload builders are split. | +| `src/dnd_simulator/layers/entities/layer.py` (584 lines), `layers/entities/perception.py` (572 lines), `round.py` (548 lines), `core/action_defs.py` (545 lines), `service/commands_worldbuilder.py` (535 lines) | `known`: large modules remain after Sprint 020 decomposition. Sprint 021 added save/load state to the entities layer, but no new god-object boundary was introduced. | Keep existing backlog: `entities-layer-regrowth`, `perception-fail-fast`, `round-growing`, `action-defs-growing`. | +| `frontend/src/components/game/EventLog.tsx:244`, `frontend/src/components/master/SchemaForm.tsx:62` | `known`: two `eslint-disable react-hooks/exhaustive-deps` suppressions remain. | Keep `event-log-eslint-suppress` and `schema-form-eslint-suppress`; remove only with targeted tests. | +| `src/dnd_simulator/layers/entities/entity_serialization.py:38`, `layers/entities/save_models.py` | `known/accepted`: entities save models are now authoritative, but `player_to_save_data()` still emits a parse-player compatibility subset. This is not a second save format for `SaveGame`, but it is still a compatibility bridge to watch as schema v1 evolves. | Do not reintroduce hand-written save envelopes. When player parsing moves fully onto save models, remove the bridge. | ## Security | File:Line | Issue | Severity | |-----------|-------|----------| -| `src/dnd_simulator/adapters/api/app.py:121-128` | CORS origins are configurable, but methods and headers remain wildcard. | low | -| `src/dnd_simulator/adapters/api/schemas.py:69-93` | `GiveItemRequest` item fields (`price`, `reach`, `base_ac`, `max_dex_bonus`, `strength_req`, `ac_bonus`) still lack bounds. Master-only surface, but arbitrary values can create impossible game data. | low | +| `src/dnd_simulator/adapters/api/app.py:153-160` | `known`: CORS origins are configurable, but default origins, methods, and headers are wildcard. Acceptable for local dev, unsafe for non-local deployment. | low | +| `src/dnd_simulator/adapters/api/routes_ws.py:149-154` | `known`: WS origin validation is optional and disabled by default. Session IDs are still the only access handle. | medium | +| `src/dnd_simulator/adapters/api/schemas.py:69-93` | `known`: `GiveItemRequest` item fields (`price`, `reach`, `base_ac`, `max_dex_bonus`, `strength_req`, `ac_bonus`) still lack bounds. Master-only surface, but impossible game data can be created. | low | +| `src/dnd_simulator/adapters/api/app.py:177-180` | `known`: `/api/frontend-error` accepts arbitrary JSON without a Pydantic schema or size limit. | low | -Verified clean/hardened: no hardcoded secrets, `.env` is gitignored, no `subprocess`, no `dangerouslySetInnerHTML`. WS keeps optional origin allow-list and token-bucket rate limiting in `routes_ws.py:90-157`. +Verified clean/hardened: no hardcoded secrets, `.env` is gitignored, no `subprocess`, no `dangerouslySetInnerHTML`, WS has token-bucket rate limiting, and autosave errors now log. ## Architecture Violations | File:Line | Violation | Should Be | Severity | |-----------|-----------|-----------|----------| -| none | No cross-layer imports across different layers. `core/` imports neither layers nor adapters. `rules/` imports neither layers nor service/adapters/storage. `adapters/` do not import layers. | none | none | -| none | Fresh split modules keep the intended direction: `layers/*` depend on `core` + pure `rules`; `rules/` stays free of I/O and layer references. | none | none | +| none | No cross-layer imports across different layers. `core/` imports neither layers nor adapters. `rules/` imports neither layers/service/adapters/storage nor I/O libraries. `adapters/` do not import layers. | none | none | -Accepted boundary imports: API schemas/routes import core enums and creation constants; `app.py` constructs `LlmClient` at the injection point; these match existing conventions. +Accepted boundary imports: API schemas/routes import core enums and creation constants; `app.py` constructs `LlmClient` at the service injection point; `storage/save_schema.py` imports layer state models because the versioned save envelope intentionally lives outside `core`. ## Convention Violations | File:Line | Violation | Rule | |-----------|-----------|------| -| `src/dnd_simulator/content_loader/monsters.py:129`, `content_loader/refs.py:62` | Raw-YAML/dynamic-model helpers keep `Any` parameters. | Existing `any-to-object-sweep` / `any-encounter-entries`. | -| `src/dnd_simulator/layers/ecology/movement.py:9,83`, `layers/ecology/squad_combat.py:9,106`, `layers/entities/encounters.py:112-123` | Process-global `random` is used in simulation decisions outside the seeded dice RNG. | Use injected RNG or `get_global_rng()` consistently so seeded replay and tests cover world simulation too. | +| `src/dnd_simulator/storage/save_schema.py:41`, `layers/*/state.py`, `layers/entities/save_models.py:228`, `layers/common/rng_state.py` | `fresh/accepted`: RNG state uses `list[Any]` because `random.Random.getstate()` is nested tuple/list data with implementation-defined shape. | Prefer `object` over `Any`, but keep this exception localized unless a typed RNG-state codec is introduced. | +| `src/dnd_simulator/content_loader/monsters.py:129`, `content_loader/refs.py:62` | `known`: raw-YAML/dynamic-model helpers still use `Any`. | Existing `any-to-object-sweep` / `any-encounter-entries`. | +| `src/dnd_simulator/service/session.py`, `round.py`, `core/character.py`, `core/combat.py`, `core/resource.py`, layer model files | `known/accepted`: mutable dataclasses remain for runtime state (`GameSession`, `Round`, creatures, combat state, resource pools, lairs, world models). | Fine for stateful objects; revisit only when a model is meant to be a value object. | -Line length is clean. Bare mutable dataclasses are still used for runtime state objects (`Creature`, `CombatState`, `TurnBudget`, `ResourcePool`, `Lair`, sessions/round controllers), which is acceptable unless they cross into value-object use. +Line length is clean. ## Layer Contract | Layer | Issue | |-------|-------| -| none | All 5 concrete layers implement the `Layer` ABC surface: `name`, `tick_interval`, `tick`, `handle_event`, `query`, `get_state`, `load_state`. | +| none | All concrete layers implement the `Layer` ABC surface: `name`, `tick_interval`, `tick`, `handle_event`, `query`, `get_state`, `load_state`. Sprint 021 state models preserve the dict-facing Layer interface while validating through Pydantic internally. | ## Test Gaps | Source File | Expected Test | Status | |-------------|---------------|--------| -| `src/dnd_simulator/layers/ecology/movement.py`, `squad_combat.py`, `layers/entities/encounters.py` | Seeded deterministic replay tests for random encounter count, roam movement, retreat destination. | missing; ties to the fresh RNG finding | -| `src/dnd_simulator/adapters/api/routes_ws.py` | Malformed JSON unit/integration test. | backlogged `test-gap-ws-malformed-json` | -| `src/dnd_simulator/adapters/api/routes_ws.py` / `service/session.py` | Disconnect during active loop and reconnect while NPC turn is in flight. | backlogged `test-gap-ws-disconnect`, known reload race remains out of sprint scope | -| `src/dnd_simulator/round.py` / activation path | Fast-forward wait with co-located rule NPC. | backlogged `test-gap-ws-fastforward`; current backlog has a live repro | -| `frontend/src/components/game/EventLog.tsx`, `SchemaForm.tsx` | Tests that would let the exhaustive-deps suppressions be removed safely. | backlogged `event-log-eslint-suppress`, `schema-form-eslint-suppress` | +| `src/dnd_simulator/service/commands_save.py`, `service/session.py`, `adapters/api/app.py` | Concurrency test for save/autosave/load while a round thread is active and mutating world state. | `fresh`, missing | +| `src/dnd_simulator/service/commands_save.py:65-75`, `service/session.py:461-578` | Load while connected/in combat should pause or restart round lifecycle deterministically before the player reconnects. | `known`: `load-combat-round-resume` | +| `src/dnd_simulator/adapters/api/routes_ws.py` | Malformed non-object JSON from client, not only invalid JSON / unknown message type. | `known`: extends `test-gap-ws-malformed-json` / `action-params-validation` | +| `tests/unit/test_periodic_autosave.py` | Shutdown path where final `service.autosave_all_sessions()` itself raises. | `fresh`, missing; likely low severity because startup/shutdown should expose hard failures, but the behavior is currently unpinned | -No skipped or xfailed tests. The mechanical "rules module name → test_rules_*.py" script reports many false positives because this repo uses semantic test names (`test_action_provider_isolated.py`, `test_combat_pipeline.py`, etc.); I did not count those as direct gaps. +No skipped or xfailed tests. The mechanical "rules module name -> test_rules_*.py" script still reports false positives because this repo uses semantic test names (`test_combat.py`, `test_movement.py`, `test_action_provider_isolated.py`, etc.); I did not count those as direct gaps. ## Vision Drift | Change | Invariant Violated | Impact | |--------|-------------------|--------| -| Split ecology/entities simulation modules still use process-global `random` for encounter rolls, squad roam movement, and retreat selection. | Simulation-core says the world is frozen on a half-step and should be replayable/testable without hidden external state. Sprint 020 phase 1 already pushed handlers toward injected RNG. | Medium. Not a layer-direction violation and not a current gameplay blocker, but it undercuts deterministic replay/save-schema work. | +| none | Sprint 021 aligns with simulation-core: save schema v1, seeded layer RNG, RNG state persistence, and periodic autosave all support the "world frozen on a half-step" direction. | none | -No drift found for the larger simulation-core re-scope: activation logic was isolated without polishing, common materialization tracker was intentionally deferred, combat/peaceful loop merge was correctly cancelled, and serialization dedup moves toward the save-schema prerequisite. +Classic mode still works without LLM, time remains a single session timeline, layers remain callback-separated, and master control still goes through service endpoints. -## Sprint 020 Backlog Reconciliation +## Sprint 021 Backlog Reconciliation | Item | Audit Result | |------|--------------| -| `round-growing` | Partially closed by Sprint 020 (awareness builder + movement helper + one activation per loop), but still a large module. Backlog wording updated to 548 lines and simulation-core remaining work. | -| `activation-manager-growing` | Closed as originally scoped: activation is isolated and encounters/materialization are split. Marked fixed in backlog. | -| `god-class-combat-manager` | Closed as originally scoped: combat resolution split out and relation helper extracted. Marked fixed in backlog. | -| `session-serialization-duplication` | Already closed before Sprint 020; no change. | -| `equip-action-collapse` | Still deferred by explicit phase 3 decision. Keep backlog entry. | -| `event-log-eslint-suppress`, `schema-form-eslint-suppress` | Still present after frontend decomposition. Keep backlog entries. | -| `any-to-object-sweep`, `dict-str-object-overuse` | Partially reduced by typed query work, but still present in content/service/adapter/save boundaries. Keep backlog entries. | - -## Triage Outcome - -Coordinator decision: no blockers. Quick-fix applied: `entity_serialization.py` now returns `dict[str, object]`; `make check-backend` stayed green. New backlog entries added for `layer-rng-threading` and `test-gap-world-rng-determinism`. +| `save-schema` | Substantively closed for v1: `SaveGame(schema_version=1)` is the single envelope, typed layer states are authoritative, legacy saves are rejected. Future simulation-core fields will extend v1/v2 rather than reopening the old manual format. | +| `layer-rng-threading` | Closed: encounter rolls, squad movement, retreat, lair depletion, weather, politics, ecology, and entities use owned RNG streams rather than process-global `random`. | +| `test-gap-world-rng-determinism` | Closed: `tests/unit/test_world_seed.py` pins same-seed full-world replay, different-seed divergence, layer seed streams, and encounter spawn replay. | +| `periodic-autosave-scheduler` | Closed: FastAPI lifespan starts `_periodic_autosave()` with `DND_AUTOSAVE_SECONDS`, cancels it before final shutdown autosave, and tests interval/error/cancel behavior. | +| `silent-failure-autosave` | Closed for the three Sprint 021 targets: create-player autosave, empty-session autosave, and periodic autosave log exceptions. | +| `saved-session-accumulation` | Partially addressed: integration suite cleanup is in place; UX pagination/filter/TTL remains a separate product/debt question. | +| `load-combat-round-resume` | Still known and not duplicated: phase-2 E2E showed save JSON correct, but load/reconnect can advance combat before the UI stabilizes. | From 9a7af291fefd958d4dbddee7a200c8916b8ba984 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:24:50 +0300 Subject: [PATCH 21/25] =?UTF-8?q?sprint=20021:=20audit=20triage=20?= =?UTF-8?q?=E2=80=94=20close=20sprint=20items,=20file=20save-round-concurr?= =?UTF-8?q?ency=20+=20minor=20gaps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BACKLOG.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index 05428280..dc5a23b6 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -14,7 +14,7 @@ Эпики новой модели из [simulation-core.md](brainstorms/simulation-core.md). Порядок зависимостей: `save-schema` → `anchor-as-property` + `intents` → `trigger-table` → `inner-self` → `detail-ladder` → `quest-system`. -- [ ] **must** `save-schema` — единая схема сейва (Pydantic по образцу контента) вместо рукописного формата в нескольких местах. Предусловие всей модели: «мир заморожен на полушаге» требует lossless-сейва (намерения, планы мозгов, триггеры, лог мыслей, зародыши субъектности). Стартовый кусок — «дедуп сериализации» в Sprint 020 phase 3, добивается отдельным спринтом +- [x] **must** `save-schema` — CLOSED Sprint 021: единая схема сейва (Pydantic по образцу контента) вместо рукописного формата в нескольких местах. Предусловие всей модели: «мир заморожен на полушаге» требует lossless-сейва (намерения, планы мозгов, триггеры, лог мыслей, зародыши субъектности). Стартовый кусок — «дедуп сериализации» в Sprint 020 phase 3, добивается отдельным спринтом - [ ] **must** `anchor-as-property` — якорь материализации как свойство существа, не тип: активация без `isinstance(PlayerCharacter)`. Player-agnostic движок (идея 1 вижна). Дешёвый первый шаг, можно брать рано - [ ] **must** `intents` — намерение как первоклассная сущность на Creature: спит/идёт/ждёт, каждое действие несёт длительность, встроенные прерывания (телесное, втягивание в сцену, прибытие, таймер), wake-точки как обобщение `wake_at_seconds`. Убивает travel-as-wait хак; входной таск — `travel-action-type` - [ ] **must** `trigger-table` — парные декларативные триггеры `{on, until}` на существе (YAML + ручка ГМ), матчинг при эмиссии событий, активация/гашение dormant↔active, самогашение «моя роль сыграна» как действие мозга. Требует типизированной таксономии событий (фундамент заложен Sprint 020 phase 2). Поглощает `spawn-event-trigger` @@ -79,13 +79,14 @@ ## Engine & Session +- [ ] **should** `save-round-concurrency` — save/load/autosave снимают `session.world` без синхронизации с живым раунд-тредом (`commands_save.py`, `app.py` periodic autosave, `session.py`): `Round.run_loop()` мутирует мир в фоне, периодический автосейв (Sprint 021) сделал гонку регулярной — риск порванного сейва/`RuntimeError: dict changed size`. Нужна session-уровневая критическая секция сейва/загрузки, согласованная с round lifecycle (manual save, autosave, periodic, evict, load) + concurrency-тест. Audit 2026-07-10, top-кандидат ближайшего спринта - [ ] **must** `travel-action-type` — Отдельный `ActionType.TRAVEL` с длительностью и по-рёберным движением по графу локаций: путешественник всегда в конкретной локации, пути активных пересекаются (дорожные встречи), прерывание — всегда в конкретной точке. Первый кирпич эпика `intents` (повышен should→must). Текущий хак: `LocationPanel` шлёт `Action(WAIT, {hours: 0, travel_to})` с телепортом в конце - [ ] **should** `npc-instant-say-response` — dormant отвечает пассивно, не просыпаясь (simulation-core): после `say` дать существам в локации отреагировать в том же запросе (1 раунд). Сейчас NPC отвечают только при `advance_time` - [ ] **could** `list-npcs-iterate-entities` — `list_npcs` итерирует по регионам; NPC в несуществующем регионе выпадает из списка. Итерировать по entities напрямую -- [ ] **should** `periodic-autosave-scheduler` — фоновый asyncio таск в FastAPI lifespan каждые ~2 мин вызывает `autosave_all_sessions()`; cancel на shutdown перед финальным autosave. Дополняет per-action и shutdown автосейв. Повышен could→should: «мир заморожен на полушаге» (simulation-core) требует надёжного автосейва +- [x] **should** `periodic-autosave-scheduler` — FIXED Sprint 021 phase 3 (`DND_AUTOSAVE_SECONDS`, cancel до финального сейва): фоновый asyncio таск в FastAPI lifespan каждые ~2 мин вызывает `autosave_all_sessions()`; cancel на shutdown перед финальным autosave. Дополняет per-action и shutdown автосейв. Повышен could→should: «мир заморожен на полушаге» (simulation-core) требует надёжного автосейва - [ ] **could** `control-interfaces-donor` — Донор-ветка `sprint/020-control-interfaces` и PR #15: identity/roles, три линзы и admin park реализованы против до-thermo control-plane. При планировании control-interfaces использовать как референс задач и тестов; raw merge невозможен из-за конфликтов с thermo-sweep. Grace-period и spectator уже перенесены отдельно - [ ] **should?** `wait-no-fastforward-with-npc` — **требует проверки: баг или медленно-но-корректно.** `wait` не делает fast-forward, когда в локации игрока сидит активный rule-NPC — вместо прыжка к `wake_at` раунд тикает по 6 c, и управление к игроку возвращается только через ~600 раундов (1 час игрового времени). Ожидание (playbook 2.3): время сдвигается на 1 час, ход быстро возвращается. **Repro (E2E sprint 020 phase 2):** мир «Долина Мечей», сессия 283d42a2, локация «Солёный Якорь» (`silverport_city_tavern`) с co-located rule-NPC «Марта»; игрок Grimwald QA (Fighter L1) жмёт «Ждать» → action bar застревает на «Ожидание хода…». Бэклог: `wait_sleep` hours=1, `wake_at=46326855600`, но `round_end` показывает `game_time` сдвинутым лишь на 6 c (`second=6`) — fast-forward «нет активных существ → прыжок к ближайшему wake_at» не срабатывает, т.к. Марта остаётся активной. Гипотеза: при `wait` игрок получает `wake_at` и перестаёт быть anchor'ом, значит co-located NPC тоже должен уйти в dormant и включить fast-forward — но не уходит. Проверить `ActivationManager.update_activation` / anchor-логику и путь fast-forward в `Round.run_loop`. NB: наблюдалось после evict→reconnect (см. `session-disconnect-debounce`), но поведение `wait` от этого не зависит. Смежно: `test-gap-ws-fastforward`. По simulation-core гипотеза корректна: расписание-NPC без якоря рядом не должен оставаться активным; тот самый путь, который перепишут `intents`/`anchor-as-property`, — но проверить/починить стоит уже сейчас -- [ ] **could** `saved-session-accumulation` — Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке +- [ ] **could** `saved-session-accumulation` — тест-гигиена закрыта Sprint 021 phase 3 (интеграционный стек чистит saves/ через session fixture); остаётся UX-половина (пагинация/фильтр/TTL в Sessions-вкладке). Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке - [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` **сразу** ставит раунд на паузу (`stop_round`), а откладывает только выселение из реестра: ставится grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`), reconnect в окне отменяет timer, а вернувшийся игрок перезапускает раунд через `start_round`. Раньше откладывались И stop_round, И evict — из-за этого player-less раунд-луп продолжал крутить ходы NPC в grace-окне (при сетевом блипе игрок молча терял боевые ходы), а поскольку все сессии тянут один процесс-глобальный RNG костей, пересекающиеся «осиротевшие» раунды делали seed-зависимые integration-тесты недетерминированными (`test_player_state_xp::test_rest_status_updated_after_kill` флакал в CI). Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`. ## DevOps / Infra @@ -153,7 +154,7 @@ - [x] `base-action-provider-stateful` — ~~BaseActionProvider в rules/ — stateful class с self._types~~ FIXED Sprint 020 phase 1 task 5: standalone-функция / frozen dataclass - [x] `adapter-imports-core-directly` — ~~routes_player импортирует PlayerCharacter/Ability, routes_master — Query/QueryType напрямую из core~~ FIXED Sprint 019 phase 2 task 3: старые PlayerCharacter/Ability/Query/QueryType импорты убраны при routes_master split (Sprint 016); Action/ActionType вынесены в `service/action_parsing.py` (task 3). Оставшиеся BrainType/FightingStyle — enum-at-boundary в Pydantic-схемах, приняты (аудит 2026-06-28: 0 арх-нарушений, адаптерам можно импортировать enum) - [ ] **should** `any-to-object-sweep` — dict[str, Any] вместо dict[str, object] (core/models, layers, llm, adapters). Частично закрыт Sprint 020 phase 2 (typed query contract, ~28 cast-сайтов); остатки в llm/ и adapters/ -- [ ] **should** `layer-rng-threading` — encounter rolls, squad roam movement и retreat selection в `layers/entities/encounters.py`, `layers/ecology/movement.py`, `layers/ecology/squad_combat.py` используют process-global `random`. Прокинуть RNG явно или через единый seeded источник, чтобы world simulation была воспроизводима для `save-schema` / `gm-interlude` ([simulation-core](brainstorms/simulation-core.md)) +- [x] **should** `layer-rng-threading` — FIXED Sprint 021 phase 1: encounter rolls, squad roam movement и retreat selection в `layers/entities/encounters.py`, `layers/ecology/movement.py`, `layers/ecology/squad_combat.py` используют process-global `random`. Прокинуть RNG явно или через единый seeded источник, чтобы world simulation была воспроизводима для `save-schema` / `gm-interlude` ([simulation-core](brainstorms/simulation-core.md)) - [x] `entity-type-enum` — ~~"player"/"npc"/"creature" строковые сравнения в 5+ файлах~~ FIXED Sprint 016 (`EntityKind(StrEnum)`) + добивка на границах Sprint 020 phase 2 task 3 - [x] `brain-type-enum` — ~~ai_type == "rule_based" строковые сравнения~~ FIXED Sprint 016 (`BrainType(StrEnum)`) + границы Sprint 020 phase 2 - [x] `layer-source-string-cmp` — ~~game_service.py source == "library" вместо enum~~ FIXED Sprint 020 phase 2 task 3: `LayerSource` @@ -167,7 +168,7 @@ - [x] `perception-dispatch-chain` — ~~perception.py if-elif chain~~ FIXED Sprint 012 phase 4: dict[EventType, handler] dispatch - [x] `activation-manager-growing` — ~~activation_manager.py 626 строк. Вынести EncounterRoller + materialization~~ FIXED Sprint 020 phase 3: 626→150, encounters/materialization вынесены. Оставшаяся activation-логика сознательно только изолирована, дальнейшая замена идёт через `intents`/`trigger-table`/`anchor-as-property` - [ ] **could** `deep-nesting-diplomacy` — politics/layer.py _process_diplomacy 7 уровней вложенности -- [ ] **should** `silent-failure-autosave` — 3x contextlib.suppress(Exception) вокруг autosave. Логировать ошибки, не глушить +- [x] **should** `silent-failure-autosave` — FIXED Sprint 021 phase 3 (+ гвард на evict-после-DELETE): 3x contextlib.suppress(Exception) вокруг autosave. Логировать ошибки, не глушить - [x] `silent-failure-awareness` — ~~awareness_builder.py 6x broad except Exception~~ FIXED Sprint 012 phase 4: narrowed to KeyError/LookupError - [x] `silent-failure-movement` — ~~handle_wait except ValueError: pass~~ FIXED Sprint 020 phase 1 task 2: недостижимый/несуществующий travel-таргет → `ActionResult(success=False)` - [x] `schema-form-growing` — ~~frontend SchemaForm.tsx 488 строк, 30+ nested helpers~~ FIXED Sprint 020 phase 4: `FieldShell`, `schemaResolve.ts`, `localizedCodec.ts`, один `buildDefaults`; `SchemaForm.tsx` 373 строки @@ -238,7 +239,7 @@ - [ ] **should** `test-gap-commands-time` — service/commands_time.py 0 test references - [ ] **should** `test-gap-fighting-style` — rules/fighting_style.py без выделенных unit-тестов (indirect через test_second_wind, test_create_player) - [ ] **could** `test-gap-ws-malformed-json` — WS handler не тестируется на невалидный JSON (только unknown message type) -- [ ] **could** `test-gap-world-rng-determinism` — нет тестов, фиксирующих seeded deterministic behavior для encounter rolls, squad roam movement и squad retreat selection. Добавить вместе с `layer-rng-threading` +- [x] **could** `test-gap-world-rng-determinism` — FIXED Sprint 021 phase 1 (`test_world_seed.py`): нет тестов, фиксирующих seeded deterministic behavior для encounter rolls, squad roam movement и squad retreat selection. Добавить вместе с `layer-rng-threading` ## From audit 2026-04-13 (post Sprint 017) @@ -248,6 +249,8 @@ - [ ] **could** `test-gap-ws-disconnect` — нет теста disconnect во время активного game loop - [ ] **could** `test-gap-ws-reaction-prompts` — reaction prompt flow по WS не покрыт - [ ] **could** `test-gap-ws-concurrent-messages` — concurrent message handling по WS не тестируется +- [ ] **could** `test-gap-shutdown-autosave-failure` — поведение shutdown-пути, когда финальный `autosave_all_sessions()` сам бросает, не запинено (audit 2026-07-10) +- [ ] **could** `player-save-bridge-removal` — `player_to_save_data()` остался как compatibility-subset для `parse_player`; когда парсинг игрока переедет целиком на save-модели — убрать мост (audit 2026-07-10) ## From audit 2026-06-28 (post Sprint 018), triaged From 444a6533aa6395b661e1d0a96c35d36642ad3750 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:45:09 +0300 Subject: [PATCH 22/25] sprint 021: post-audit e2e smoke --- .../e2e-reports/2026-07-10-sprint021-close.md | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 docs/e2e-reports/2026-07-10-sprint021-close.md diff --git a/docs/e2e-reports/2026-07-10-sprint021-close.md b/docs/e2e-reports/2026-07-10-sprint021-close.md new file mode 100644 index 00000000..0539598f --- /dev/null +++ b/docs/e2e-reports/2026-07-10-sprint021-close.md @@ -0,0 +1,45 @@ +# E2E Report: sprint021-close + +**Date:** 2026-07-10 +**Flags:** --no-llm +**Scope:** post-audit regression smoke +**Stack:** uvicorn :8001, vite :5173, `LOG_LEVEL=DEBUG`, `LOG_DIR=/tmp/dnd-e2e-logs` + +## Summary + +- Scenarios: 4 requested smoke areas, 2 passed, 1 partial, 1 blocked by target/session instability. +- Quick fixes: 0, code was not changed. +- Blockers: 1 E2E blocker in the combined player flow. + +## Results + +| # | Scenario | Status | Notes | +|---|---|---|---| +| 1 | Create character -> movement -> combat kill -> XP/loot | partial | Character creation and movement to Silverport Market worked. Combat/loot was unstable because the UI exposes several identical `Attack` buttons; automation hit Gretta in one run instead of the spawned test creature. XP was verified separately on `level_up_test`: killing `xp_dummy` awarded 500 XP and opened level-up. | +| 2 | Quick save -> load sanity | pass | Save `sprint021_smoke_1783701831851` was listed, file had `schema_version=1`, and load restored time from `Y1490 M6 D1 13:02` to `Y1490 M6 D1 10:02`. | +| 3 | Trading | pass earlier, unstable after contamination | Clean run passed: buy/sell with Gretta changed gold `1000 -> 950 -> 1000`. After a diagnostic run accidentally killed Gretta, a later fresh player session in the same backend process reached Silverport Market with no nearby entities and stayed `Waiting for turn...`; backend restart was needed. | +| 4 | Master panel time/spawn creature | pass | `/master/:sessionId` Creatures tab spawned `Smoke Spawn`; Time tab advanced from `Y1490 M6 D1 10:00` to `Y1490 M6 D2 10:00`. | + +## Findings + +### Blockers + +- Combined player smoke is not reliable through the current UI because repeated `Attack` buttons have the same accessible name. Repro: create a Sword Vale fighter, move to Silverport Market, add a test creature near Gretta, then target by role/name. Playwright sees Gretta, the test creature, and the action-bar attack as indistinguishable `Attack` controls; one run attacked and killed Gretta, which then polluted later in-process sessions. + +### Minor + +- Mixed locale text appears in EN UI during combat/logs: e.g. Russian combat lines and `человек` labels on an English screen. +- `SAVES_DIR=/tmp/dnd-e2e-saves` was ignored by the API app; saves were written under repo `saves/`. +- Live-spawned generic monsters do not accept `xp_value` through the master spawn API, so XP smoke used the existing `level_up_test` fixture. + +## Log Analysis + +- No backend `error`, `exception`, or `traceback` lines were found in `/tmp/dnd-e2e-backend.log`. +- Expected `llm_not_configured_fallback` warnings appeared for LLM NPCs; LLM scenarios were out of scope. +- Session logs confirm XP fixture behavior: `XP Smoke` killed `XP Dummy`, received 500 XP, and `level_up_available=true`. + +## Artifacts + +- Playwright screenshots and JSON: `/tmp/dnd-e2e-sprint021-close/` +- Backend log: `/tmp/dnd-e2e-backend.log` +- Frontend log: `/tmp/dnd-e2e-frontend.log` From cb81afb045daa7153bc8d4a068e368391c7d0a12 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:47:06 +0300 Subject: [PATCH 23/25] =?UTF-8?q?sprint=20021:=20e2e=20close=20triage=20?= =?UTF-8?q?=E2=80=94=20reclassify=20automation=20blocker,=20file=20testabi?= =?UTF-8?q?lity=20items?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/BACKLOG.md | 3 +++ docs/e2e-reports/2026-07-10-sprint021-close.md | 2 ++ docs/sprints/021-save-schema/sprint.md | 2 ++ 3 files changed, 7 insertions(+) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index dc5a23b6..0e029e50 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -41,6 +41,7 @@ - [ ] **should** `loot-drops-monsters` — Общемонстровый дроп: loot-таблицы на шаблонах монстров, корпс-лут с обычных мобов поверх action `take` (Sprint 018 закладывает примитив `Lootable`/`transfer_items`) - [ ] **should** `theft` — Воровство как отдельный режим доступа к инвентарю: take у живого несогласного владельца, contested Sleight of Hand против Perception, crime/репутация; отдельная `validate_steal` поверх общего `transfer_items` - [x] `spawn-event-trigger` — ~~Event-триггер спавна (спавн по мировому событию), в связке со спринтом квестов~~ ПОГЛОЩЁН `trigger-table`: спавн — одно из действий сработавшего триггера +- [ ] **could** `spawn-api-xp-value` — master spawn API не принимает `xp_value` для generic-монстров: XP-смоук возможен только на фикстурном мире. E2E sprint 021 close - [ ] **could** `container-hp-locks` — Сундуки с замком/HP: взлом (lockpicking) и «разбить» контейнер - [ ] **should** `lair-death-event` — смерти существ логова не фиксируются в `EcologyLayer` в реальном времени: `LairState`/`core_alive` обновляются только при dematerialize. При рестарте сервера без сейва lair стартует как ACTIVE заново. Фикс-механизм подтверждён брейнштормом simulation-core: событийная запись (`ENTITY_DIED` через emit_fn, ecology подписывается) — это **прототип write-back всей `detail-ladder`**, кандидат в ближайший спринт как первая проба механизма. Направление зависимости: entities→ecology через emit_fn, не прямой импорт - [ ] **could** `lair-actions` — D&D lair actions на ядре логова @@ -72,6 +73,7 @@ - [ ] **could** `drag-resize-panels` — Drag-and-drop / resizable панели на dashboard - [ ] **could** `mobile-layout` — Мобильная адаптация dashboard - [ ] **could** `log-filter-tabs` — Фильтрация лога табами (Все/Бой/Диалоги) +- [ ] **should** `attack-buttons-accessible-names` — кнопки Attack в nearby-списке и action bar имеют одинаковые accessible names: цели неразличимы для автоматизации и скринридеров — E2E-смоук бил не в ту цель (убил торговку Гретту). Добавить aria-label с именем цели. Побочная аномалия того прогона (не отрепрожена чисто): свежая сессия в том же процессе увидела пустой рынок и зависла в «Waiting for turn…» — если всплывёт снова, разбирать отдельно. E2E sprint 021 close - [ ] **should** `master-panel-creature-inventory` — `CreatureResponse` / `all_entities` query не включают inventory/equipped_weapon; мастер не видит предметы существ. Добавить поля в схему и query - [x] `master-give-item-ui` — ~~endpoint для give_item есть, кнопки нет~~ FIXED Sprint 007 phase 2: кнопка «Выдать предмет» в карточке существа - [x] `inspect-as-idle-param` — ~~inspect шёл как `Action(IDLE, {inspect_target})`~~ FIXED Sprint 009 phase 4: клиентская NpcInspectModal из awareness @@ -91,6 +93,7 @@ ## DevOps / Infra +- [ ] **could** `saves-dir-env` — каталог сейвов захардкожен (`DEFAULT_SAVES_DIR` в app.py); env-переопределение (напр. `DND_SAVES_DIR`) нужно E2E/тестам для изоляции от рабочего saves/. E2E sprint 021 close - [ ] **should** `containerized-stack` — Воспроизводимый контейнерный сетап для подъёма всего стека (фронт + бэк) одной командой. Двойная польза: локально быстро поднять перед E2E и переиспользовать на проде. Сейчас `docker-compose.test.yml` — только `backend` + `integration-tests` (pytest), без фронта и без проброса портов наружу, поэтому браузерный E2E гоняется на хостовых `uvicorn`/`vite`: ловит убийство процесса песочницей при бинде порта и зависит от хостовых Node/uv. План: добавить сервис `frontend` (собранный бандл через `vite build` + `vite preview` или nginx со статикой, не dev-сервер — заодно тестируем прод-бандл), пробросить `8001`/`5173`, оформить профилем `--profile e2e` чтобы не мешать `integration-tests`, и перевести шаг «Start the stack» в скилле `/e2e` на `docker compose --profile e2e up`. Прод-вариант: тот же образ фронта (nginx) + бэкенд, общий базовый compose. Не закрывает E2E-в-CI (нужен отдельно Playwright-в-контейнере + написанные спеки) — это про воспроизводимость стека, не про сами тесты - [ ] **could** `pnpm-shared-store` — Перевести frontend с npm на pnpm: общий content-addressable store делает `node_modules` в свежем git-ворктри почти мгновенным (hardlink из стора) вместо ~1-2 мин `npm ci` на каждый Orca-воркер. Возникло из оркестрации 2026-07-04: `orca.yaml` setup ставит только uv-зависимости, фронт каждый воркер ставит сам. Дешёвый первый шаг без смены менеджера — добавить `cd frontend && npm ci` в `orca.yaml` setup. Полный переход = правки CI, Makefile, docker, доков diff --git a/docs/e2e-reports/2026-07-10-sprint021-close.md b/docs/e2e-reports/2026-07-10-sprint021-close.md index 0539598f..3e2c9cd5 100644 --- a/docs/e2e-reports/2026-07-10-sprint021-close.md +++ b/docs/e2e-reports/2026-07-10-sprint021-close.md @@ -24,6 +24,8 @@ ### Blockers +_Триаж координатора (2026-07-10): переквалифицировано в testability-находку — продуктовые сценарии прошли (боевой путь и XP верифицированы на `level_up_test`), сломана различимость целей для автоматизации, не функциональность. Заведено в BACKLOG: `attack-buttons-accessible-names` (should), `saves-dir-env`, `spawn-api-xp-value` (could). Блокеров закрытия спринта нет._ + - Combined player smoke is not reliable through the current UI because repeated `Attack` buttons have the same accessible name. Repro: create a Sword Vale fighter, move to Silverport Market, add a test creature near Gretta, then target by role/name. Playwright sees Gretta, the test creature, and the action-bar attack as indistinguishable `Attack` controls; one run attacked and killed Gretta, which then polluted later in-process sessions. ### Minor diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index 0093a716..1aeae057 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -66,6 +66,8 @@ Pydantic-модели сейва (`SaveGame`: `schema_version`, `meta`, `world{t - Phase 3 закрыта без отдельного E2E (фоновый шедулер и логирование, UI-поверхности нет); де-глушение сразу окупилось: evict-таймер после явного DELETE сессии падал автосейвом «session not found» 37 раз за integration-прогон (раньше глоталось) — починено гвардом в `_on_session_empty`, который заодно закрывает воскрешение удалённой сессии из stale-автосейва (2026-07-10). +- Post-audit E2E: «блокер» отчёта переквалифицирован — одинаковые accessible names у Attack-кнопок ломают адресацию целей в автоматизации (продукт цел, все сценарии прошли или верифицированы на фикстуре); заведено `attack-buttons-accessible-names` + `saves-dir-env` + `spawn-api-xp-value` (2026-07-10). + ## Deferred _(заполняется по ходу спринта)_ From 2fec982bf3e47093228de7d14dd38a0024514018 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:51:04 +0300 Subject: [PATCH 24/25] docs: update living docs (full sync, 6 files) --- .claude/skills/update-docs/state.json | 8 ++++---- ARCHITECTURE.md | 8 ++++++++ CLAUDE.md | 2 +- docs/ROADMAP.md | 6 +++++- src/dnd_simulator/layers/common/__init__.py | 2 +- src/dnd_simulator/layers/entities/__init__.py | 3 +++ src/dnd_simulator/storage/__init__.py | 8 ++++++-- 7 files changed, 28 insertions(+), 9 deletions(-) diff --git a/.claude/skills/update-docs/state.json b/.claude/skills/update-docs/state.json index 307402c5..7787285a 100644 --- a/.claude/skills/update-docs/state.json +++ b/.claude/skills/update-docs/state.json @@ -1,6 +1,6 @@ { - "last_run": "2026-06-29", + "last_run": "2026-07-10", "last_run_mode": "full", - "last_commit": "c37507c", - "last_full_run": "2026-06-29" -} + "last_commit": "cb81afb", + "last_full_run": "2026-07-10" +} \ No newline at end of file diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 16dfc2e5..85e23d6f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -273,6 +273,14 @@ Centralized derived stat computation replacing ad-hoc logic scattered across com **Convenience API:** `effective_speed(creature)`, `effective_ac(creature)`, `attack_modifiers(attacker, target, melee)`. +## Save Schema & Reproducibility + +**Save format** (Sprint 021): one versioned Pydantic envelope — `SaveGame(schema_version=1, meta, world)` in `storage/save_schema.py`. `WorldSave` carries the world seed, dice RNG state, time, last tick times, and typed layer states: each layer owns a state model (`layers/*/state.py`, `layers/entities/save_models.py`) that is the authoritative format (`extra="forbid"`), while the `Layer` ABC keeps its dict-facing `get_state()/load_state()` signatures (core stays pydantic-free). Entity payloads are a discriminated union on `entity_type` (`PlayerSave`/`NpcSave`/`CreatureSave`/`ContainerSave`) built directly from live objects in `entity_serialization.py`; combat state persists turn order, round, battle map, and sides. `save_game()` and `autosave_session()` build the same envelope; `load_game()` validates it and rejects legacy saves without `schema_version`. + +**Reproducibility**: `DND_WORLD_SEED` (env; random + logged when absent) seeds the world in `game_service` — per-layer seeds are derived deterministically and passed to layer constructors, which own their `random.Random` streams (weather, politics, ecology roam/retreat/lair depletion, entity encounters). The dice RNG (`rules/dice.py`, `DND_DICE_SEED`) is a separate stream. All RNG states are serialized into the save, so a loaded game continues the same random sequences — same seed, same content → identical world evolution (pinned by `tests/unit/test_world_seed.py`). + +**Autosave**: per-action (create_player), on session evict, on shutdown, plus a periodic lifespan task every `DND_AUTOSAVE_SECONDS` (default 120, cancelled before the final shutdown autosave). Autosave failures are logged, never suppressed. + ## Logging Structured logging via `structlog` (`logging_config.py`, `logging_file_dispatch.py`). `LOG_LEVEL` env var controls verbosity (default: WARNING). When `LOG_LEVEL=DEBUG` and stderr is a TTY, uses pretty console renderer; otherwise JSON. `LOG_DIR` enables denormalized JSONL file dispatch per domain tag. See [docs/LOGGING.md](docs/LOGGING.md). diff --git a/CLAUDE.md b/CLAUDE.md index 04a99187..c0dd42aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,7 +77,7 @@ adapters/ — FastAPI REST + WebSocket API rules/ — pure D&D mechanics: combat, validation, conditions, weapons, modifiers, proficiency, sneak attack, divine smite, fighting style, resources, character creation (point buy, HP, starting equipment), leveling (XP-by-CR, thresholds, perform_level_up), action providers, handlers/ package, reputation, combat_sides, encounters (time-of-day gate), inventory (transfer_items), loot, rule_brain (no deps) llm/ — LLM client, prompt builders, tool schemas (OpenRouter) -storage/ — SaveStore interface, JsonFileStore +storage/ — SaveStore interface, JsonFileStore, versioned save schema (SaveGame, schema_version=1, world seed + RNG state в сейве) content_loader/ — loads worlds, nations, settlements, NPCs, player from YAML; Pydantic content schemas, JSON Schema generation, entity CRUD, manifest resolver, library catalog, world assembly, catalog loader (monsters/items) content/ — YAML world definitions (data, not code); library/ (reusable layer templates), worlds/ (manifest + optional custom layers) frontend/ — React + TypeScript SPA (Vite, shadcn/ui, Zustand) diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 852f036f..77f1e4ef 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -123,6 +123,10 @@ Paladin L1-L2 как первый caster-класс. Phase 1: spell slots как Техспринт по результатам термоядерного ревью. Phase 1: save/load integrity (accessory modifiers, XP), visible bugs, deterministic handlers, i18n errors, pure action providers. Phase 2: typed query accessors, SquadInfo/LairInfo payloads, LayerSource/BrainType/EntityKind cleanup, `World.get_layer`, app-level exception handlers, unified player-status. Phase 3: backend decomposition — `combat_manager` lifecycle vs `combat_resolution`, `activation_manager` → encounters/materialization, ecology submodules, `AwarenessBuilder`, entity serialization split, backend equipment registry. Phase 4: frontend decomposition — `TargetDropdown`, `SchemaForm`, `EventLog`, `WorldOverview`, shared `PlayerStatus`, typed world-state rows, store/transport dedup. Audit closed with no blockers; deferred RNG threading filed for simulation-core determinism. → [план спринта](sprints/020-thermo-sweep/sprint.md) +### Sprint 021 — Save Schema & World Reproducibility (фазы 1-3) +Первый эпик цепочки simulation-core. Phase 1: единый `DND_WORLD_SEED` — слоевые сиды выводятся детерминированно в `game_service`, слои владеют своими `random.Random` (погода, политика, roam/retreat/деплит логова, encounter rolls), процесс-глобальный `random` из `layers/` убран, сквозной пин детерминизма (`test_world_seed.py`: один сид → идентичный `World.save()`). Phase 2: версионированная Pydantic-схема сейва — `SaveGame(schema_version=1)` в `storage/save_schema.py`, типизированные state-модели слоёв (`extra="forbid"`), entity-сейвы как discriminated union, построение напрямую из объектов, combat sides в сейве (закрыт lossless-пробел), состояние RNG (слоевые + dice) сериализуется и продолжает последовательности после load, legacy-форматы отклоняются. Phase 3: периодический автосейв (`DND_AUTOSAVE_SECONDS`, cancel до финального сейва), ошибки автосейва логируются вместо suppress, гвард на evict-после-DELETE (заодно закрыл воскрешение удалённой сессии), интеграционный стек чистит `saves/`. Закрыты backlog: `save-schema`, `layer-rng-threading`, `test-gap-world-rng-determinism`, `periodic-autosave-scheduler`, `silent-failure-autosave`. +→ [план спринта](sprints/021-save-schema/sprint.md) + ## Planned ### Level 2 — Расходуемые ресурсы @@ -134,7 +138,7 @@ Spell slots, ki, rage. Дополнительные типы брони и ор → [брейншторм](brainstorms/ecs-and-content.md) ### Simulation Core — намерения, триггеры, внутреннее я, лестница детализации -Заменяет прежний план «Phase 3 — Автономные тики» (периодические тики отброшены в пользу decision-точек). Цепочка эпиков: единая схема сейва → якорь-как-свойство + намерения (спит/идёт/ждёт, travel по рёбрам) → парные триггеры `{on, until}` активации/гашения → внутреннее я NPC (цели, отношения, живой alignment, переваривание + правиловый близнец) → лестница детализации поселений (событийная запись, храповик субъектности) → квесты как контент поверх целей и триггеров. +Заменяет прежний план «Phase 3 — Автономные тики» (периодические тики отброшены в пользу decision-точек). Первый эпик (единая схема сейва + воспроизводимость) закрыт Sprint 021. Цепочка эпиков: ~~единая схема сейва~~ → якорь-как-свойство + намерения (спит/идёт/ждёт, travel по рёбрам) → парные триггеры `{on, until}` активации/гашения → внутреннее я NPC (цели, отношения, живой alignment, переваривание + правиловый близнец) → лестница детализации поселений (событийная запись, храповик субъектности) → квесты как контент поверх целей и триггеров. → [брейншторм](brainstorms/simulation-core.md), эпики в [BACKLOG](BACKLOG.md#simulation-core-брейншторм-2026-07-04) ### World Builder (advanced) diff --git a/src/dnd_simulator/layers/common/__init__.py b/src/dnd_simulator/layers/common/__init__.py index fc6c525e..b64f9b69 100644 --- a/src/dnd_simulator/layers/common/__init__.py +++ b/src/dnd_simulator/layers/common/__init__.py @@ -1 +1 @@ -"""Shared layer implementation helpers.""" +"""Shared layer implementation helpers: JSON-safe random.Random state codec (rng_state).""" diff --git a/src/dnd_simulator/layers/entities/__init__.py b/src/dnd_simulator/layers/entities/__init__.py index 8c96bd49..5a845810 100644 --- a/src/dnd_simulator/layers/entities/__init__.py +++ b/src/dnd_simulator/layers/entities/__init__.py @@ -10,6 +10,9 @@ holds structured tags (NpcTag), recent events, inner state, and conversation context. MemorySummarizer (in llm/) compresses events into memory after combat ends. Direct access: get_entity, add_entity, remove_entity for hot controls. +Save format is defined by Pydantic models in save_models.py (EntitiesState: +discriminated entity union, combat state incl. sides, layer RNG state); +entity_serialization.py builds them directly from live objects. """ from dnd_simulator.layers.entities.layer import EntitiesLayer diff --git a/src/dnd_simulator/storage/__init__.py b/src/dnd_simulator/storage/__init__.py index 10b0d125..7eee9656 100644 --- a/src/dnd_simulator/storage/__init__.py +++ b/src/dnd_simulator/storage/__init__.py @@ -3,7 +3,11 @@ Abstracts how world state is persisted: - SaveStore: abstract interface (save, load, list, delete) - JsonFileStore: saves as JSON files in a directory +- save_schema: versioned Pydantic envelope — SaveGame(schema_version=1, meta, + world) with typed layer states, world seed and RNG states; legacy saves + without schema_version are rejected on load -Layers produce and consume plain dicts via get_state()/load_state(). -World serializes the full state tree. This module handles the rest. +Layers produce and consume plain dicts via get_state()/load_state(), validated +internally through per-layer state models. World serializes the full state tree; +the envelope here is the single source of truth for the on-disk format. """ From ca931708468d55baddd3216a3ede9dea5b907336 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 19:51:56 +0300 Subject: [PATCH 25/25] =?UTF-8?q?sprint=20021:=20close=20=E2=80=94=20save?= =?UTF-8?q?=20schema=20v1,=20world=20reproducibility,=20autosave=20hardeni?= =?UTF-8?q?ng?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/STATUS.md | 20 ++++++-------------- docs/sprints/021-save-schema/sprint.md | 8 +++++++- 2 files changed, 13 insertions(+), 15 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index cf7aa906..4ad8f9a9 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,27 +3,18 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-10 -**Position:** Классовые механики и система уровней доведены до D&D L2 (Fighter / Rogue / Paladin; XP & leveling — Sprint 017). Sprint 018 закрыл логова, лут, встречи и время суток. Sprint 019 отвердел control-plane под будущий разрез на роли. Sprint 020 закрыл thermo-sweep: корректностные баги, чистота `rules/`, типизация границ, backend/frontend decomposition и сверка с новым [simulation-core](brainstorms/simulation-core.md). -**Next:** активного спринта нет. Следующий `/new-sprint` должен выбрать направление: цепочка simulation-core (`save-schema` → `anchor-as-property` / `intents` → `trigger-table` → ...), `control-interfaces`, либо точечный debt из свежего audit (`layer-rng-threading`, `test-gap-world-rng-determinism`). `quest-system` планировать только после триггеров и целей. +**Position:** Sprint 020 закрыл thermo-sweep (корректность, чистота `rules/`, типизация границ, decomposition). Sprint 021 закрыл первый эпик simulation-core: версионированная Pydantic-схема сейва (`SaveGame`, schema_version=1), воспроизводимость мира от `DND_WORLD_SEED` (слоевые RNG, их состояние в сейве), периодический автосейв. Классовые механики на уровне D&D L2 (Fighter / Rogue / Paladin). +**Next:** активного спринта нет. Следующий `/new-sprint`: продолжение цепочки simulation-core (`anchor-as-property` / `intents` + `travel-action-type` → `trigger-table` → ...), при этом `save-round-concurrency` из свежего audit — top-кандидат на включение (сейв гоняется с живым раунд-тредом без синхронизации). Альтернатива — `control-interfaces`. `quest-system` планировать только после триггеров и целей. **Blockers:** нет. ## Current Sprint -**Sprint:** 021-save-schema -**Goal:** Единая версионированная Pydantic-схема сейва + воспроизводимость мира от явного сида + периодический автосейв. -**Started:** 2026-07-10 -**Phase:** 3 — Autosave hardening (task 2 done, phase ready to close) — 2026-07-10 - -Phase 2 закрыта (e2e 6/7, находки в бэклоге). Phase 3 tasks 1-2 done; ready to close phase 3. - -### Phases - -1. RNG threading & determinism -2. Unified Pydantic save schema -3. Autosave hardening +No active sprint. ## Recent activity (non-sprint) +- 2026-07-10 — Sprint 021 save-schema закрыт: unit 2429, integration 160, два E2E-прогона, audit triaged (свежий риск `save-round-concurrency` в бэклоге), PR в main. + - 2026-07-10: перенесены ценные фичи из `sprint/020-control-interfaces`: disconnect grace-period закрыл `session-disconnect-debounce`, spectator-listener добавил read-only WS `?spectate=true` и live-вкладку в master session view. - 2026-07-10 — Sprint 020 thermo-sweep закрыт: integration 154 passed, post-audit E2E smoke 5/5, audit triaged, PR opened to main. - 2026-07-04 — брейншторм [simulation-core](brainstorms/simulation-core.md): консенсус-модель времени/активности/внутреннего я/лестницы детализации. VISION.md переписан, BACKLOG реструктурирован (секция Simulation Core, поглощённые/переформулированные айтемы, чекбоксы фаз 1-2 спринта 020), ROADMAP Planned обновлён, указатели-актуализации в старых брейнштормах. @@ -34,6 +25,7 @@ Phase 2 закрыта (e2e 6/7, находки в бэклоге). Phase 3 task | Sprint | Goal | Started | Completed | |--------|------|---------|-----------| +| 021-save-schema | Версионированная Pydantic-схема сейва (schema_version=1, RNG в сейве, combat sides), воспроизводимость мира от DND_WORLD_SEED, периодический автосейв | 2026-07-10 | 2026-07-10 | | 020-thermo-sweep | Закрыть структурный долг из термоядерного ревью: корректность + чистота rules, типизация границ, backend/frontend decomposition, сверка с simulation-core | 2026-06-30 | 2026-07-10 | | 019-control-plane-prep | Отвердить control-plane под разрез на роли: GameService 1044→357 (миксины WorldBuilderCommands/PlayerCommands), тест-сетка на session, развязка core/adapter (action_parsing seam, public World query API), видимые дырки (combat-log i18n, encounter-перцептор, труп-кнопки) | 2026-06-28 | 2026-06-29 | | 018-lairs-encounters-loot | Логова (active→depleted), лут/контейнеры (`take`, `transfer_items`), региональные таблицы встреч, время суток; закрыт `monster-spawn` | 2026-06-28 | 2026-06-28 | diff --git a/docs/sprints/021-save-schema/sprint.md b/docs/sprints/021-save-schema/sprint.md index 1aeae057..9b5a2af7 100644 --- a/docs/sprints/021-save-schema/sprint.md +++ b/docs/sprints/021-save-schema/sprint.md @@ -74,4 +74,10 @@ _(заполняется по ходу спринта)_ ## Results -_(заполняется в конце спринта)_ +**Completed:** 2026-07-10 + +Спринт закрыл первый эпик цепочки simulation-core. Мир воспроизводим от одного сида: `DND_WORLD_SEED` детерминированно раздаёт слоевые сиды, слои владеют своими RNG (в `layers/` не осталось процесс-глобального `random`), пин `test_world_seed.py` — один сид → идентичный `World.save()`. Сейв — единый версионированный конверт `SaveGame(schema_version=1)`: типизированные state-модели слоёв и entity-сейвов (`extra="forbid"`, discriminated union, построение напрямую из объектов), состояние всех RNG в сейве (загрузка продолжает те же последовательности), combat sides сериализуются (закрыт lossless-пробел), legacy-форматы отклоняются. Автосейв периодический (`DND_AUTOSAVE_SECONDS`), ошибки логируются; попутно починен evict-после-DELETE (падал автосейвом 37 раз за integration-прогон и мог воскресить удалённую сессию). + +Метрики: 24 коммита, 65 файлов (+2808/−586), unit 2382→2429, integration 160 зелёные, два E2E-прогона (phase 2: 6/7; close smoke: продуктовые сценарии зелёные). Аудит: 16 находок, 0 архитектурных нарушений, 0 vision-дрифта. Закрыты backlog: `save-schema` (must), `layer-rng-threading`, `test-gap-world-rng-determinism`, `periodic-autosave-scheduler`, `silent-failure-autosave`, тест-половина `saved-session-accumulation`. + +**Deferred:** `save-round-concurrency` (should, top-кандидат следующего спринта — сейв без синхронизации с живым раунд-тредом), `load-combat-round-resume` (should), `attack-buttons-accessible-names` (should), `ui-language-mixing`, `saves-dir-env`, `spawn-api-xp-value`, `test-gap-shutdown-autosave-failure`, `player-save-bridge-removal` (could).