From 571f5810bac4224aadb836bfd32cd7c0f57f6c75 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:04:02 +0300 Subject: [PATCH 01/33] =?UTF-8?q?sprint=20022:=20plan=20=E2=80=94=20intent?= =?UTF-8?q?ions=20and=20travel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/STATUS.md | 14 ++++- docs/sprints/022-intents-travel/sprint.md | 74 +++++++++++++++++++++++ 2 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 docs/sprints/022-intents-travel/sprint.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 4ad8f9a9..303c3a56 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -9,7 +9,19 @@ ## Current Sprint -No active sprint. +**Sprint:** 022-intents-travel +**Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. +**Started:** 2026-07-10 +**Phase:** Planning (COMPLETE) — 2026-07-10 + +Ready for Phase 1 task generation. + +### Phases + +1. Safe session lifecycle +2. Anchors, wait and sleep intents +3. Travel as an intent +4. Interruptible journeys and E2E closure ## Recent activity (non-sprint) diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md new file mode 100644 index 00000000..68d3ad84 --- /dev/null +++ b/docs/sprints/022-intents-travel/sprint.md @@ -0,0 +1,74 @@ +# Sprint 022 — Intentions & Travel + +**Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. + +**Started:** 2026-07-10 + +## Context + +Второй эпик цепочки simulation-core после единой схемы сейва Sprint 021. Текущая активация знает тип `PlayerCharacter`, ожидание хранится как частный `wake_at_seconds`, а путешествие замаскировано под `WAIT` и телепортирует существо в конечную точку. Это расходится с player-agnostic вижном и не позволяет существам пересекаться в дороге. + +Спринт вводит якорь как свойство существа и первоклассное сохраняемое намерение для ожидания, сна и путешествия. Travel идёт по рёбрам графа, каждое существо остаётся в конкретной локации, базовые встроенные причины могут прервать намерение. Декларативные trigger-table, Brain gate/decide, динамические маршруты NPC, LLM-планирование, цели и квесты остаются за границей спринта. + +Параллельно закрываются связанные дефекты жизненного цикла. Save/load/autosave получают общую с раундом критическую секцию; ожидание без бодрствующего якоря быстро переводит мир к ближайшей wake-точке; загруженный бой не продолжается до подключения игрока. Небольшой фикс различимых accessible names у Attack-кнопок допустим как финальный polish, но не является целью спринта. + +**Ссылки:** [simulation-core](../../brainstorms/simulation-core.md), [BACKLOG](../../BACKLOG.md#simulation-core-брейншторм-2026-07-04), [Sprint 021](../021-save-schema/sprint.md) + +## Phase 1: Safe session lifecycle + +Save, load, autosave, evict и round loop согласованы одной session-level критической секцией. Загруженная посреди боя сессия остаётся на сохранённом ходе и запускается только после подключения игрока. Проверка: конкурентный autosave не получает порванный мир; сохранённый Round 1 после load остаётся Round 1 до реконнекта. + +Почему первой: следующие фазы добавляют изменяемое и сохраняемое состояние намерения, которое нельзя строить поверх известной гонки. Закрывает `save-round-concurrency` и `load-combat-round-resume`. + +**Tasks:** + +_(генерируются отдельно перед началом фазы)_ + +## Phase 2: Anchors, wait and sleep intents + +Якорь становится свойством существа, активация больше не проверяет `PlayerCharacter`. Wait и sleep представлены сохраняемыми намерениями с длительностью и wake-точкой. Проверка: любое назначенное якорем существо удерживает локальную сцену активной; wait/sleep переживает save/load; без бодрствующего якоря мир быстро переходит к ближайшему пробуждению. + +Почему второй: фаза формирует минимальную модель Intent и исправляет активацию до появления движения по графу. Закрывает `anchor-as-property`, базовую часть `intents`, `wait-no-fastforward-with-npc` и `test-gap-ws-fastforward`. + +**Tasks:** + +_(генерируются отдельно перед началом фазы)_ + +## Phase 3: Travel as an intent + +Игрок начинает настоящее travel-намерение. Путешественник проходит маршрут по рёбрам графа, остаётся в конкретной локации на каждом шаге и прибывает после игрового времени, а не телепортируется через `WAIT`. UI показывает текущее путешествие и его завершение. Проверка: маршрут из нескольких рёбер виден по шагам; save/load в середине дороги продолжает тот же маршрут; два активных существа могут оказаться в одной промежуточной точке. + +Почему третьей: travel переиспользует Intent, wake-точки и player-agnostic активацию из Phase 2. Закрывает `travel-action-type`, основную оставшуюся часть `intents` и старый `WAIT + travel_to` хак. + +**Tasks:** + +_(генерируются отдельно перед началом фазы)_ + +## Phase 4: Interruptible journeys and E2E closure + +Wait, sleep и travel штатно прерываются базовыми встроенными причинами: телесное событие, втягивание в сцену или бой, прибытие и таймер. После прерывания существо остаётся в согласованной локации и получает управление без потери или двойного исполнения намерения. Проверка: сохранение, загрузка, реконнект и прерывание в середине пути дают одно и то же состояние; полный пользовательский сценарий проходит через UI. Здесь же закрывается `attack-buttons-accessible-names`, чтобы E2E однозначно выбирал цель. + +Почему последней: прерывания проверяют совместную работу всех предыдущих фаз, не вводя декларативную trigger-table. + +**Tasks:** + +_(генерируются отдельно перед началом фазы)_ + +--- + +## Status + +**Current:** Planning complete. Ready to generate Phase 1 tasks. + +## Decisions + +- Граница Sprint 022: anchors + сохраняемые intents для wait/sleep/travel + встроенные прерывания. Декларативные trigger-table, Brain gate/decide, NPC wandering, LLM-планирование, цели и квесты остаются за пределами спринта (2026-07-10). +- Связанные lifecycle-дефекты `save-round-concurrency`, `load-combat-round-resume` и `wait-no-fastforward-with-npc` входят в основной scope; `attack-buttons-accessible-names` закрывается как E2E-polish (2026-07-10). + +## Deferred + +_(заполняется по ходу спринта)_ + +## Results + +_(заполняется в конце спринта)_ From 5a60a22758c01afbba6d8bdebaa8f8bd7ce5aeec Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:19:36 +0300 Subject: [PATCH 02/33] sprint 022 phase 1: task breakdown --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 6 ++- .../tasks/phase1-task1-session-dice-rng.md | 36 +++++++++++++++++ .../tasks/phase1-task2-world-state-gate.md | 37 ++++++++++++++++++ .../phase1-task3-consistent-save-paths.md | 38 ++++++++++++++++++ .../tasks/phase1-task4-atomic-load-resume.md | 39 +++++++++++++++++++ 6 files changed, 157 insertions(+), 3 deletions(-) create mode 100644 docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 303c3a56..cb5baab8 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** Planning (COMPLETE) — 2026-07-10 +**Phase:** 1 — Safe session lifecycle (tasks generated) — 2026-07-10 -Ready for Phase 1 task generation. +Ready to start task 1. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 68d3ad84..64bfae12 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -22,7 +22,10 @@ Save, load, autosave, evict и round loop согласованы одной sess **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [Session-owned dice RNG](tasks/phase1-task1-session-dice-rng.md) +2. [World-state mutation gate](tasks/phase1-task2-world-state-gate.md) +3. [Consistent save and eviction paths](tasks/phase1-task3-consistent-save-paths.md) +4. [Atomic load and connection-driven resume](tasks/phase1-task4-atomic-load-resume.md) ## Phase 2: Anchors, wait and sleep intents @@ -64,6 +67,7 @@ _(генерируются отдельно перед началом фазы)_ - Граница Sprint 022: anchors + сохраняемые intents для wait/sleep/travel + встроенные прерывания. Декларативные trigger-table, Brain gate/decide, NPC wandering, LLM-планирование, цели и квесты остаются за пределами спринта (2026-07-10). - Связанные lifecycle-дефекты `save-round-concurrency`, `load-combat-round-resume` и `wait-no-fastforward-with-npc` входят в основной scope; `attack-buttons-accessible-names` закрывается как E2E-polish (2026-07-10). +- Phase 1: session-level world gate недостаточен, пока dice RNG process-global. Dice RNG переводится во владение сессии до синхронизации snapshot; модульный fallback остаётся только для изолированных правиловых тестов (2026-07-10). ## Deferred diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md b/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md new file mode 100644 index 00000000..ec6e101c --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md @@ -0,0 +1,36 @@ +# Task: Session-owned dice RNG + +**Date:** 2026-07-10 +**Sprint:** 022-intents-travel +**Phase:** 1 — Safe session lifecycle + +## Description + +Убрать process-global dice RNG из runtime игрового процесса. Каждая `GameSession` владеет своим генератором костей; `Round` и `ActionContext` используют его для инициативы, атак, реакций, лечения и остальных правиловых бросков. `DND_DICE_SEED` сохраняет назначение детерминированного стартового сида, но одна сессия больше не сдвигает последовательность другой. Сейв и восстановление autosave работают с RNG конкретной сессии. + +Модульный fallback в `rules/dice.py` можно оставить для изолированных правиловых unit-тестов, но session runtime не должен обращаться к `get_global_rng()`. + +## Tests First + +- Две сессии с одинаковым seed получают одинаковую начальную последовательность, даже если между их бросками другая сессия выполняет дополнительные действия. +- После save/load следующая кость в конкретной сессии совпадает с продолжением её сохранённой последовательности и не зависит от бросков соседней сессии. +- Реальный round/action path использует session RNG, а не модульный fallback. + +## Implementation + +Добавить RNG во владение `GameSession` и передать его в `Round`, затем использовать при построении всех `ActionContext` и при инициативе. Перенести чтение/инициализацию `DND_DICE_SEED` к созданию сессии вместо мутации module-global состояния в API lifespan. В `commands_save.py` и autosave restore сериализовать и восстанавливать состояние `session.dice_rng`. Не менять чистые правила: их существующий явный параметр `rng` остаётся точкой инъекции. + +Ключевые файлы: `service/session.py`, `service/game_service.py`, `round.py`, `service/commands_save.py`, `adapters/api/app.py`, тесты dice/save/session lifecycle. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Runtime `Round` не вызывает `get_global_rng()` +- [ ] Броски одной сессии не меняют dice-последовательность другой +- [ ] Save/load продолжает dice-последовательность своей сессии + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md b/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md new file mode 100644 index 00000000..68f4212b --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md @@ -0,0 +1,37 @@ +# Task: World-state mutation gate + +**Date:** 2026-07-10 +**Sprint:** 022-intents-travel +**Phase:** 1 — Safe session lifecycle + +## Description + +Ввести отдельную session-level критическую секцию для состояния мира. Раундовые мутации и получение согласованного snapshot используют один gate; listener/lifecycle lock остаётся отдельным и не меняет назначения. Gate нельзя удерживать во время блокирующего ожидания действия `PlayerBrain`, иначе autosave зависнет на всё время человеческого хода. + +Граница атомарности должна позволять сохранить мир между законченными действиями или раундами, но никогда посреди handler, реакции, activation/materialization либо `advance_time`. + +## Tests First + +- Autosave, начатый во время контролируемой мутации мира, ждёт её завершения и получает состояние после целой мутации, без смешения значений до и после. +- Autosave проходит, пока раунд припаркован в ожидании действия игрока. +- Реакция и вызвавшее её действие не дают snapshot с наполовину применённым результатом. +- Остановка раунда и конкурентный snapshot завершаются без deadlock. + +## Implementation + +Добавить в `GameSession` отдельный re-entrant world-state gate и явный API для согласованного чтения/мутации. Передать gate в `Round` либо инъецировать эквивалентный контекстный callback. Покрыть им activation/materialization, action dispatch вместе с вложенными реакциями, завершение боя и продвижение времени. Не расширять существующий `_lock`: он защищает listeners, timer и ссылки lifecycle, а его смешение с world-state создаст обратный порядок блокировок. + +Ключевые файлы: `service/session.py`, `round.py`, тесты round/session concurrency. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Snapshot невозможен посреди мутации игрового состояния +- [ ] Ожидание `PlayerBrain` не удерживает world-state gate +- [ ] Listener callbacks и stop/join не создают обратного порядка блокировок + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md b/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md new file mode 100644 index 00000000..2d7d4905 --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md @@ -0,0 +1,38 @@ +# Task: Consistent save and eviction paths + +**Date:** 2026-07-10 +**Sprint:** 022-intents-travel +**Phase:** 1 — Safe session lifecycle + +## Description + +Перевести manual save, per-action autosave, periodic autosave, shutdown autosave и empty-session evict на единый способ получения согласованного session snapshot. Snapshot включает мир и состояние session-owned dice RNG из одной критической секции. Файловая запись выполняется после получения snapshot, чтобы медленный диск не останавливал раунд. + +Параллельное удаление или выселение сессии не должно воскрешать её, падать на итерации registry либо оставлять частично записанный SaveGame. + +## Tests First + +- Manual и periodic autosave во время живого раунда сохраняют валидный `SaveGame`, который загружается и содержит согласованное состояние мира и dice RNG. +- Медленная запись в `SaveStore` не удерживает world-state gate: после построения snapshot раунд может продолжить работу. +- Одновременные autosave и empty-session evict создают не более одного корректного autosave и не воскрешают удалённую сессию. +- Ошибка одной сессии в `autosave_all_sessions` не мешает сохранению остальных. + +## Implementation + +Сделать единый snapshot-builder на границе `GameSession`/`SaveCommands`; `_build_save_game` не должен читать изменяемые `session.world` и RNG раздельно. Все save entry points используют готовый immutable/Pydantic snapshot, затем вызывают store вне gate. Укрепить итерацию активных сессий и evict-путь против конкурентного удаления, сохранив существующее логирование ошибок. + +Ключевые файлы: `service/commands_save.py`, `service/game_service.py`, `service/session.py`, `adapters/api/app.py`, тесты commands_save/periodic_autosave/autosave_error_logging. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Все точки save/autosave используют один согласованный snapshot path +- [ ] Мир и dice RNG попадают в snapshot под одной критической секцией +- [ ] Store I/O не выполняется под world-state gate +- [ ] Concurrent evict/delete не воскрешает сессию + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md b/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md new file mode 100644 index 00000000..6d1dc226 --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md @@ -0,0 +1,39 @@ +# Task: Atomic load and connection-driven resume + +**Date:** 2026-07-10 +**Sprint:** 022-intents-travel +**Phase:** 1 — Safe session lifecycle + +## Description + +Сделать load атомарной сменой состояния сессии. Живой round loop сначала полностью останавливается, затем мир, dice RNG и brains восстанавливаются под world-state gate. Загруженный бой не исполняет ходы NPC в фоне до подключения игрока и не отправляет события в старый listener из состояния до load. + +После следующего player connection обычный idempotent `start_round` продолжает игру из сохранённого состояния. Spectator не запускает раунд. Контракт применяется и к явному load в существующую сессию, и к восстановлению session autosave из registry. + +## Tests First + +- Сейв боя на Round 1 загружается после расхождения мира и остаётся на Round 1, пока player listener отсутствует; spectator также не сдвигает бой. +- После подключения игрока round запускается один раз и ждёт сохранённого хода игрока, не прокручивая промежуточные раунды. +- Load во время живого round thread не оставляет старый thread/brain и не применяет действие, ожидавшее в старой очереди, к новому миру. +- Autosave restore создаёт паузированную сессию и начинает симуляцию только через player WebSocket lifecycle. + +## Implementation + +Скоординировать `SaveCommands.load_game`, `GameSession.stop_round`, listener lifecycle и `_try_restore_session`. Перед заменой состояния дождаться завершения старого round thread, очистить cached turn/reaction state, атомарно загрузить world + session RNG, затем переназначить brains. Не запускать round из load/restore; единственная точка старта остаётся player WebSocket connection после регистрации listener. + +Ключевые файлы: `service/commands_save.py`, `service/session.py`, `service/game_service.py`, `adapters/api/routes_ws.py`, unit/integration tests session lifecycle и save round-trip. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Старый round thread полностью завершён до загрузки snapshot +- [ ] Загруженный бой не меняется без player listener +- [ ] Spectator не запускает загруженный раунд +- [ ] Player connection запускает ровно один round loop из сохранённого состояния +- [ ] Mid-combat save → load → reconnect проходит интеграционно + +## Status + +`pending` From 080c24fcb4efbcc629ca37576630362480f016ad Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:35:48 +0300 Subject: [PATCH 03/33] sprint 022 phase 1 task 1: own dice RNG per session --- docs/STATUS.md | 2 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../tasks/phase1-task1-session-dice-rng.md | 11 ++++++++- src/dnd_simulator/adapters/api/app.py | 6 ----- .../layers/entities/combat_manager.py | 8 +++++-- .../layers/entities/combat_resolution.py | 3 ++- src/dnd_simulator/layers/entities/layer.py | 3 ++- src/dnd_simulator/round.py | 10 ++++---- .../rules/handlers/attack_resolution.py | 6 +++-- src/dnd_simulator/service/commands_save.py | 5 ++-- src/dnd_simulator/service/game_service.py | 11 +++++++-- src/dnd_simulator/service/session.py | 4 +++- tests/unit/test_check_reactions.py | 2 ++ tests/unit/test_commands_save.py | 24 ++++++++++++++----- 14 files changed, 66 insertions(+), 31 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index cb5baab8..529258a7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,7 +12,7 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 1 — Safe session lifecycle (tasks generated) — 2026-07-10 +**Phase:** 1 — Safe session lifecycle (task 1 done, task 2 pending) — 2026-07-10 Ready to start task 1. diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 64bfae12..41406910 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -61,7 +61,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Planning complete. Ready to generate Phase 1 tasks. +**Current:** Phase 1 task 1 complete. Task 2 pending. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md b/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md index ec6e101c..122c95d5 100644 --- a/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md +++ b/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md @@ -33,4 +33,13 @@ ## Status -`pending` +`done` + +## Developer Notes + +`GameSession` теперь владеет отдельным `random.Random`, инициализированным из `DND_DICE_SEED`. Тот же объект +используют `Round`, initiative, battle-map placement и attack resolution. Save/load и восстановление autosave +сохраняют состояние RNG конкретной сессии. Глобальный RNG остался fallback для изолированных rules-тестов. + +Существующий helper reaction-тестов дополнен явным RNG, потому что он создаёт `Round` через `__new__` в обход +runtime-конструктора. diff --git a/src/dnd_simulator/adapters/api/app.py b/src/dnd_simulator/adapters/api/app.py index 143977bb..fab8c1bf 100644 --- a/src/dnd_simulator/adapters/api/app.py +++ b/src/dnd_simulator/adapters/api/app.py @@ -85,12 +85,6 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]: log_dir_raw = os.getenv("LOG_DIR") configure_logging(log_level=log_level, log_dir=Path(log_dir_raw) if log_dir_raw else None) - dice_seed = os.getenv("DND_DICE_SEED") - if dice_seed is not None: - from dnd_simulator.rules.dice import set_global_seed - - set_global_seed(int(dice_seed)) - store = JsonFileStore(DEFAULT_SAVES_DIR) llm: LlmClient | None = None diff --git a/src/dnd_simulator/layers/entities/combat_manager.py b/src/dnd_simulator/layers/entities/combat_manager.py index 146d817c..4e3e7b8d 100644 --- a/src/dnd_simulator/layers/entities/combat_manager.py +++ b/src/dnd_simulator/layers/entities/combat_manager.py @@ -2,6 +2,8 @@ from __future__ import annotations +import random + import structlog from dnd_simulator.core.character import Creature, Entity @@ -34,6 +36,7 @@ def __init__( entities: dict[str, Entity], location_log: dict[str, list[Event]], battle_map_configs: dict[str, BattleMap] | None = None, + rng: random.Random | None = None, ) -> None: self._entities = entities self._location_log = location_log @@ -41,6 +44,7 @@ def __init__( self._attack_this_round: dict[str, bool] = {} self._sneak_attack_used: set[str] = set() # creature IDs that used SA this round self._battle_map_configs: dict[str, BattleMap] = battle_map_configs or {} + self._rng = rng or random.Random() def get_combat_locations(self) -> list[str]: """Return location IDs with active combats.""" @@ -60,7 +64,7 @@ def start_combat( creatures = self._active_creatures_at_location(location_id) if len(creatures) < 2: return None - ordered = roll_initiative(creatures) + ordered = roll_initiative(creatures, rng=self._rng) if location_id in self._battle_map_configs: template = self._battle_map_configs[location_id] @@ -74,7 +78,7 @@ def start_combat( fixed_ids.add(c.id) remaining = [c.id for c in creatures if c.id not in fixed_ids] if remaining: - battle_map.place_randomly(remaining) + battle_map.place_randomly(remaining, rng=self._rng) combat = CombatState( location_id=location_id, diff --git a/src/dnd_simulator/layers/entities/combat_resolution.py b/src/dnd_simulator/layers/entities/combat_resolution.py index f4b9371e..52fee3d9 100644 --- a/src/dnd_simulator/layers/entities/combat_resolution.py +++ b/src/dnd_simulator/layers/entities/combat_resolution.py @@ -118,7 +118,7 @@ def resolve_attack(mgr: CombatManager, event: Event, query_fn: QueryFn | None = attack = get_weapon_attack(attacker) atk_mods = attack_modifiers(attacker, target, melee=attack.reach <= 10) - rolled_dice, dice_total = roll_attack_dice(atk_mods) + rolled_dice, dice_total = roll_attack_dice(atk_mods, rng=mgr._rng) modifier = atk_mods.modifier + dice_total ally_adjacent = False @@ -177,6 +177,7 @@ def resolve_attack(mgr: CombatManager, event: Event, query_fn: QueryFn | None = disadvantage=atk_mods.disadvantage, force_crit=atk_mods.force_crit, gwf_reroll=atk_mods.gwf_reroll, + rng=mgr._rng, ) logger.info( "attack_result", diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index d5ec41cd..0c3abc1f 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -82,6 +82,7 @@ def __init__( monster_templates: dict[str, MonsterTemplate] | None = None, encounter_tables: dict[str, list[EncounterEntry]] | None = None, seed: int | None = None, + dice_rng: random.Random | None = None, ) -> None: self._entities: dict[str, Entity] = {} self._location_log: dict[str, list[Event]] = defaultdict(list) @@ -99,7 +100,7 @@ def __init__( if entities: for e in entities: self._entities[e.id] = e - self._combat = CombatManager(self._entities, self._location_log, battle_map_configs) + self._combat = CombatManager(self._entities, self._location_log, battle_map_configs, rng=dice_rng) self._awareness = AwarenessBuilder(self._entities, self._location_log, self._combat) self._activation = ActivationManager( self._entities, diff --git a/src/dnd_simulator/round.py b/src/dnd_simulator/round.py index 017bca99..6d66f6f3 100644 --- a/src/dnd_simulator/round.py +++ b/src/dnd_simulator/round.py @@ -7,6 +7,7 @@ from __future__ import annotations +import random from collections.abc import Callable from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING @@ -32,7 +33,6 @@ get_num_bonus_actions, ) from dnd_simulator.rules.conditions import is_incapacitated, tick_conditions -from dnd_simulator.rules.dice import get_global_rng from dnd_simulator.rules.modifiers import effective_speed from dnd_simulator.rules.movement import resolve_abstract_move from dnd_simulator.rules.validation import ActionContext @@ -67,6 +67,7 @@ def __init__( world: World, creature_host: CreatureHost | None = None, dispatcher: ActionDispatcher | None = None, + rng: random.Random | None = None, ) -> None: self._world = world self._host = creature_host or world.creature_host @@ -75,6 +76,7 @@ def __init__( dispatcher = create_dispatcher(world) self._dispatcher = dispatcher + self._rng = rng or random.Random() self._stop_flag = False self._on_round_end: Callable[[RoundResult], None] | None = None self._on_action: OnActionCallback | None = None @@ -186,7 +188,7 @@ def _prepare_combat_turn( turn_budget=creature.turn_budget, combat_state=combat_state, get_entity=self._host.get_entity, - rng=get_global_rng(), + rng=self._rng, ) def _build_combat_awareness( @@ -306,7 +308,7 @@ def run_peaceful_turn( is_combat=False, current_turn_entity_id=creature.id, get_entity=self._host.get_entity, - rng=get_global_rng(), + rng=self._rng, ) while True: @@ -387,7 +389,7 @@ def check_reactions( turn_budget=creature.turn_budget, combat_state=combat_state, get_entity=self._host.get_entity, - rng=get_global_rng(), + rng=self._rng, ) result = self._execute_action(creature, action, ctx, _emit) if result.success: diff --git a/src/dnd_simulator/rules/handlers/attack_resolution.py b/src/dnd_simulator/rules/handlers/attack_resolution.py index d81dea2c..2cfd82f3 100644 --- a/src/dnd_simulator/rules/handlers/attack_resolution.py +++ b/src/dnd_simulator/rules/handlers/attack_resolution.py @@ -6,6 +6,8 @@ from __future__ import annotations +import random + import structlog from dnd_simulator.core.character import Attack, Creature @@ -93,7 +95,7 @@ def build_damage_components( return components -def roll_attack_dice(atk_mods: AttackModifiers) -> tuple[list[RollComponent], int]: +def roll_attack_dice(atk_mods: AttackModifiers, *, rng: random.Random | None = None) -> tuple[list[RollComponent], int]: """Roll dice bonuses (Bless +1d4, etc.). Returns (rolled_components, total).""" rolled_dice: list[RollComponent] = [] dice_total = 0 @@ -102,7 +104,7 @@ def roll_attack_dice(atk_mods: AttackModifiers) -> tuple[list[RollComponent], in for rc in atk_mods.roll_components: if rc.dice: - rolled_value = roll_dice_fn(rc.dice).total + rolled_value = roll_dice_fn(rc.dice, rng=rng).total rolled_dice.append(RollComponent(source=rc.source, value=rolled_value, dice=rc.dice)) dice_total += rolled_value return rolled_dice, dice_total diff --git a/src/dnd_simulator/service/commands_save.py b/src/dnd_simulator/service/commands_save.py index 09c33ae3..bafd8c39 100644 --- a/src/dnd_simulator/service/commands_save.py +++ b/src/dnd_simulator/service/commands_save.py @@ -4,7 +4,6 @@ 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 @@ -18,7 +17,7 @@ class SaveCommands(GameServiceProtocol): 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()) + world_data["dice_rng_state"] = dump_rng_state(session.dice_rng) return SaveGame.model_validate( { "schema_version": SCHEMA_VERSION, @@ -68,7 +67,7 @@ def load_game(self, session_id: str, name: str) -> None: data = self._store.load(name, world=session.world_name) save = self._validate_save(data) - load_rng_state(get_global_rng(), save.world.dice_rng_state) + load_rng_state(session.dice_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) diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index eb886eb7..188ce39c 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -70,6 +70,11 @@ def _derive_layer_seeds(world_seed: int) -> dict[str, int]: return {layer_name: rng.getrandbits(64) for layer_name in _LAYER_SEED_ORDER} +def _create_dice_rng() -> random.Random: + raw = os.getenv("DND_DICE_SEED") + return random.Random(int(raw)) if raw is not None else random.Random() + + def _flatten_region_defaults[T]( locations: list[Location], by_region: dict[str, T], @@ -131,6 +136,7 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe meta = load_world_meta_from_manifest(world_path, lang=lang) world_seed = _resolve_world_seed() layer_seeds = _derive_layer_seeds(world_seed) + dice_rng = _create_dice_rng() regions = load_world(layer_paths["geography"], lang=lang) nations = load_nations(layer_paths["politics"], lang=lang) settlements = load_settlements(layer_paths["settlements"], lang=lang) @@ -209,6 +215,7 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe encounter_tables=effective_encounters, battle_map_configs=battle_map_configs, seed=layer_seeds["entities"], + dice_rng=dice_rng, ) # Assign brains via factory (content_loader only parses data, not brains) @@ -230,6 +237,7 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe lang=lang, world_name=world_name, default_player_faction=meta.get("default_player_faction", ""), + dice_rng=dice_rng, ) session._on_empty = self._on_session_empty self._sessions[session_id] = session @@ -343,7 +351,6 @@ def _try_restore_session(self, session_id: str) -> None: return 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: @@ -367,7 +374,7 @@ def _try_restore_session(self, session_id: str) -> None: del self._sessions[session.session_id] session.session_id = session_id - load_rng_state(get_global_rng(), save.world.dice_rng_state) + load_rng_state(session.dice_rng, save.world.dice_rng_state) session.world.load(save.world.to_world_dict()) self._assign_brains(self._get_entities_layer(session)) diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index a750d14c..07f1035e 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -4,6 +4,7 @@ import contextvars import dataclasses import os +import random import threading from collections.abc import Callable from dataclasses import dataclass, field @@ -268,6 +269,7 @@ class GameSession: lang: str = "en" world_name: str = "" default_player_faction: str = "" + dice_rng: random.Random = field(default_factory=random.Random, repr=False) # Round lifecycle (managed, not serialized) _round: Round | None = field(default=None, init=False, repr=False) @@ -508,7 +510,7 @@ def on_reaction( player.brain = brain dispatcher = create_dispatcher(self.world) - game_round = Round(self.world, creature_host, dispatcher=dispatcher) + game_round = Round(self.world, creature_host, dispatcher=dispatcher, rng=self.dice_rng) # Wire on_action: fires after each action by any creature def on_action(creature: Creature, action: Action, budget: TurnBudget | None, error: str) -> None: diff --git a/tests/unit/test_check_reactions.py b/tests/unit/test_check_reactions.py index 377a10cc..1855b497 100644 --- a/tests/unit/test_check_reactions.py +++ b/tests/unit/test_check_reactions.py @@ -6,6 +6,7 @@ from __future__ import annotations +import random from unittest.mock import MagicMock from dnd_simulator.core.action import Action, ActionType @@ -67,6 +68,7 @@ def _make_round(creatures: list[Creature]) -> object: r._world = world r._host = entities r._dispatcher = dispatcher + r._rng = random.Random(0) r._on_action = None return r diff --git a/tests/unit/test_commands_save.py b/tests/unit/test_commands_save.py index 699cdafb..c69abaf1 100644 --- a/tests/unit/test_commands_save.py +++ b/tests/unit/test_commands_save.py @@ -14,7 +14,7 @@ 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.dice import roll from dnd_simulator.rules.rule_brain import RuleBrain from dnd_simulator.service import GameService from dnd_simulator.storage.store import JsonFileStore @@ -69,15 +69,27 @@ 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") + session.dice_rng.seed(77) + roll("1d20", rng=session.dice_rng) svc.save_game(sid, "dice") - expected = roll("1d20").total - roll("1d20") + expected = roll("1d20", rng=session.dice_rng).total + roll("1d20", rng=session.dice_rng) svc.load_game(sid, "dice") - assert roll("1d20").total == expected + assert roll("1d20", rng=session.dice_rng).total == expected + + def test_sessions_own_independent_dice_sequences(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setenv("DND_DICE_SEED", "91") + svc = _make_service(tmp_path) + first = svc.start_game("sword_vale") + second = svc.start_game("sword_vale") + + first_initial = roll("1d20", rng=first.dice_rng).total + roll("5d20", rng=first.dice_rng) + second_initial = roll("1d20", rng=second.dice_rng).total + + assert first_initial == second_initial def test_state_restored_to_saved_snapshot(self, tmp_path: Path) -> None: svc = _make_service(tmp_path) From 687233b25822bf60b2c882b134171fc7e691f5a7 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:44:15 +0300 Subject: [PATCH 04/33] sprint 022 phase 1 task 2: gate world mutations --- docs/STATUS.md | 6 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../tasks/phase1-task2-world-state-gate.md | 12 +- src/dnd_simulator/round.py | 110 +++++++------ src/dnd_simulator/service/commands_save.py | 5 +- src/dnd_simulator/service/session.py | 23 ++- tests/unit/test_check_reactions.py | 2 + tests/unit/test_world_state_gate.py | 154 ++++++++++++++++++ 8 files changed, 256 insertions(+), 58 deletions(-) create mode 100644 tests/unit/test_world_state_gate.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 529258a7..dc6d8b60 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -4,7 +4,7 @@ **Last updated:** 2026-07-10 **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` планировать только после триггеров и целей. +**Next:** Sprint 022 исполняет цепочку simulation-core: safe session lifecycle → anchors/intents → travel → interruptible journeys. **Blockers:** нет. ## Current Sprint @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 1 — Safe session lifecycle (task 1 done, task 2 pending) — 2026-07-10 +**Phase:** 1 — Safe session lifecycle (task 2 done, task 3 pending) — 2026-07-10 -Ready to start task 1. +Ready to start task 3. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 41406910..9fd68417 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -61,7 +61,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 1 task 1 complete. Task 2 pending. +**Current:** Phase 1 tasks 1-2 complete. Task 3 pending. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md b/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md index 68f4212b..b9b041bf 100644 --- a/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md +++ b/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md @@ -34,4 +34,14 @@ ## Status -`pending` +`done` + +## Developer Notes + +`GameSession` получил отдельный re-entrant world-state lock с явными `read_world()` и `mutate_world()` scopes. +`Round` получает mutation scope через инъекцию и удерживает его только на activation/materialization, подготовке +хода, action dispatch с вложенными реакциями, завершении боя и продвижении времени. Ожидание `PlayerBrain` и +listener callbacks выполняются вне gate. Save snapshot читает world и dice RNG под тем же lock. + +Существующий reaction helper создаёт `Round` через `__new__`, поэтому в нём добавлен no-op mutation scope для +нового обязательного runtime-состояния. diff --git a/src/dnd_simulator/round.py b/src/dnd_simulator/round.py index 6d66f6f3..334ef9cc 100644 --- a/src/dnd_simulator/round.py +++ b/src/dnd_simulator/round.py @@ -9,6 +9,7 @@ import random from collections.abc import Callable +from contextlib import AbstractContextManager, nullcontext from dataclasses import dataclass, field, replace from typing import TYPE_CHECKING @@ -68,6 +69,7 @@ def __init__( creature_host: CreatureHost | None = None, dispatcher: ActionDispatcher | None = None, rng: random.Random | None = None, + mutation_scope: Callable[[], AbstractContextManager[None]] | None = None, ) -> None: self._world = world self._host = creature_host or world.creature_host @@ -77,6 +79,7 @@ def __init__( dispatcher = create_dispatcher(world) self._dispatcher = dispatcher self._rng = rng or random.Random() + self._mutation_scope = mutation_scope or nullcontext self._stop_flag = False self._on_round_end: Callable[[RoundResult], None] | None = None self._on_action: OnActionCallback | None = None @@ -106,7 +109,8 @@ def _execute_action( emit_fn: EmitFn, ) -> ActionResult: """Execute action via dispatcher. Validates, checks budget, executes, consumes budget.""" - return self._dispatcher.dispatch(creature, action, ctx, emit_fn) + with self._mutation_scope(): + return self._dispatcher.dispatch(creature, action, ctx, emit_fn) def get_perceived_events(self, creature: Creature) -> list[PerceivedEvent]: """Return perceived events for a creature (delegates to CreatureHost).""" @@ -139,23 +143,24 @@ def _prepare_combat_turn( Returns an ActionContext if the creature can act, or None if the turn is skipped. """ - self._host.reset_combat_turn_state(creature.id) - - expired = tick_conditions(creature.conditions) - if expired: - logger.info("conditions_expired", conditions=[c.value for c in expired]) - - if is_incapacitated(creature.conditions): - logger.info("turn_skipped_incapacitated") - reasons = sorted(c.value for c in creature.conditions if is_incapacitated({c: None})) - emit_fn( - Event( - event_type=EventType.TURN_SKIPPED, - source_layer="entities", - data={"entity_id": creature.id, "reason": "incapacitated", "conditions": reasons}, + with self._mutation_scope(): + self._host.reset_combat_turn_state(creature.id) + + expired = tick_conditions(creature.conditions) + if expired: + logger.info("conditions_expired", conditions=[c.value for c in expired]) + + if is_incapacitated(creature.conditions): + logger.info("turn_skipped_incapacitated") + reasons = sorted(c.value for c in creature.conditions if is_incapacitated({c: None})) + emit_fn( + Event( + event_type=EventType.TURN_SKIPPED, + source_layer="entities", + data={"entity_id": creature.id, "reason": "incapacitated", "conditions": reasons}, + ) ) - ) - return None + return None weapon_info = "fists" if creature.equipped_weapon and creature.equipped_weapon.weapon_def: @@ -165,21 +170,22 @@ def _prepare_combat_turn( conds_info = {c.value: r for c, r in creature.conditions.items()} if creature.conditions else {} logger.debug("combat_turn_start", weapon=weapon_info, conditions=conds_info) - speed = effective_speed(creature) - if speed != creature.speed: - logger.debug( - "speed_reduced", - base_speed=creature.speed, - effective_speed=speed, - conditions=[c.value for c in creature.conditions], + with self._mutation_scope(): + speed = effective_speed(creature) + if speed != creature.speed: + logger.debug( + "speed_reduced", + base_speed=creature.speed, + effective_speed=speed, + conditions=[c.value for c in creature.conditions], + ) + creature.turn_budget = TurnBudget( + actions=get_num_actions(creature), + bonus_actions=get_num_bonus_actions(creature), + movement_remaining=speed, + reaction=1, ) - creature.turn_budget = TurnBudget( - actions=get_num_actions(creature), - bonus_actions=get_num_bonus_actions(creature), - movement_remaining=speed, - reaction=1, - ) - creature.is_disengaging = False + creature.is_disengaging = False combat_state = self._host.get_combat(creature.location_id) return ActionContext( @@ -234,7 +240,8 @@ def run_combat_turn( if creature.brain is None: return [] - ctx = self._prepare_combat_turn(creature, emit_fn) + with self._mutation_scope(): + ctx = self._prepare_combat_turn(creature, emit_fn) if ctx is None: return [] @@ -470,11 +477,13 @@ def run_round(self, *, skip_activation: bool = False) -> RoundResult: for entity_id in list(combat.turn_order): entity = self._host.get_entity(entity_id) if isinstance(entity, Creature) and entity.is_alive and entity.active and entity.in_combat: - entity.is_dodging = False # dodge lasts until start of next turn - entity.is_disengaging = False + with self._mutation_scope(): + entity.is_dodging = False # dodge lasts until start of next turn + entity.is_disengaging = False self.run_creature_turn(entity, time, query_fn, emit_fn) # End of round — check for combat exit - self._host.end_combat_round(location_id) + with self._mutation_scope(): + self._host.end_combat_round(location_id) # Peaceful turns: creatures not in combat for creature in self._host.get_active_creatures(): @@ -483,7 +492,8 @@ def run_round(self, *, skip_activation: bool = False) -> RoundResult: self.run_creature_turn(creature, time, query_fn, emit_fn) # Advance time by one round (6 seconds) - tick_events = self._world.advance_time(TimeDelta.from_rounds(1)) + with self._mutation_scope(): + tick_events = self._world.advance_time(TimeDelta.from_rounds(1)) logger.info("round_end", game_time=str(self._world.time), tick_events=len(tick_events)) return RoundResult(events=tick_events) @@ -492,7 +502,8 @@ def _activate(self) -> None: """Update activation with materialization support.""" qfn = self._world.make_query_fn("entities") efn = self._world.make_emit_fn("entities") - self._host.update_activation(self._world.time, query_fn=qfn, emit_fn=efn) + with self._mutation_scope(): + self._host.update_activation(self._world.time, query_fn=qfn, emit_fn=efn) def run_loop(self, max_rounds: int | None = None) -> None: """Run rounds until no active creatures remain or stop() is called.""" @@ -528,21 +539,22 @@ def _fast_forward(self) -> bool: Returns True if time was advanced (loop should continue), False if there's nobody to wake up (loop should exit). """ - nearest_wake = self._host.get_nearest_wake_time() + with self._mutation_scope(): + nearest_wake = self._host.get_nearest_wake_time() - if nearest_wake is None: - return False + if nearest_wake is None: + return False - now = self._world.time.to_total_seconds() - delta_seconds = nearest_wake - now - if delta_seconds <= 0: - # Timer already expired — just run update_activation - self._activate() - return True + now = self._world.time.to_total_seconds() + delta_seconds = nearest_wake - now + if delta_seconds <= 0: + # Timer already expired — just run update_activation + self._activate() + return True - logger.info("fast_forward", delta_seconds=delta_seconds) - tick_events = self._world.advance_time(TimeDelta(seconds=delta_seconds)) - self._activate() + logger.info("fast_forward", delta_seconds=delta_seconds) + tick_events = self._world.advance_time(TimeDelta(seconds=delta_seconds)) + self._activate() if self._on_round_end: self._on_round_end(RoundResult(events=tick_events)) diff --git a/src/dnd_simulator/service/commands_save.py b/src/dnd_simulator/service/commands_save.py index bafd8c39..fa061ff9 100644 --- a/src/dnd_simulator/service/commands_save.py +++ b/src/dnd_simulator/service/commands_save.py @@ -16,8 +16,9 @@ class SaveCommands(GameServiceProtocol): 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(session.dice_rng) + with session.read_world(): + world_data = session.world.save() + world_data["dice_rng_state"] = dump_rng_state(session.dice_rng) return SaveGame.model_validate( { "schema_version": SCHEMA_VERSION, diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index 07f1035e..545d2497 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -6,7 +6,7 @@ import os import random import threading -from collections.abc import Callable +from collections.abc import Callable, Iterator from dataclasses import dataclass, field from typing import Any, Protocol @@ -281,6 +281,7 @@ class GameSession: _spectators: list[SessionEventListener] = field(default_factory=list, init=False, repr=False) _last_turn_msg: dict[str, Any] | None = field(default=None, init=False, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) + _world_state_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False) _on_empty: Callable[[GameSession], None] | None = field(default=None, init=False, repr=False) _evict_timer: threading.Timer | None = field(default=None, init=False, repr=False) _evict_grace_seconds: float = field(default=_DEFAULT_EVICT_GRACE_SECONDS, init=False, repr=False) @@ -289,6 +290,18 @@ class GameSession: # Player queries # --------------------------------------------------------------------------- + @contextlib.contextmanager + def read_world(self) -> Iterator[None]: + """Hold the session gate while reading a consistent world snapshot.""" + with self._world_state_lock: + yield + + @contextlib.contextmanager + def mutate_world(self) -> Iterator[None]: + """Hold the re-entrant session gate while mutating world state.""" + with self._world_state_lock: + yield + def get_players(self) -> list[PlayerCharacter]: return query_players(self.world.query_layer) @@ -510,7 +523,13 @@ def on_reaction( player.brain = brain dispatcher = create_dispatcher(self.world) - game_round = Round(self.world, creature_host, dispatcher=dispatcher, rng=self.dice_rng) + game_round = Round( + self.world, + creature_host, + dispatcher=dispatcher, + rng=self.dice_rng, + mutation_scope=self.mutate_world, + ) # Wire on_action: fires after each action by any creature def on_action(creature: Creature, action: Action, budget: TurnBudget | None, error: str) -> None: diff --git a/tests/unit/test_check_reactions.py b/tests/unit/test_check_reactions.py index 1855b497..88baa828 100644 --- a/tests/unit/test_check_reactions.py +++ b/tests/unit/test_check_reactions.py @@ -7,6 +7,7 @@ from __future__ import annotations import random +from contextlib import nullcontext from unittest.mock import MagicMock from dnd_simulator.core.action import Action, ActionType @@ -69,6 +70,7 @@ def _make_round(creatures: list[Creature]) -> object: r._host = entities r._dispatcher = dispatcher r._rng = random.Random(0) + r._mutation_scope = nullcontext r._on_action = None return r diff --git a/tests/unit/test_world_state_gate.py b/tests/unit/test_world_state_gate.py new file mode 100644 index 00000000..25c962c6 --- /dev/null +++ b/tests/unit/test_world_state_gate.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import threading +from contextlib import contextmanager +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock + +from dnd_simulator.core.action import Action, ActionType +from dnd_simulator.core.character import Creature +from dnd_simulator.core.models import ActionResult, TimeDelta +from dnd_simulator.core.reactions import ReactionOption, ReactionTrigger, TriggerType +from dnd_simulator.core.turn_budget import TurnBudget +from dnd_simulator.round import Round +from dnd_simulator.rules.rule_brain import RuleBrain +from dnd_simulator.rules.validation import ActionContext +from dnd_simulator.service import GameService +from dnd_simulator.storage.store import JsonFileStore + +_FIGHTER_DATA = { + "name": "Thrain", + "race": "dwarf", + "class": "fighter", + "alignment": "lawful_good", + "ability_scores": {"str": 15, "dex": 10, "con": 14, "int": 8, "wis": 12, "cha": 8}, + "fighting_style": "defense", +} + + +def _service(tmp_path: Path) -> GameService: + return GameService(store=JsonFileStore(tmp_path / "saves")) + + +def test_save_waits_for_complete_world_mutation(tmp_path: Path) -> None: + service = _service(tmp_path) + session = service.start_game("sword_vale") + mutation_started = threading.Event() + release_mutation = threading.Event() + save_done = threading.Event() + saved: list[Any] = [] + + def mutate() -> None: + with session.mutate_world(): + session.world.advance_time(TimeDelta(seconds=6)) + mutation_started.set() + assert release_mutation.wait(timeout=2) + + def snapshot() -> None: + saved.append(service._build_save_game(session.session_id)) + save_done.set() + + mutation_thread = threading.Thread(target=mutate) + mutation_thread.start() + assert mutation_started.wait(timeout=2) + save_thread = threading.Thread(target=snapshot) + save_thread.start() + + assert not save_done.wait(timeout=0.05) + release_mutation.set() + mutation_thread.join(timeout=2) + save_thread.join(timeout=2) + + assert save_done.is_set() + assert saved[0].world.time == session.world.time.to_dict() + + +def test_snapshot_is_available_while_player_brain_waits(tmp_path: Path) -> None: + service = _service(tmp_path) + session = service.start_game("sword_vale") + service.create_player(session.session_id, _FIGHTER_DATA) + player = session.get_player() + assert player is not None + session.start_round(player) + + snapshot_acquired = threading.Event() + + def snapshot() -> None: + with session.read_world(): + snapshot_acquired.set() + + thread = threading.Thread(target=snapshot) + thread.start() + try: + assert snapshot_acquired.wait(timeout=2) + finally: + session.stop_round() + thread.join(timeout=2) + + +def test_action_and_nested_reaction_use_one_reentrant_gate() -> None: + lock = threading.RLock() + depth = 0 + observed_depths: list[int] = [] + + @contextmanager + def mutation_scope() -> Any: + nonlocal depth + with lock: + depth += 1 + try: + yield + finally: + depth -= 1 + + reactor = Creature(id="guard", name="Guard", location_id="arena") + reactor.brain = RuleBrain() + reactor.turn_budget = TurnBudget(actions=1, bonus_actions=0, movement_remaining=30, reaction=1) + host = MagicMock() + host.get_entity.return_value = reactor + host.get_combat.return_value = None + dispatcher = MagicMock() + game_round = Round(MagicMock(), host, dispatcher=dispatcher, mutation_scope=mutation_scope) + nested = False + + def dispatch(*args: object) -> ActionResult: + nonlocal nested + observed_depths.append(depth) + if not nested: + nested = True + game_round.check_reactions( + ReactionTrigger(trigger_type=TriggerType.LEAVING_REACH, source_creature_id="mover"), + [ReactionOption(action_type=ActionType.OPPORTUNITY_ATTACK, description="OA")], + [reactor], + ) + return ActionResult() + + dispatcher.dispatch.side_effect = dispatch + game_round._execute_action( + reactor, + Action(name=ActionType.DODGE), + ActionContext(is_combat=True), + lambda event: ActionResult(), + ) + + assert observed_depths == [1, 2] + + +def test_stop_round_and_concurrent_snapshot_finish_without_deadlock(tmp_path: Path) -> None: + service = _service(tmp_path) + session = service.start_game("sword_vale") + service.create_player(session.session_id, _FIGHTER_DATA) + player = session.get_player() + assert player is not None + session.start_round(player) + + snapshot_thread = threading.Thread(target=lambda: service._build_save_game(session.session_id)) + stop_thread = threading.Thread(target=session.stop_round) + snapshot_thread.start() + stop_thread.start() + snapshot_thread.join(timeout=2) + stop_thread.join(timeout=2) + + assert not snapshot_thread.is_alive() + assert not stop_thread.is_alive() From 1fd8c2d39e8698d305e41c707fad3a7201521558 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:17:02 +0300 Subject: [PATCH 05/33] sprint 022 phase 1 task 3: unify save snapshots --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../phase1-task3-consistent-save-paths.md | 13 ++- src/dnd_simulator/service/base.py | 2 + src/dnd_simulator/service/commands_save.py | 40 ++++----- src/dnd_simulator/service/game_service.py | 59 ++++++++----- src/dnd_simulator/service/session.py | 26 +++++- src/dnd_simulator/storage/store.py | 17 +++- tests/unit/test_autosave_all.py | 19 ++-- tests/unit/test_autosave_error_logging.py | 88 +++++++++++++++++-- tests/unit/test_world_state_gate.py | 31 +++++++ 11 files changed, 236 insertions(+), 65 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index dc6d8b60..c2c14742 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 1 — Safe session lifecycle (task 2 done, task 3 pending) — 2026-07-10 +**Phase:** 1 — Safe session lifecycle (task 3 done, task 4 pending) — 2026-07-10 -Ready to start task 3. +Ready to start task 4. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 9fd68417..58eb9933 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -61,7 +61,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 1 tasks 1-2 complete. Task 3 pending. +**Current:** Phase 1 tasks 1-3 complete. Task 4 pending. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md b/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md index 2d7d4905..d9e613e5 100644 --- a/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md +++ b/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md @@ -35,4 +35,15 @@ ## Status -`pending` +`done` + +## Developer Notes + +`GameSession.build_save_game()` стал единственным builder для согласованного Pydantic snapshot мира и dice RNG. +Manual save и все autosave paths получают готовый snapshot до записи. `JsonFileStore` пишет во временный файл, +делает `fsync` и атомарно заменяет целевой JSON. + +Реестр сессий защищён отдельным `RLock`. Autosave проверяет identity активной сессии перед записью, concurrent +evict выполняется один раз, а explicit delete удаляет session autosave и не оставляет источник восстановления. +Существующие autosave error tests переведены с monkeypatch публичного метода на сбой store boundary, потому что +evict теперь сохраняет уже захваченный объект сессии без повторного registry lookup. diff --git a/src/dnd_simulator/service/base.py b/src/dnd_simulator/service/base.py index 02cca1b8..68eaba0e 100644 --- a/src/dnd_simulator/service/base.py +++ b/src/dnd_simulator/service/base.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading from pathlib import Path from typing import TYPE_CHECKING, Protocol @@ -19,6 +20,7 @@ class GameServiceProtocol(Protocol): _store: SaveStore _sessions: dict[str, GameSession] + _sessions_lock: threading.RLock _brain_factory: BrainFactory _content_dir: Path diff --git a/src/dnd_simulator/service/commands_save.py b/src/dnd_simulator/service/commands_save.py index fa061ff9..add997c0 100644 --- a/src/dnd_simulator/service/commands_save.py +++ b/src/dnd_simulator/service/commands_save.py @@ -3,10 +3,10 @@ import structlog from pydantic import ValidationError -from dnd_simulator.layers.common.rng_state import dump_rng_state, load_rng_state +from dnd_simulator.layers.common.rng_state import load_rng_state 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 +from dnd_simulator.storage.save_schema import SaveGame logger = structlog.get_logger(domain="save") @@ -16,21 +16,7 @@ class SaveCommands(GameServiceProtocol): def _build_save_game(self, session_id: str) -> SaveGame: session: GameSession = self._get_session(session_id) - with session.read_world(): - world_data = session.world.save() - world_data["dice_rng_state"] = dump_rng_state(session.dice_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, - } - ) + return session.build_save_game() @staticmethod def _validate_save(data: object) -> SaveGame: @@ -50,15 +36,25 @@ def save_game(self, session_id: str, name: str | None = None) -> str: def autosave_session(self, session_id: str) -> None: """Autosave a session with metadata needed for restore.""" session: GameSession = self._get_session(session_id) - 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) + self._autosave_active_session(session) + + def _autosave_active_session(self, session: GameSession) -> bool: + """Snapshot and persist a session if the registry still owns this exact object.""" + snapshot = session.build_save_game() + data = snapshot.model_dump(mode="json", by_alias=True) + with self._sessions_lock: + if self._sessions.get(session.session_id) is not session: + return False + self._store.save(f"session_{session.session_id}", data, world=session.world_name) + return True def autosave_all_sessions(self) -> None: """Autosave all active sessions. One failing session does not block the others.""" - sessions: dict[str, GameSession] = self._sessions - for sid in list(sessions): + with self._sessions_lock: + sessions = list(self._sessions.items()) + for sid, session in sessions: try: - self.autosave_session(sid) + self._autosave_active_session(session) except Exception: logger.exception("autosave_failed", session_id=sid) diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index 188ce39c..dec9d255 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -1,7 +1,9 @@ from __future__ import annotations +import contextlib import os import random +import threading import uuid from pathlib import Path @@ -114,6 +116,7 @@ def __init__( llm: LlmClient | None = None, ) -> None: self._sessions: dict[str, GameSession] = {} + self._sessions_lock = threading.RLock() self._store = store self._content_dir = content_dir self._llm = llm @@ -240,7 +243,8 @@ def start_game(self, world_name: str = "sword_vale", lang: str = "en") -> GameSe dice_rng=dice_rng, ) session._on_empty = self._on_session_empty - self._sessions[session_id] = session + with self._sessions_lock: + self._sessions[session_id] = session return session def list_sessions(self) -> list[dict[str, str]]: @@ -248,7 +252,9 @@ def list_sessions(self) -> list[dict[str, str]]: result: dict[str, dict[str, str]] = {} # In-memory sessions - for sid, s in self._sessions.items(): + with self._sessions_lock: + active_sessions = list(self._sessions.items()) + for sid, s in active_sessions: players = s.get_players() player_name = players[0].name if players else "" result[sid] = {"session_id": sid, "player_name": player_name, "world_name": s.world_name} @@ -272,23 +278,26 @@ def get_session(self, session_id: str) -> GameSession: def delete_session(self, session_id: str) -> None: """Stop and remove a session.""" - self._get_session(session_id) - del self._sessions[session_id] + session = self._get_session(session_id) + with self._sessions_lock: + if self._sessions.get(session_id) is session: + del self._sessions[session_id] + with contextlib.suppress(KeyError): + self._store.delete(f"session_{session_id}", world=session.world_name) 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) - except Exception: - logger.exception("session_empty_autosave_failed", session_id=sid) - self._sessions.pop(sid, None) + with self._sessions_lock: + if self._sessions.get(sid) is not session: + logger.info("session_empty_evict_skipped", session_id=sid) + return + logger.info("session_empty_evict", session_id=sid) + try: + self._autosave_active_session(session) + except Exception: + logger.exception("session_empty_autosave_failed", session_id=sid) + self._sessions.pop(sid, None) # -- Layer accessors -- @@ -319,13 +328,17 @@ def _assign_brains(self, entities_layer: EntitiesLayer) -> None: def _get_session(self, session_id: str) -> GameSession: from dnd_simulator.service.errors import SessionNotFoundError - if session_id not in self._sessions: - # Try to restore from autosave on disk - self._try_restore_session(session_id) - if session_id not in self._sessions: + with self._sessions_lock: + session = self._sessions.get(session_id) + if session is None: + # Serialize restore with registry changes so concurrent callers cannot + # create two in-memory sessions from the same autosave. + self._try_restore_session(session_id) + session = self._sessions.get(session_id) + if session is None: raise SessionNotFoundError(f"Session '{session_id}' not found") structlog.contextvars.bind_contextvars(session_id=session_id) - return self._sessions[session_id] + return session def _try_restore_session(self, session_id: str) -> None: """Attempt to restore a session from its autosave file.""" @@ -371,11 +384,13 @@ def _try_restore_session(self, session_id: str) -> None: return # Reassign to the original session_id - del self._sessions[session.session_id] + with self._sessions_lock: + del self._sessions[session.session_id] session.session_id = session_id load_rng_state(session.dice_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 + with self._sessions_lock: + self._sessions[session_id] = session diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index 545d2497..5f5052dc 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -8,10 +8,13 @@ import threading from collections.abc import Callable, Iterator from dataclasses import dataclass, field -from typing import Any, Protocol +from typing import TYPE_CHECKING, Any, Protocol import structlog +if TYPE_CHECKING: + from dnd_simulator.storage.save_schema import SaveGame + from dnd_simulator.core.action import Action, ActionType from dnd_simulator.core.action_defs import get_action_def from dnd_simulator.core.awareness import CombatAwareness, PeacefulAwareness, PerceivedEvent @@ -302,6 +305,27 @@ def mutate_world(self) -> Iterator[None]: with self._world_state_lock: yield + def build_save_game(self) -> SaveGame: + """Build an immutable save snapshot under the world-state gate.""" + from dnd_simulator.layers.common.rng_state import dump_rng_state + from dnd_simulator.storage.save_schema import SCHEMA_VERSION, SaveGame, SaveMeta + + with self.read_world(): + world_data = self.world.save() + world_data["dice_rng_state"] = dump_rng_state(self.dice_rng) + return SaveGame.model_validate( + { + "schema_version": SCHEMA_VERSION, + "meta": SaveMeta( + session_id=self.session_id, + world_name=self.world_name, + lang=self.lang, + default_player_faction=self.default_player_faction, + ).model_dump(mode="json"), + "world": world_data, + } + ) + def get_players(self) -> list[PlayerCharacter]: return query_players(self.world.query_layer) diff --git a/src/dnd_simulator/storage/store.py b/src/dnd_simulator/storage/store.py index fd7bec01..6ae4bffa 100644 --- a/src/dnd_simulator/storage/store.py +++ b/src/dnd_simulator/storage/store.py @@ -1,6 +1,8 @@ from __future__ import annotations import json +import os +import tempfile from abc import ABC, abstractmethod from datetime import UTC, datetime from pathlib import Path @@ -42,8 +44,19 @@ def __init__(self, directory: Path) -> None: def save(self, name: str, data: dict[str, Any], *, world: str = "") -> None: path = self._path_for(name, world=world) path.parent.mkdir(parents=True, exist_ok=True) - with path.open("w") as f: - json.dump(data, f, indent=2, ensure_ascii=False) + temporary_path: Path | None = None + try: + with tempfile.NamedTemporaryFile( + "w", encoding="utf-8", dir=path.parent, prefix=f".{path.name}.", suffix=".tmp", delete=False + ) as file: + temporary_path = Path(file.name) + json.dump(data, file, indent=2, ensure_ascii=False) + file.flush() + os.fsync(file.fileno()) + temporary_path.replace(path) + finally: + if temporary_path is not None: + temporary_path.unlink(missing_ok=True) def load(self, name: str, *, world: str = "") -> dict[str, Any]: path = self._path_for(name, world=world) diff --git a/tests/unit/test_autosave_all.py b/tests/unit/test_autosave_all.py index dd7931eb..721f8acf 100644 --- a/tests/unit/test_autosave_all.py +++ b/tests/unit/test_autosave_all.py @@ -2,6 +2,7 @@ from __future__ import annotations +import threading from typing import Any from unittest.mock import MagicMock @@ -15,23 +16,27 @@ class _Svc(SaveCommands): def __init__(self, sessions: dict[str, Any]) -> None: self._sessions = sessions # type: ignore[assignment] + self._sessions_lock = threading.RLock() self._store = MagicMock() self.saved: list[str] = [] - def autosave_session(self, session_id: str) -> None: # type: ignore[override] - raise NotImplementedError - class TestAutosaveAllSessions: def test_one_failure_does_not_block_others_and_logs(self) -> None: - svc = _Svc({"a": object(), "b": object(), "c": object()}) - - def autosave(sid: str) -> None: + sessions = {sid: MagicMock() for sid in ("a", "b", "c")} + for sid, session in sessions.items(): + session.session_id = sid + session.build_save_game.return_value.model_dump.return_value = {} + session.world_name = "world" + svc = _Svc(sessions) + + def autosave(name: str, data: dict[str, Any], *, world: str = "") -> None: + sid = name.removeprefix("session_") if sid == "b": raise RuntimeError("disk full") svc.saved.append(sid) - svc.autosave_session = autosave # type: ignore[method-assign] + svc._store.save.side_effect = autosave with structlog.testing.capture_logs() as logs: svc.autosave_all_sessions() diff --git a/tests/unit/test_autosave_error_logging.py b/tests/unit/test_autosave_error_logging.py index eb2e0387..741cb7f5 100644 --- a/tests/unit/test_autosave_error_logging.py +++ b/tests/unit/test_autosave_error_logging.py @@ -1,12 +1,48 @@ from __future__ import annotations +import threading from pathlib import Path +from typing import Any +import pytest import structlog from dnd_simulator.core.character import Ability from dnd_simulator.service import GameService -from dnd_simulator.storage.store import JsonFileStore +from dnd_simulator.service.errors import SessionNotFoundError +from dnd_simulator.storage.store import JsonFileStore, SaveStore + + +class _BlockingStore(SaveStore): + def __init__(self) -> None: + self.save_started = threading.Event() + self.release_save = threading.Event() + self.save_calls = 0 + self.data: dict[tuple[str, str], dict[str, Any]] = {} + + def save(self, name: str, data: dict[str, Any], *, world: str = "") -> None: + self.save_calls += 1 + self.save_started.set() + assert self.release_save.wait(timeout=2) + self.data[(world, name)] = data + + def load(self, name: str, *, world: str = "") -> dict[str, Any]: + try: + return self.data[(world, name)] + except KeyError: + for (_saved_world, saved_name), data in self.data.items(): + if saved_name == name: + return data + raise KeyError(name) from None + + def list_saves(self, *, world: str = "") -> list[str]: + return [name for saved_world, name in self.data if saved_world == world] + + def delete(self, name: str, *, world: str = "") -> None: + try: + del self.data[(world, name)] + except KeyError: + raise KeyError(name) from None def _scores() -> dict[str, int]: @@ -24,10 +60,10 @@ def test_create_player_logs_autosave_failure_and_still_returns_player(tmp_path: 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}") + def fail_save(name: str, data: dict[str, Any], *, world: str = "") -> None: + raise RuntimeError(f"autosave failed for {name}") - service.autosave_session = fail_autosave # type: ignore[method-assign] + service._store.save = fail_save # type: ignore[method-assign] with structlog.testing.capture_logs() as logs: player = service.create_player( @@ -50,10 +86,10 @@ def test_empty_session_evict_logs_autosave_failure_and_still_removes_session(tmp 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}") + def fail_save(name: str, data: dict[str, Any], *, world: str = "") -> None: + raise RuntimeError(f"autosave failed for {name}") - service.autosave_session = fail_autosave # type: ignore[method-assign] + service._store.save = fail_save # type: ignore[method-assign] with structlog.testing.capture_logs() as logs: service._on_session_empty(session) @@ -77,3 +113,41 @@ def test_empty_session_evict_after_delete_is_noop(tmp_path: Path) -> None: 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"] + + +def test_concurrent_evict_runs_one_autosave() -> None: + store = _BlockingStore() + service = GameService(store=store) + session = service.start_game() + first = threading.Thread(target=service._on_session_empty, args=(session,)) + second = threading.Thread(target=service._on_session_empty, args=(session,)) + + first.start() + assert store.save_started.wait(timeout=2) + second.start() + store.release_save.set() + first.join(timeout=2) + second.join(timeout=2) + + assert store.save_calls == 1 + assert session.session_id not in service._sessions + + +def test_explicit_delete_racing_evict_does_not_leave_restorable_autosave() -> None: + store = _BlockingStore() + service = GameService(store=store) + session = service.start_game() + evict = threading.Thread(target=service._on_session_empty, args=(session,)) + evict.start() + assert store.save_started.wait(timeout=2) + + delete = threading.Thread(target=service.delete_session, args=(session.session_id,)) + delete.start() + store.release_save.set() + evict.join(timeout=2) + delete.join(timeout=2) + + assert not evict.is_alive() + assert not delete.is_alive() + with pytest.raises(SessionNotFoundError): + service.get_session(session.session_id) diff --git a/tests/unit/test_world_state_gate.py b/tests/unit/test_world_state_gate.py index 25c962c6..833e183e 100644 --- a/tests/unit/test_world_state_gate.py +++ b/tests/unit/test_world_state_gate.py @@ -152,3 +152,34 @@ def test_stop_round_and_concurrent_snapshot_finish_without_deadlock(tmp_path: Pa assert not snapshot_thread.is_alive() assert not stop_thread.is_alive() + + +def test_slow_store_write_does_not_hold_world_gate() -> None: + store = MagicMock() + save_started = threading.Event() + release_save = threading.Event() + + def slow_save(*args: object, **kwargs: object) -> None: + save_started.set() + assert release_save.wait(timeout=2) + + store.save.side_effect = slow_save + service = GameService(store=store) + session = service.start_game("sword_vale") + save_thread = threading.Thread(target=service.autosave_session, args=(session.session_id,)) + save_thread.start() + assert save_started.wait(timeout=2) + + mutation_acquired = threading.Event() + + def mutate() -> None: + with session.mutate_world(): + mutation_acquired.set() + + mutation_thread = threading.Thread(target=mutate) + mutation_thread.start() + assert mutation_acquired.wait(timeout=2) + + release_save.set() + save_thread.join(timeout=2) + mutation_thread.join(timeout=2) From e534475b135dbd6c391fc3ada21dcdf9ee2a7b45 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:30:36 +0300 Subject: [PATCH 06/33] sprint 022 phase 1 task 4: make load lifecycle atomic --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../tasks/phase1-task4-atomic-load-resume.md | 12 ++- src/dnd_simulator/round.py | 5 ++ src/dnd_simulator/service/commands_save.py | 11 ++- src/dnd_simulator/service/game_service.py | 10 ++- src/dnd_simulator/service/session.py | 30 ++++++- tests/integration/test_lairs.py | 10 ++- tests/unit/test_commands_save.py | 78 ++++++++++++++++++- 9 files changed, 147 insertions(+), 15 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index c2c14742..0000e5c7 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 1 — Safe session lifecycle (task 3 done, task 4 pending) — 2026-07-10 +**Phase:** 1 — Safe session lifecycle (all tasks done, ready for close-phase) — 2026-07-10 -Ready to start task 4. +Ready to close Phase 1. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 58eb9933..fff5223b 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -61,7 +61,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 1 tasks 1-3 complete. Task 4 pending. +**Current:** Phase 1 tasks complete. Ready for phase closure. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md b/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md index 6d1dc226..dadf8047 100644 --- a/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md +++ b/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md @@ -36,4 +36,14 @@ ## Status -`pending` +`done` + +## Developer Notes + +`GameSession` сериализует start/stop/load через отдельный round-transition lock. Load отключает callbacks старого +раунда, полностью завершает thread, очищает cached turn и под world-state gate восстанавливает world, session RNG +и brains. Player после load остаётся без brain до следующего player connection; spectator по-прежнему не запускает +симуляцию. Autosave restore использует тот же gated restore и публикуется в registry в паузированном состоянии. + +Два существующих lair integration-сценария обновлены под новый контракт: после REST load они переподключают player +WebSocket перед продолжением. Полный integration suite: 160 passed. diff --git a/src/dnd_simulator/round.py b/src/dnd_simulator/round.py index 334ef9cc..115605b8 100644 --- a/src/dnd_simulator/round.py +++ b/src/dnd_simulator/round.py @@ -101,6 +101,11 @@ def set_on_action(self, callback: OnActionCallback) -> None: """Set callback invoked after each action within a turn.""" self._on_action = callback + def clear_callbacks(self) -> None: + """Detach transport callbacks before discarding a live round.""" + self._on_action = None + self._on_round_end = None + def _execute_action( self, creature: Creature, diff --git a/src/dnd_simulator/service/commands_save.py b/src/dnd_simulator/service/commands_save.py index add997c0..63d3ae9a 100644 --- a/src/dnd_simulator/service/commands_save.py +++ b/src/dnd_simulator/service/commands_save.py @@ -64,11 +64,14 @@ def load_game(self, session_id: str, name: str) -> None: data = self._store.load(name, world=session.world_name) save = self._validate_save(data) - load_rng_state(session.dice_rng, save.world.dice_rng_state) - session.world.load(save.world.to_world_dict()) + def restore() -> None: + load_rng_state(session.dice_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)) + # Reassign brains based on restored ai_type (may differ from pre-load state) + self._assign_brains(self._get_entities_layer(session)) + + session.replace_world_state(restore) def delete_save(self, session_id: str, name: str) -> None: """Delete a save file.""" diff --git a/src/dnd_simulator/service/game_service.py b/src/dnd_simulator/service/game_service.py index dec9d255..e84eb902 100644 --- a/src/dnd_simulator/service/game_service.py +++ b/src/dnd_simulator/service/game_service.py @@ -317,11 +317,14 @@ def _assign_brains(self, entities_layer: EntitiesLayer) -> None: match the (possibly restored) ai_type field. """ from dnd_simulator.core.character import Creature + from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.layers.entities.models import Npc for entity in entities_layer._entities.values(): if isinstance(entity, Npc): entity.brain = self._brain_factory.create(entity.ai_type) + elif isinstance(entity, PlayerCharacter): + entity.brain = None elif isinstance(entity, Creature) and entity.brain is None: entity.brain = self._brain_factory.create(BrainType.RULE_BASED) @@ -388,9 +391,10 @@ def _try_restore_session(self, session_id: str) -> None: del self._sessions[session.session_id] session.session_id = session_id - load_rng_state(session.dice_rng, save.world.dice_rng_state) - session.world.load(save.world.to_world_dict()) - self._assign_brains(self._get_entities_layer(session)) + with session.mutate_world(): + load_rng_state(session.dice_rng, save.world.dice_rng_state) + session.world.load(save.world.to_world_dict()) + self._assign_brains(self._get_entities_layer(session)) with self._sessions_lock: self._sessions[session_id] = session diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index 5f5052dc..2ef7a78e 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -284,6 +284,7 @@ class GameSession: _spectators: list[SessionEventListener] = field(default_factory=list, init=False, repr=False) _last_turn_msg: dict[str, Any] | None = field(default=None, init=False, repr=False) _lock: threading.Lock = field(default_factory=threading.Lock, init=False, repr=False) + _round_transition_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False) _world_state_lock: threading.RLock = field(default_factory=threading.RLock, init=False, repr=False) _on_empty: Callable[[GameSession], None] | None = field(default=None, init=False, repr=False) _evict_timer: threading.Timer | None = field(default=None, init=False, repr=False) @@ -495,6 +496,11 @@ def _build_round_state( # --------------------------------------------------------------------------- def start_round(self, player: PlayerCharacter) -> Round: + """Start or return the round while excluding load/stop transitions.""" + with self._round_transition_lock: + return self._start_round(player) + + def _start_round(self, player: PlayerCharacter) -> Round: """Start the round loop if not already running. Idempotent. Wires PlayerBrain, creates Round, starts background thread. @@ -602,7 +608,12 @@ def run_round_loop() -> None: return game_round - def stop_round(self) -> None: + def stop_round(self, *, discard_events: bool = False) -> None: + """Stop the round while excluding concurrent start/load transitions.""" + with self._round_transition_lock: + self._stop_round(discard_events=discard_events) + + def _stop_round(self, *, discard_events: bool = False) -> None: """Stop the round loop and clean up.""" self._bind_session_context() logger.info("stop_round") @@ -616,11 +627,26 @@ def stop_round(self) -> None: self._round_thread = None if game_round is not None: + if discard_events: + game_round.clear_callbacks() game_round.stop() if brain is not None: brain.submit_action(Action(name=ActionType.END_TURN)) # unblock queue + brain.submit_reaction(Action(name=ActionType.SKIP)) if thread is not None: - thread.join(timeout=5) + thread.join() + + def clear_cached_turn(self) -> None: + """Discard transport state tied to the world that was just replaced.""" + self._last_turn_msg = None + + def replace_world_state(self, loader: Callable[[], None]) -> None: + """Pause the round and atomically replace state before another start can win.""" + with self._round_transition_lock: + self._stop_round(discard_events=True) + self.clear_cached_turn() + with self.mutate_world(): + loader() def submit_player_action(self, action: Action) -> None: """Submit a player action to the brain queue. diff --git a/tests/integration/test_lairs.py b/tests/integration/test_lairs.py index d1a1c1c6..8d0091fa 100644 --- a/tests/integration/test_lairs.py +++ b/tests/integration/test_lairs.py @@ -224,6 +224,12 @@ def test_depleted_state_survives_save_load(self, backend_url: str, api_url: str, resp = requests.post(f"{api_url}/sessions/{sid}/saves/lair_depleted/load", timeout=10) assert resp.status_code == HTTPStatus.OK + # Load pauses the old round. Reconnect is the only lifecycle edge that + # starts the restored round and binds a fresh PlayerBrain. + sock.close() + sock = ws_connect(ws_base, sid, pid) + _get_turn(sock) + # Return after load: depletion persisted, nothing spawns. _move_player(api_url, sid, pid, "cave") _advance_turn(sock) @@ -321,7 +327,9 @@ def test_looted_treasury_survives_save_load(self, backend_url: str, api_url: str resp = requests.post(f"{api_url}/sessions/{sid}/saves/lair_looted/load", timeout=10) assert resp.status_code == HTTPStatus.OK - turn = _advance_turn(sock) + sock.close() + sock = ws_connect(ws_base, sid, pid) + turn = _get_turn(sock) chest = _nearby(turn, TREASURY_ID) assert chest is not None assert chest["loot_gold"] == 0 diff --git a/tests/unit/test_commands_save.py b/tests/unit/test_commands_save.py index c69abaf1..067be6fc 100644 --- a/tests/unit/test_commands_save.py +++ b/tests/unit/test_commands_save.py @@ -12,7 +12,8 @@ import pytest -from dnd_simulator.core.brain import BrainType +from dnd_simulator.core.brain import BrainType, PlayerBrain +from dnd_simulator.core.combat import BattleMap, CombatState from dnd_simulator.layers.entities.models import Npc from dnd_simulator.rules.dice import roll from dnd_simulator.rules.rule_brain import RuleBrain @@ -128,6 +129,81 @@ def test_brains_reassigned_on_load(self, tmp_path: Path) -> None: assert isinstance(restored, Npc) assert isinstance(restored.brain, RuleBrain) + def test_load_stops_old_round_and_resumes_once_for_restored_player(self, tmp_path: Path) -> None: + svc = _make_service(tmp_path) + session = svc.start_game("sword_vale") + sid = session.session_id + player = svc.create_player( + sid, + { + "name": "Loader", + "race": "human", + "class": "fighter", + "ability_scores": {"str": 15, "dex": 10, "con": 14, "int": 8, "wis": 12, "cha": 8}, + "fighting_style": "defense", + }, + ) + layer = svc._get_entities_layer(session) + player.in_combat = True + player.active = True + layer._combat._combats[player.location_id] = CombatState( + location_id=player.location_id, + turn_order=[player.id], + round_number=1, + battle_map=BattleMap(width=60, height=60), + ) + svc.save_game(sid, "combat") + session._last_turn_msg = {"type": "stale"} + session.start_round(player) + old_thread = session._round_thread + assert old_thread is not None + + svc.load_game(sid, "combat") + + assert not old_thread.is_alive() + assert session._round is None + assert session._round_thread is None + assert session._player_brain is None + assert session.get_last_turn_msg() is None + restored_combat = svc._get_entities_layer(session).get_combat(player.location_id) + assert restored_combat is not None + assert restored_combat.round_number == 1 + + restored_player = session.get_player() + assert restored_player is not None + first_round = session.start_round(restored_player) + first_thread = session._round_thread + second_round = session.start_round(restored_player) + try: + assert first_round is second_round + assert session._round_thread is first_thread + assert isinstance(restored_player.brain, PlayerBrain) + finally: + session.stop_round() + + def test_autosave_restore_is_paused_until_player_connection(self, tmp_path: Path) -> None: + svc = _make_service(tmp_path) + session = svc.start_game("sword_vale") + sid = session.session_id + svc.create_player( + sid, + { + "name": "Restored", + "race": "human", + "class": "fighter", + "ability_scores": {"str": 15, "dex": 10, "con": 14, "int": 8, "wis": 12, "cha": 8}, + "fighting_style": "defense", + }, + ) + svc.autosave_session(sid) + svc._sessions.pop(sid) + + restored = svc.get_session(sid) + + assert restored._round is None + assert restored._round_thread is None + assert restored._player_brain is None + class TestListAndDeleteSaves: def test_list_saves_returns_saved_names(self, tmp_path: Path) -> None: From e7aab3fd3af3f0ffacc8bedf4932dc7ba2e8e998 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:21:39 +0300 Subject: [PATCH 07/33] sprint 022 phase 1: close after reconnect fix --- docs/STATUS.md | 4 +- .../022-intents-travel/e2e/phase1-report.md | 49 +++++++++++++++++++ docs/sprints/022-intents-travel/sprint.md | 4 +- src/dnd_simulator/service/session.py | 48 +++++++++--------- tests/unit/test_session_lifecycle.py | 40 +++++++++++++++ 5 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 docs/sprints/022-intents-travel/e2e/phase1-report.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 0000e5c7..95e384cb 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 1 — Safe session lifecycle (all tasks done, ready for close-phase) — 2026-07-10 +**Phase:** 1 — Safe session lifecycle (COMPLETE) — 2026-07-10 -Ready to close Phase 1. +Блокер connection-driven resume закрыт вне последовательности sprint-задач: listener lifecycle сериализован с round transitions, stale cleanup стал no-op. Backend check: 2441 passed; live save/load + WS integration: 8 passed; reconnect stress: 20/20. Ready for Phase 2 task generation. ### Phases diff --git a/docs/sprints/022-intents-travel/e2e/phase1-report.md b/docs/sprints/022-intents-travel/e2e/phase1-report.md new file mode 100644 index 00000000..314fd026 --- /dev/null +++ b/docs/sprints/022-intents-travel/e2e/phase1-report.md @@ -0,0 +1,49 @@ +# E2E Report: sprint022-phase1 + +**Date:** 2026-07-10 +**Flags:** --no-llm +**Sections tested:** 1, 2, 6 + phase lifecycle scenario +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs + +## Summary + +- Scenarios: 3 tested, 2 passed, 1 failed during the original E2E run +- Follow-up: blocker fixed and verified outside the sprint task sequence +- Quick fixes: 1 applied +- Blockers: 0 open + +## Results + +| Scenario | Status | Notes | +|---|---|---| +| Landing and character creation | pass | Sword Vale session created through UI; Fighter with Defense entered the game, AC 19 and the normal action bar were visible. | +| Basic peaceful action | pass | Wait advanced game time from 10:00 to 11:00 through the player UI. | +| Save → mutate → load → reconnect | resolved | The original E2E run failed after reconnect. The WS lifecycle race was fixed and verified with lifecycle tests, live save/load + WS integration tests, and 20 consecutive quick reconnect runs. The exact browser scenario was not rerun. | + +## Findings + +### Blockers + +- None open. + +### Resolved + +- The disconnect path could observe an empty player-listener list, release `_lock`, and later stop the round after a reconnect had already registered a new listener and started a new round. Listener add/remove and round start/stop are now serialized by the session `_round_transition_lock`. +- Removing an unknown stale listener is now a no-op and does not arm session eviction. +- Regression coverage models the disconnect/reconnect interleaving directly. + +### Minor + +- None found in the tested scope. + +## Log Analysis + +- No browser console errors; one existing React Router future-flag warning. +- Backend accepted the reconnecting player WebSocket and logged `add_listener`. During the first reconnect transition it also logged `remove_listener`, `stop_round`, and a round ending at restored time 10:00:06. The page never received a turn afterward. + +## Follow-up Verification + +- `tests/unit/test_session_lifecycle.py`: 30 passed. +- Full backend check: ruff, formatting and mypy passed; 2441 unit tests passed. +- Live backend with integration content: save/load and WebSocket connection selection, 8 passed. +- Quick reconnect stress: 20 consecutive runs passed. diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index fff5223b..86c9ae20 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -14,7 +14,7 @@ **Ссылки:** [simulation-core](../../brainstorms/simulation-core.md), [BACKLOG](../../BACKLOG.md#simulation-core-брейншторм-2026-07-04), [Sprint 021](../021-save-schema/sprint.md) -## Phase 1: Safe session lifecycle +## Phase 1: Safe session lifecycle ✓ Save, load, autosave, evict и round loop согласованы одной session-level критической секцией. Загруженная посреди боя сессия остаётся на сохранённом ходе и запускается только после подключения игрока. Проверка: конкурентный autosave не получает порванный мир; сохранённый Round 1 после load остаётся Round 1 до реконнекта. @@ -61,7 +61,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 1 tasks complete. Ready for phase closure. +**Current:** Phase 1 complete. Ready for Phase 2 task generation. ## Decisions diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py index 2ef7a78e..e4ceefdc 100644 --- a/src/dnd_simulator/service/session.py +++ b/src/dnd_simulator/service/session.py @@ -366,8 +366,10 @@ def has_player_listeners(self) -> bool: def add_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() - with self._lock: - # A (re)connecting player cancels any pending evict during the grace window. + with self._round_transition_lock, self._lock: + # Serialize listener arrival with disconnect-driven stop_round. Otherwise an + # old connection can observe an empty listener list, a reconnect can start a + # new round, and the old connection can then stop that new round. self._cancel_evict_check() self._listeners.append(listener) count = len(self._listeners) @@ -396,30 +398,26 @@ def get_last_turn_msg(self) -> dict[str, Any] | None: def remove_listener(self, listener: SessionEventListener) -> None: self._bind_session_context() - stop_round = False - with self._lock: - with contextlib.suppress(ValueError): - self._listeners.remove(listener) - count = len(self._listeners) - player_empty = not self.has_player_listeners() - if player_empty and self._round is not None: - stop_round = True - logger.info("remove_listener", listener_count=count, player_empty=player_empty, stop_round=stop_round) - if stop_round: - # Pause the simulation immediately when no player drives it. A lingering round - # thread would keep advancing NPC turns during the grace window: for a real - # player a network blip would silently cost them combat turns, and because all - # sessions share one process-global dice RNG, overlapping player-less rounds make - # seeded integration tests nondeterministic. stop_round() cancels any stale timer, - # so the evict is (re)armed after it returns. - self.stop_round() - if player_empty: - # Defer only the registry eviction: a quick disconnect+reconnect (StrictMode - # remount, network blip) must not flash the session out of the registry, and the - # reconnecting player restarts the round via start_round. The deferred check - # (_run_evict_check) re-verifies emptiness before firing _on_empty. + player_empty = False + with self._round_transition_lock: with self._lock: - self._schedule_evict_check() + try: + self._listeners.remove(listener) + except ValueError: + logger.info("remove_listener_ignored", listener_count=len(self._listeners)) + return + count = len(self._listeners) + player_empty = not self.has_player_listeners() + stop_round = player_empty and self._round is not None + logger.info("remove_listener", listener_count=count, player_empty=player_empty, stop_round=stop_round) + if stop_round: + # Pause before a reconnect can enter add_listener/start_round. + self._stop_round() + if player_empty: + # Arm eviction before allowing add_listener to enter; a reconnect then + # cancels this exact timer while it starts the next round. + with self._lock: + self._schedule_evict_check() def _schedule_evict_check(self) -> None: """Arm the deferred empty-session check. Caller holds ``_lock``.""" diff --git a/tests/unit/test_session_lifecycle.py b/tests/unit/test_session_lifecycle.py index f978bfc4..fa672f12 100644 --- a/tests/unit/test_session_lifecycle.py +++ b/tests/unit/test_session_lifecycle.py @@ -9,6 +9,7 @@ from __future__ import annotations +import threading from typing import Any from unittest.mock import MagicMock @@ -176,6 +177,45 @@ def test_remove_unregistered_listener_is_noop(self) -> None: assert fired == [] assert session._listeners == [registered] + def test_stale_remove_does_not_arm_evict_when_already_empty(self) -> None: + session = _session() + + session.remove_listener(RecordingListener()) + + assert session._evict_timer is None + + def test_reconnect_cannot_interleave_with_disconnect_stop(self) -> None: + session = _session() + old_listener = RecordingListener() + new_listener = RecordingListener() + session.add_listener(old_listener) + session._round = MagicMock(spec=Round) + + stop_entered = threading.Event() + allow_stop = threading.Event() + + def blocking_stop(*, discard_events: bool = False) -> None: + stop_entered.set() + assert allow_stop.wait(timeout=1) + session._round = None + + session._stop_round = blocking_stop # type: ignore[method-assign] + disconnect = threading.Thread(target=session.remove_listener, args=(old_listener,)) + reconnect = threading.Thread(target=session.add_listener, args=(new_listener,)) + + disconnect.start() + assert stop_entered.wait(timeout=1) + reconnect.start() + assert new_listener not in session._listeners + + allow_stop.set() + disconnect.join(timeout=1) + reconnect.join(timeout=1) + + assert not disconnect.is_alive() + assert not reconnect.is_alive() + assert session._listeners == [new_listener] + class TestSpectatorListener: """Read-only spectators (Sprint 020 phase 3): receive the broadcast, never drive lifecycle.""" From ff22cbbff2517ca6c72ef0011fa98f5368475df2 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:05:52 +0300 Subject: [PATCH 08/33] sprint 022 phase 2: task breakdown --- docs/STATUS.md | 6 +-- docs/sprints/022-intents-travel/sprint.md | 6 ++- .../phase2-task1-persisted-anchors-intents.md | 36 ++++++++++++++++ ...e2-task2-anchor-activation-fast-forward.md | 41 ++++++++++++++++++ .../phase2-task3-wait-sleep-lifecycle.md | 42 +++++++++++++++++++ 5 files changed, 126 insertions(+), 5 deletions(-) create mode 100644 docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 95e384cb..8981f1b1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,7 +2,7 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". -**Last updated:** 2026-07-10 +**Last updated:** 2026-07-11 **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:** Sprint 022 исполняет цепочку simulation-core: safe session lifecycle → anchors/intents → travel → interruptible journeys. **Blockers:** нет. @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 1 — Safe session lifecycle (COMPLETE) — 2026-07-10 +**Phase:** 2 — Anchors, wait and sleep intents (tasks generated) — 2026-07-11 -Блокер connection-driven resume закрыт вне последовательности sprint-задач: listener lifecycle сериализован с round transitions, stale cleanup стал no-op. Backend check: 2441 passed; live save/load + WS integration: 8 passed; reconnect stress: 20/20. Ready for Phase 2 task generation. +Phase 2 разбита на три задачи: сохраняемая модель anchor/intent, player-agnostic activation + fast-forward и lifecycle wait/sleep через save/load/reconnect. Ready to start task 1. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 86c9ae20..c242f8e8 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -35,7 +35,9 @@ Save, load, autosave, evict и round loop согласованы одной sess **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [Persisted anchors and timed intents](tasks/phase2-task1-persisted-anchors-intents.md) +2. [Anchor-driven activation and fast-forward](tasks/phase2-task2-anchor-activation-fast-forward.md) +3. [Wait and sleep intent lifecycle](tasks/phase2-task3-wait-sleep-lifecycle.md) ## Phase 3: Travel as an intent @@ -61,7 +63,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 1 complete. Ready for Phase 2 task generation. +**Current:** Phase 2 tasks generated. Ready to start task 1. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md b/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md new file mode 100644 index 00000000..182ccdaa --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md @@ -0,0 +1,36 @@ +# Task: Persisted anchors and timed intents + +**Date:** 2026-07-11 +**Sprint:** 022-intents-travel +**Phase:** 2 — Anchors, wait and sleep intents + +## Description + +Introduce the minimal activity model on `Creature`: an explicit anchor capability and a typed current intent for timed wait or sleep. The intent owns its kind, start time, and wake time; `wake_at_seconds` must stop being a parallel source of truth. Both fields must round-trip through the strict `EntitySave` union for players, NPCs, and generic creatures. + +Keep this model in `core/` as transport- and layer-independent data. This task only establishes state and persistence. It does not change activation or action handlers yet, and it does not add travel fields that Phase 3 has not designed. + +## Tests First + +- A player, NPC, and generic creature with different anchor values and wait/sleep intents survive an EntitiesLayer save/load round-trip without losing the intent kind or timestamps. +- A creature with no intent round-trips as idle, without a fabricated wake time. +- The strict save schema rejects an unknown intent kind or extra intent fields instead of silently accepting a state that the engine cannot execute. + +## Implementation + +Add typed activity/intent data under `core/` and fields on `Creature`. Extend `CreatureFields` and entity serialization/restoration to use the same model for all creature variants. Remove `wake_at_seconds` from runtime and save models once all reads in this task's compile boundary have moved to the intent wake time; temporary handler/activation compatibility may read the new field but must not maintain both representations. + +Likely files: `core/character.py`, a focused `core/intent.py`, `layers/entities/save_models.py`, `layers/entities/entity_serialization.py`, `layers/entities/layer.py`, and serialization tests. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Anchor status is explicit on every `Creature`, not inferred from `PlayerCharacter`. +- [ ] Wait and sleep use a typed persisted intent with one authoritative wake time. +- [ ] All `EntitySave` creature variants preserve the new fields under `extra="forbid"`. + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md b/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md new file mode 100644 index 00000000..d80eaf9b --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md @@ -0,0 +1,41 @@ +# Task: Anchor-driven activation and fast-forward + +**Date:** 2026-07-11 +**Sprint:** 022-intents-travel +**Phase:** 2 — Anchors, wait and sleep intents + +## Description + +Make activation depend on the creature's anchor capability instead of its Python class. A living, idle anchor keeps its local scene active; an anchor with a wait or sleep intent does not. Nearby non-anchor creatures must not keep the scene running after its last awake anchor starts a timed intent. Combat remains active independently of anchors. + +Teach the round loop to derive its next wake point from timed intents. When no creature needs a round before that point, advance the world directly to the nearest wake time, complete the timer boundary, and reactivate from anchor state. Preserve the existing materialization and encounter boundaries without making activation transitive. + +## Tests First + +- An explicitly anchored NPC or generic creature activates its co-located scene even when no `PlayerCharacter` exists. +- A `PlayerCharacter` whose anchor flag is off does not activate a scene merely because of its type. +- When the only awake anchor starts waiting beside a rule NPC, the NPC becomes dormant and the world jumps to the anchor's wake time instead of executing hundreds of six-second NPC turns. +- With two timed intents, fast-forward stops at the earlier wake point and leaves the later intent in progress. +- A creature in combat stays active without an awake anchor; a dormant non-anchor does not become a new anchor by being near another active creature. + +## Implementation + +Refactor `ActivationManager.update_activation` into type-agnostic passes over `Creature`. Replace player-location naming and the no-player early return with anchor locations. Expire/complete timed intent boundaries consistently, without proximity silently deleting an unrelated intent. Update `CreatureHost` and `EntitiesLayer` wake-point queries to read intents, then adapt `Round._fast_forward` to the new contract. + +Keep encounter, squad, and lair materialization keyed to anchored active locations as today. Do not introduce `Brain.gate()` or declarative triggers in this phase. + +Likely files: `layers/entities/activation_manager.py`, `layers/entities/layer.py`, `core/creature_host.py`, `round.py`, activation/round tests, and affected materialization tests. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Activation contains no `PlayerCharacter` type check or player-presence early return. +- [ ] Active creatures do not activate further creatures transitively. +- [ ] Waiting beside an ordinary NPC reaches the nearest wake point without per-round churn. +- [ ] Combat activity and materialization behavior remain covered. + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md b/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md new file mode 100644 index 00000000..2f3bc889 --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md @@ -0,0 +1,42 @@ +# Task: Wait and sleep intent lifecycle + +**Date:** 2026-07-11 +**Sprint:** 022-intents-travel +**Phase:** 2 — Anchors, wait and sleep intents + +## Description + +Move WAIT, SHORT_REST, and LONG_REST from ad hoc dormancy fields to the timed intent lifecycle. Starting an action records the intent and duration; reaching its wake point completes it exactly once and returns an eligible anchor to play. Rest benefits must be applied at successful completion, not granted up front before elapsed time or save/load. + +Cover the real session path so wait/sleep remains correct across save, load, reconnect, and websocket turn delivery. Keep the legacy `WAIT + travel_to` branch isolated for removal in Phase 3; it must not create a wait intent or share the new completion path. + +## Tests First + +- WAIT creates a wait intent for the requested duration, yields the turn, and clears the intent when its timer completes. +- SHORT_REST and LONG_REST create sleep intents with one-hour and eight-hour wake points; resources, healing, and other completion effects are applied once at wake, not when sleep begins. +- Saving during wait or sleep and loading the session preserves the remaining intent; reconnect resumes from the saved game time and completes it once. +- Through the websocket action path, a player waiting beside a rule NPC receives the next playable turn after a fast jump to the wake time, with no intermediate NPC-turn flood. +- The existing `WAIT + travel_to` compatibility path still reports invalid destinations and does not leave a timed intent behind. + +## Implementation + +Make the wait/rest handlers start typed intents only. Add a focused completion operation used by activation at the wake boundary; centralize rest rewards there so repeated activation cannot apply them twice. Update handler tests and add a session/websocket regression test for `test-gap-ws-fastforward`. + +Retain the current action names and UI contract in Phase 2. Do not add `ActionType.TRAVEL`, route progress, general interruptions, or Brain gate/decide; those belong to later phases. + +Likely files: `rules/handlers/movement.py`, `rules/handlers/rest.py`, the intent model/completion helper, action and rest tests, session lifecycle tests, and websocket integration tests. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] WAIT, SHORT_REST, and LONG_REST use the persisted intent lifecycle. +- [ ] Rest effects happen once, after elapsed game time. +- [ ] Save/load/reconnect preserves an in-progress timed intent. +- [ ] The websocket wait-with-NPC regression reaches the next player turn via fast-forward. +- [ ] Travel compatibility remains isolated for Phase 3 removal. + +## Status + +`pending` From 387f9aa00ba8a5d8980523552a6f8b29642abeec Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:15:14 +0300 Subject: [PATCH 09/33] sprint 022 phase 2 task 1: persist anchors and intents --- docs/STATUS.md | 4 +- .../phase2-task1-persisted-anchors-intents.md | 18 +++-- src/dnd_simulator/core/character.py | 4 +- src/dnd_simulator/core/creature_host.py | 2 +- src/dnd_simulator/core/intent.py | 26 ++++++++ src/dnd_simulator/core/player.py | 2 + .../layers/entities/activation_manager.py | 26 ++++---- .../layers/entities/entity_serialization.py | 12 +++- src/dnd_simulator/layers/entities/layer.py | 20 ++++-- .../layers/entities/save_models.py | 10 ++- src/dnd_simulator/rules/handlers/movement.py | 7 +- src/dnd_simulator/rules/handlers/rest.py | 9 +-- tests/unit/test_action_dispatcher.py | 6 +- tests/unit/test_entities_serialization.py | 65 ++++++++++++++++++- tests/unit/test_game_loop.py | 6 +- tests/unit/test_handlers_movement.py | 8 ++- tests/unit/test_rest.py | 10 +-- tests/unit/test_round_private_access.py | 4 +- 18 files changed, 185 insertions(+), 54 deletions(-) create mode 100644 src/dnd_simulator/core/intent.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 8981f1b1..3235a91b 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 2 — Anchors, wait and sleep intents (tasks generated) — 2026-07-11 +**Phase:** 2 — Anchors, wait and sleep intents (task 1 done, task 2 pending) — 2026-07-11 -Phase 2 разбита на три задачи: сохраняемая модель anchor/intent, player-agnostic activation + fast-forward и lifecycle wait/sleep через save/load/reconnect. Ready to start task 1. +Task 1 завершена: `Creature` получил явный anchor и типизированный сохраняемый timed intent без параллельного `wake_at_seconds`. Full check: backend 2445 passed, frontend 274 passed. Ready for task 2. ### Phases diff --git a/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md b/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md index 182ccdaa..bdf72b88 100644 --- a/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md +++ b/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md @@ -24,13 +24,17 @@ Likely files: `core/character.py`, a focused `core/intent.py`, `layers/entities/ ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] Anchor status is explicit on every `Creature`, not inferred from `PlayerCharacter`. -- [ ] Wait and sleep use a typed persisted intent with one authoritative wake time. -- [ ] All `EntitySave` creature variants preserve the new fields under `extra="forbid"`. +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] Anchor status is explicit on every `Creature`, not inferred from `PlayerCharacter`. +- [x] Wait and sleep use a typed persisted intent with one authoritative wake time. +- [x] All `EntitySave` creature variants preserve the new fields under `extra="forbid"`. ## Status -`pending` +`done` + +## Developer Notes + +Added `TimedIntent` with strict wait/sleep kinds and absolute start/wake timestamps. `Creature.current_intent` is the only runtime wake source; the old standalone field was removed. Existing activation and action callers were migrated mechanically to the new field without changing their behavior, leaving the player-agnostic activation rewrite for task 2. `PlayerCharacter` defaults to an anchor while other creatures default to non-anchor, and save payloads persist the explicit value for every creature kind. diff --git a/src/dnd_simulator/core/character.py b/src/dnd_simulator/core/character.py index 0622ef5b..be988767 100644 --- a/src/dnd_simulator/core/character.py +++ b/src/dnd_simulator/core/character.py @@ -10,6 +10,7 @@ from dnd_simulator.core.class_features import ClassFeatures from dnd_simulator.core.conditions import Condition +from dnd_simulator.core.intent import TimedIntent from dnd_simulator.core.items import EquipmentSlot, Item from dnd_simulator.core.resource import ResourcePool from dnd_simulator.core.turn_budget import TurnBudget @@ -228,7 +229,8 @@ class Creature(Entity): reputation: dict[str, int] = field(default_factory=dict) # sparse: faction_id → rep score xp_value: int = 0 # XP awarded to Character attacker on kill (0 for most creatures, set from CR for monsters) squad_id: str | None = None # which squad this creature belongs to (if materialized) - wake_at_seconds: int | None = None # absolute game-time seconds; None = not waiting + is_anchor: bool = False + current_intent: TimedIntent | None = None combat_position: tuple[int, int] | None = None # fixed starting position on battle map (x, y in feet) brain: Brain | None = field(default=None, repr=False) diff --git a/src/dnd_simulator/core/creature_host.py b/src/dnd_simulator/core/creature_host.py index dbcea203..5a9d1e6c 100644 --- a/src/dnd_simulator/core/creature_host.py +++ b/src/dnd_simulator/core/creature_host.py @@ -45,7 +45,7 @@ def get_merchants_at(self, location_id: str, hour: int) -> list[Character]: ... def get_nearest_wake_time(self) -> int | None: - """Return the earliest `wake_at_seconds` across dormant creatures, or None.""" + """Return the earliest timed-intent wake point, or None.""" ... def update_activation( diff --git a/src/dnd_simulator/core/intent.py b/src/dnd_simulator/core/intent.py new file mode 100644 index 00000000..9a5b7c1e --- /dev/null +++ b/src/dnd_simulator/core/intent.py @@ -0,0 +1,26 @@ +"""Persisted creature intentions that span game time.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class IntentType(StrEnum): + """Kinds of timed intent supported by the current simulation.""" + + WAIT = "wait" + SLEEP = "sleep" + + +@dataclass(frozen=True) +class TimedIntent: + """A creature activity with an absolute start and wake boundary.""" + + kind: IntentType + started_at_seconds: int + wake_at_seconds: int + + def __post_init__(self) -> None: + if self.wake_at_seconds < self.started_at_seconds: + raise ValueError("wake time cannot precede intent start time") diff --git a/src/dnd_simulator/core/player.py b/src/dnd_simulator/core/player.py index d7986031..2fa3a3dd 100644 --- a/src/dnd_simulator/core/player.py +++ b/src/dnd_simulator/core/player.py @@ -17,3 +17,5 @@ class PlayerCharacter(Character): Save/load is handled by ``content_loader.creatures`` (``player_to_full_save_data`` / ``load_player_save_data``) so ``core`` carries no dependency on ``content_loader``. """ + + is_anchor: bool = True diff --git a/src/dnd_simulator/layers/entities/activation_manager.py b/src/dnd_simulator/layers/entities/activation_manager.py index ba165638..ad2ad5bd 100644 --- a/src/dnd_simulator/layers/entities/activation_manager.py +++ b/src/dnd_simulator/layers/entities/activation_manager.py @@ -67,11 +67,11 @@ def update_activation( """Activate creatures near players, dormify the rest. Rules: - - PlayerCharacter without wake_at is always active (anchor). - - PlayerCharacter with wake_at is dormant (not an anchor) until timer expires. + - PlayerCharacter without a timed intent is always active (anchor). + - PlayerCharacter with a timed intent is dormant until its timer expires. - Creatures at an anchor's location are active. - Creatures in combat are active (don't interrupt fights). - - Creatures activated by proximity get wake_at cleared (woken early). + - Creatures activated by proximity have timed intents cleared (woken early). - Everyone else is dormant (active=False). When query_fn/emit_fn are provided, also handles squad materialization: @@ -94,14 +94,14 @@ def update_activation( has_players = True if not e.is_alive: e.active = False - e.wake_at_seconds = None + e.current_intent = None continue - # Check wake_at expiry - if e.wake_at_seconds is not None and now >= e.wake_at_seconds: - e.wake_at_seconds = None + # Check timed intent expiry + if e.current_intent is not None and now >= e.current_intent.wake_at_seconds: + e.current_intent = None logger.info("activation_wake_timer", entity_id=e.id) # Player is anchor only if not waiting - if e.wake_at_seconds is None: + if e.current_intent is None: e.active = True player_locations.add(e.location_id) else: @@ -122,9 +122,9 @@ def update_activation( e.active = False continue - # Expire wake_at for non-players too - if e.wake_at_seconds is not None and now >= e.wake_at_seconds: - e.wake_at_seconds = None + # Expire timed intents for non-players too + if e.current_intent is not None and now >= e.current_intent.wake_at_seconds: + e.current_intent = None logger.info("activation_wake_timer", entity_id=e.id) effective_location = e.location_id @@ -139,9 +139,9 @@ def update_activation( e.location_id = effective_location # Proximity wakeup: clear pending wait timer - if should_activate and e.wake_at_seconds is not None: + if should_activate and e.current_intent is not None: logger.info("activation_wake_proximity", entity_id=e.id) - e.wake_at_seconds = None + e.current_intent = None # Third pass: check for encounter spawns from creatures that were active check_encounters(self, time, previously_active, query_fn) diff --git a/src/dnd_simulator/layers/entities/entity_serialization.py b/src/dnd_simulator/layers/entities/entity_serialization.py index e8a2cec9..67ce8d80 100644 --- a/src/dnd_simulator/layers/entities/entity_serialization.py +++ b/src/dnd_simulator/layers/entities/entity_serialization.py @@ -24,6 +24,7 @@ NpcSave, PlayerSave, ResourcePoolSave, + TimedIntentSave, TurnBudgetSave, ) @@ -199,7 +200,16 @@ def _creature_fields(entity: Creature) -> dict[str, object]: "reputation": dict(entity.reputation), "xp_value": entity.xp_value, "squad_id": entity.squad_id, - "wake_at_seconds": entity.wake_at_seconds, + "is_anchor": entity.is_anchor, + "current_intent": ( + TimedIntentSave( + kind=entity.current_intent.kind, + started_at_seconds=entity.current_intent.started_at_seconds, + wake_at_seconds=entity.current_intent.wake_at_seconds, + ) + if entity.current_intent is not None + else None + ), "combat_position": entity.combat_position, } return data diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 0c3abc1f..31f067e7 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -152,11 +152,11 @@ def get_merchants_at(self, location_id: str, hour: int) -> list[Npc]: return active_merchants_at(self._entities, location_id, hour) def get_nearest_wake_time(self) -> int | None: - """Return the minimum wake_at_seconds across all creatures, or None.""" + """Return the minimum intent wake time across all creatures, or None.""" wake_times = [ - e.wake_at_seconds + e.current_intent.wake_at_seconds for e in self._entities.values() - if isinstance(e, Creature) and e.wake_at_seconds is not None + if isinstance(e, Creature) and e.current_intent is not None ] return min(wake_times) if wake_times else None @@ -507,8 +507,18 @@ def load_state(self, state: dict[str, object]) -> None: ) 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 + from dnd_simulator.core.intent import IntentType, TimedIntent + + entity.is_anchor = bool(edata.get("is_anchor", entity.is_anchor)) + intent_raw = edata.get("current_intent") + if isinstance(intent_raw, dict): + entity.current_intent = TimedIntent( + kind=IntentType(str(intent_raw["kind"])), + started_at_seconds=int(intent_raw["started_at_seconds"]), + wake_at_seconds=int(intent_raw["wake_at_seconds"]), + ) + else: + entity.current_intent = 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])) diff --git a/src/dnd_simulator/layers/entities/save_models.py b/src/dnd_simulator/layers/entities/save_models.py index 219de532..3006cb0e 100644 --- a/src/dnd_simulator/layers/entities/save_models.py +++ b/src/dnd_simulator/layers/entities/save_models.py @@ -10,6 +10,7 @@ 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.intent import IntentType 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 @@ -107,6 +108,12 @@ class TurnBudgetSave(SaveModel): reaction: int +class TimedIntentSave(SaveModel): + kind: IntentType + started_at_seconds: int + wake_at_seconds: int + + class EntitySaveBase(SaveModel): id: str name: str @@ -140,7 +147,8 @@ class CreatureFields(EntitySaveBase): reputation: dict[str, int] = Field(default_factory=dict) xp_value: int = 0 squad_id: str | None = None - wake_at_seconds: int | None = None + is_anchor: bool = False + current_intent: TimedIntentSave | None = None combat_position: tuple[int, int] | None = None diff --git a/src/dnd_simulator/rules/handlers/movement.py b/src/dnd_simulator/rules/handlers/movement.py index aaf2059d..3b787311 100644 --- a/src/dnd_simulator/rules/handlers/movement.py +++ b/src/dnd_simulator/rules/handlers/movement.py @@ -223,9 +223,10 @@ def handle_wait(actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionCon """Wait: creature goes dormant until wake_at, or travels to a location. Travel: immediate move + time advance. - Plain wait: set wake_at_seconds, mark dormant. Fast-forward in run_loop + Plain wait: set a timed intent and mark dormant. Fast-forward in run_loop handles the actual time advancement. """ + from dnd_simulator.core.intent import IntentType, TimedIntent from dnd_simulator.core.models import TimeDelta travel_to = action.params.get("travel_to") @@ -255,7 +256,7 @@ def handle_wait(actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionCon hours = int(str(action.params.get("hours", 1))) if hours > 0: now = world.time.to_total_seconds() - actor.wake_at_seconds = now + hours * 3600 + actor.current_intent = TimedIntent(IntentType.WAIT, now, now + hours * 3600) actor.active = False - logger.info("wait_sleep", hours=hours, wake_at=actor.wake_at_seconds) + logger.info("wait_sleep", hours=hours, wake_at=actor.current_intent.wake_at_seconds) return ActionResult() diff --git a/src/dnd_simulator/rules/handlers/rest.py b/src/dnd_simulator/rules/handlers/rest.py index 733200f1..177bb497 100644 --- a/src/dnd_simulator/rules/handlers/rest.py +++ b/src/dnd_simulator/rules/handlers/rest.py @@ -7,6 +7,7 @@ import structlog from dnd_simulator.core.action import Action +from dnd_simulator.core.intent import IntentType, TimedIntent from dnd_simulator.core.models import ActionResult from dnd_simulator.core.resource import RestType from dnd_simulator.rules.resources import reset_resources @@ -31,10 +32,10 @@ def handle_long_rest( healed = actor.heal(actor.max_hp) now = world.time.to_total_seconds() - actor.wake_at_seconds = now + _LONG_REST_SECONDS + actor.current_intent = TimedIntent(IntentType.SLEEP, now, now + _LONG_REST_SECONDS) actor.active = False - logger.info("long_rest", healed=healed, reset_pools=reset_ids, wake_at=actor.wake_at_seconds) + logger.info("long_rest", healed=healed, reset_pools=reset_ids, wake_at=actor.current_intent.wake_at_seconds) return ActionResult() @@ -45,8 +46,8 @@ def handle_short_rest( reset_ids = reset_resources(actor, RestType.SHORT_REST) now = world.time.to_total_seconds() - actor.wake_at_seconds = now + _SHORT_REST_SECONDS + actor.current_intent = TimedIntent(IntentType.SLEEP, now, now + _SHORT_REST_SECONDS) actor.active = False - logger.info("short_rest", reset_pools=reset_ids, wake_at=actor.wake_at_seconds) + logger.info("short_rest", reset_pools=reset_ids, wake_at=actor.current_intent.wake_at_seconds) return ActionResult() diff --git a/tests/unit/test_action_dispatcher.py b/tests/unit/test_action_dispatcher.py index 440c3c94..c9bd960f 100644 --- a/tests/unit/test_action_dispatcher.py +++ b/tests/unit/test_action_dispatcher.py @@ -335,7 +335,8 @@ def test_wait_sets_wake_at_and_dormant(self) -> None: action = Action(name=ActionType.WAIT, params={"hours": 2}) result = handle_wait(creature, action, _noop_emit, _PEACEFUL, world) assert result.success - assert creature.wake_at_seconds == 10000 + 2 * 3600 + assert creature.current_intent is not None + assert creature.current_intent.wake_at_seconds == 10000 + 2 * 3600 assert creature.active is False def test_wait_default_1_hour(self) -> None: @@ -345,7 +346,8 @@ def test_wait_default_1_hour(self) -> None: action = Action(name=ActionType.WAIT) result = handle_wait(creature, action, _noop_emit, _PEACEFUL, world) assert result.success - assert creature.wake_at_seconds == 5000 + 3600 + assert creature.current_intent is not None + assert creature.current_intent.wake_at_seconds == 5000 + 3600 def test_wait_emits_no_event(self) -> None: world = MagicMock() diff --git a/tests/unit/test_entities_serialization.py b/tests/unit/test_entities_serialization.py index 25132938..cbc94a56 100644 --- a/tests/unit/test_entities_serialization.py +++ b/tests/unit/test_entities_serialization.py @@ -8,6 +8,8 @@ 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.intent import IntentType, TimedIntent +from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.core.resource import ResourcePool, RestType from dnd_simulator.core.turn_budget import TurnBudget from dnd_simulator.layers.entities.layer import EntitiesLayer @@ -325,7 +327,7 @@ def test_combat_sides_survive_round_trip(self) -> None: class TestEntitiesStateModel: def test_state_round_trips_through_json_and_preserves_rng(self) -> None: creature = _make_creature("wanderer") - creature.wake_at_seconds = 123 + creature.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=23, wake_at_seconds=123) layer = EntitiesLayer(entities=[creature], seed=19) _ = layer._rng.random() @@ -397,7 +399,8 @@ def test_runtime_creature_fields_survive_save_load(self) -> None: creature.reputation = {"guards": 5} creature.xp_value = 25 creature.squad_id = "patrol_1" - creature.wake_at_seconds = 321 + creature.is_anchor = True + creature.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=123, wake_at_seconds=321) creature.combat_position = (10, 15) layer = EntitiesLayer(entities=[creature]) @@ -417,5 +420,61 @@ def test_runtime_creature_fields_survive_save_load(self) -> None: assert restored.reputation == {"guards": 5} assert restored.xp_value == 25 assert restored.squad_id == "patrol_1" - assert restored.wake_at_seconds == 321 + assert restored.is_anchor is True + assert restored.current_intent == TimedIntent( + IntentType.WAIT, + started_at_seconds=123, + wake_at_seconds=321, + ) assert restored.combat_position == (10, 15) + + def test_anchor_and_timed_intents_round_trip_for_every_creature_kind(self) -> None: + player = PlayerCharacter(id="hero", name="Hero", location_id="square", is_anchor=True) + player.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=100, wake_at_seconds=3700) + npc = _make_fighter_npc(is_anchor=True) + npc.current_intent = TimedIntent(IntentType.SLEEP, started_at_seconds=200, wake_at_seconds=29000) + creature = _make_creature("wolf") + creature.is_anchor = False + creature.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=300, wake_at_seconds=900) + + restored_layer = EntitiesLayer() + restored_layer.load_state(EntitiesLayer(entities=[player, npc, creature]).get_state()) + + restored_player = restored_layer.get_entity("hero") + restored_npc = restored_layer.get_entity("fighter_npc") + restored_creature = restored_layer.get_entity("wolf") + assert isinstance(restored_player, PlayerCharacter) + assert isinstance(restored_npc, Npc) + assert isinstance(restored_creature, Creature) + assert restored_player.is_anchor is True + assert restored_player.current_intent == player.current_intent + assert restored_npc.is_anchor is True + assert restored_npc.current_intent == npc.current_intent + assert restored_creature.is_anchor is False + assert restored_creature.current_intent == creature.current_intent + + def test_idle_creature_round_trips_without_fabricated_wake_time(self) -> None: + creature = _make_creature("idle") + + state = EntitiesLayer(entities=[creature]).get_state() + assert state["entities"]["idle"]["current_intent"] is None + + restored_layer = EntitiesLayer() + restored_layer.load_state(state) + restored = restored_layer.get_entity("idle") + assert isinstance(restored, Creature) + assert restored.current_intent is None + + @pytest.mark.parametrize( + "invalid_intent", + [ + {"kind": "unknown", "started_at_seconds": 10, "wake_at_seconds": 20}, + {"kind": "wait", "started_at_seconds": 10, "wake_at_seconds": 20, "extra": True}, + ], + ) + def test_invalid_timed_intent_payload_is_rejected(self, invalid_intent: dict[str, object]) -> None: + state = EntitiesLayer(entities=[_make_creature("broken")]).get_state() + state["entities"]["broken"]["current_intent"] = invalid_intent + + with pytest.raises(ValidationError): + EntitiesLayer().load_state(state) diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index f6e7a4e3..2a284593 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -124,7 +124,7 @@ def test_wait_sets_wake_at_and_fast_forwards(self) -> None: elapsed = world.time.to_total_seconds() - initial_seconds assert elapsed == 3600 + 6 assert player.active is True - assert player.wake_at_seconds is None + assert player.current_intent is None def test_wait_custom_hours(self) -> None: """Wait 3 hours: fast-forward advances 3 hours.""" @@ -317,7 +317,7 @@ def test_wait_makes_player_dormant(self) -> None: # Player went dormant, wake_at is set assert player.active is False # wake_at was set before the 6s advance - assert player.wake_at_seconds is not None + assert player.current_intent is not None def test_fast_forward_advances_to_wake_at(self) -> None: """Fast-forward skips time to nearest wake_at when no active creatures.""" @@ -338,7 +338,7 @@ def test_fast_forward_advances_to_wake_at(self) -> None: elapsed = world.time.to_total_seconds() - initial assert elapsed == 2 * 3600 + 6 assert player.active is True - assert player.wake_at_seconds is None + assert player.current_intent is None def test_nearby_npc_dormifies_when_player_waits(self) -> None: """NPC near player goes dormant when player waits, reactivates on wake.""" diff --git a/tests/unit/test_handlers_movement.py b/tests/unit/test_handlers_movement.py index 2adbd2cc..9d6b971d 100644 --- a/tests/unit/test_handlers_movement.py +++ b/tests/unit/test_handlers_movement.py @@ -265,7 +265,7 @@ def emit_fn(event: Event) -> ActionResult: class TestHandleWait: def test_sets_wake_at_and_dormant(self) -> None: - """Wait sets wake_at_seconds and marks creature dormant.""" + """Wait sets a timed intent and marks creature dormant.""" mover = _creature("mover") world = MagicMock() world.time.to_total_seconds.return_value = 10000 @@ -276,7 +276,8 @@ def test_sets_wake_at_and_dormant(self) -> None: result = handle_wait(mover, action, emit_fn, ctx, world) assert result.success - assert mover.wake_at_seconds == 10000 + 2 * 3600 + assert mover.current_intent is not None + assert mover.current_intent.wake_at_seconds == 10000 + 2 * 3600 assert mover.active is False def test_wait_default_one_hour(self) -> None: @@ -290,7 +291,8 @@ def test_wait_default_one_hour(self) -> None: handle_wait(mover, action, emit_fn, ctx, world) - assert mover.wake_at_seconds == 5000 + 3600 + assert mover.current_intent is not None + assert mover.current_intent.wake_at_seconds == 5000 + 3600 def test_wait_travel(self) -> None: """Wait with travel_to changes location and advances time.""" diff --git a/tests/unit/test_rest.py b/tests/unit/test_rest.py index 658d7344..dc6ac5a9 100644 --- a/tests/unit/test_rest.py +++ b/tests/unit/test_rest.py @@ -55,7 +55,7 @@ def test_long_rest_heals_to_full(self) -> None: assert creature.current_hp == creature.max_hp def test_long_rest_advances_8_hours(self) -> None: - """Long rest sets wake_at_seconds 8 hours ahead and marks dormant.""" + """Long rest sets a sleep intent 8 hours ahead and marks dormant.""" from dnd_simulator.rules.handlers.rest import handle_long_rest creature = _creature() @@ -63,7 +63,8 @@ def test_long_rest_advances_8_hours(self) -> None: world = _stub_world(time_seconds=10000) handle_long_rest(creature, Action(name=ActionType.LONG_REST), lambda *a: None, ctx, world) - assert creature.wake_at_seconds == 10000 + 8 * 3600 + assert creature.current_intent is not None + assert creature.current_intent.wake_at_seconds == 10000 + 8 * 3600 assert creature.active is False @@ -96,7 +97,7 @@ def test_short_rest_does_not_heal(self) -> None: assert creature.current_hp == 5 def test_short_rest_advances_1_hour(self) -> None: - """Short rest sets wake_at_seconds 1 hour ahead and marks dormant.""" + """Short rest sets a sleep intent 1 hour ahead and marks dormant.""" from dnd_simulator.rules.handlers.rest import handle_short_rest creature = _creature() @@ -104,7 +105,8 @@ def test_short_rest_advances_1_hour(self) -> None: world = _stub_world(time_seconds=10000) handle_short_rest(creature, Action(name=ActionType.SHORT_REST), lambda *a: None, ctx, world) - assert creature.wake_at_seconds == 10000 + 3600 + assert creature.current_intent is not None + assert creature.current_intent.wake_at_seconds == 10000 + 3600 assert creature.active is False diff --git a/tests/unit/test_round_private_access.py b/tests/unit/test_round_private_access.py index a3577f34..5aa4ffaf 100644 --- a/tests/unit/test_round_private_access.py +++ b/tests/unit/test_round_private_access.py @@ -3,6 +3,7 @@ from __future__ import annotations from dnd_simulator.core.character import Creature +from dnd_simulator.core.intent import IntentType, TimedIntent from dnd_simulator.core.items import Item, ItemType from dnd_simulator.layers.entities.layer import EntitiesLayer from dnd_simulator.layers.entities.models import Npc, NpcRole @@ -35,7 +36,8 @@ def _make_creature(cid: str, *, wake_at: int | None = None) -> Creature: speed=30, attacks=[], ) - c.wake_at_seconds = wake_at + if wake_at is not None: + c.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=0, wake_at_seconds=wake_at) return c From 84d457823f1d75d136d5d2dde7ce223c7cb2a36b Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 02:26:53 +0300 Subject: [PATCH 10/33] sprint 022 phase 2 task 2: drive activation by anchors --- docs/STATUS.md | 4 +- ...e2-task2-anchor-activation-fast-forward.md | 20 +++--- src/dnd_simulator/core/creature_host.py | 2 +- .../layers/entities/activation_manager.py | 63 +++++++------------ src/dnd_simulator/layers/entities/layer.py | 2 +- src/dnd_simulator/round.py | 2 +- tests/unit/test_activation_manager.py | 49 +++++++++++++++ tests/unit/test_game_loop.py | 30 +++++++-- tests/unit/test_initiative.py | 10 ++- 9 files changed, 121 insertions(+), 61 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index 3235a91b..56ccbab2 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 2 — Anchors, wait and sleep intents (task 1 done, task 2 pending) — 2026-07-11 +**Phase:** 2 — Anchors, wait and sleep intents (task 2 done, task 3 pending) — 2026-07-11 -Task 1 завершена: `Creature` получил явный anchor и типизированный сохраняемый timed intent без параллельного `wake_at_seconds`. Full check: backend 2445 passed, frontend 274 passed. Ready for task 2. +Task 2 завершена: activation больше не знает `PlayerCharacter`, активность не транзитивна, timed intent не стирается соседством, fast-forward останавливается на ближайшей wake-точке. Full check: backend 2451 passed, frontend 274 passed. Ready for task 3. ### Phases diff --git a/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md b/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md index d80eaf9b..3aaac442 100644 --- a/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md +++ b/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md @@ -28,14 +28,18 @@ Likely files: `layers/entities/activation_manager.py`, `layers/entities/layer.py ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] Activation contains no `PlayerCharacter` type check or player-presence early return. -- [ ] Active creatures do not activate further creatures transitively. -- [ ] Waiting beside an ordinary NPC reaches the nearest wake point without per-round churn. -- [ ] Combat activity and materialization behavior remain covered. +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] Activation contains no `PlayerCharacter` type check or player-presence early return. +- [x] Active creatures do not activate further creatures transitively. +- [x] Waiting beside an ordinary NPC reaches the nearest wake point without per-round churn. +- [x] Combat activity and materialization behavior remain covered. ## Status -`pending` +`done` + +## Developer Notes + +Activation now recomputes every creature from explicit anchor locations, timed intents, and combat state. Awake anchors of any creature class hold a scene active; ordinary active creatures do not propagate activation. Proximity no longer clears timed intents, so fast-forward wakes only the earliest timer and preserves later ones. Tests that previously treated `active=True` as a lasting mandate now assign `is_anchor=True` explicitly. One full-check attempt ended with pytest code 139 after all 2451 tests passed; the clean retry completed backend and frontend gates. diff --git a/src/dnd_simulator/core/creature_host.py b/src/dnd_simulator/core/creature_host.py index 5a9d1e6c..879d66e1 100644 --- a/src/dnd_simulator/core/creature_host.py +++ b/src/dnd_simulator/core/creature_host.py @@ -54,7 +54,7 @@ def update_activation( query_fn: QueryFn | None = None, emit_fn: EmitFn | None = None, ) -> None: - """Refresh active/dormant state based on proximity to player anchors.""" + """Refresh active/dormant state based on proximity to awake anchors.""" ... def get_combat_locations(self) -> list[str]: diff --git a/src/dnd_simulator/layers/entities/activation_manager.py b/src/dnd_simulator/layers/entities/activation_manager.py index ad2ad5bd..febfbf45 100644 --- a/src/dnd_simulator/layers/entities/activation_manager.py +++ b/src/dnd_simulator/layers/entities/activation_manager.py @@ -1,6 +1,6 @@ """Proximity-based activation for the entities layer. -The activation passes (anchor players, activate/dormify creatures) live here. Encounter +The activation passes (anchor creatures, activate/dormify creatures) live here. Encounter rolling and squad/lair materialization are isolated in sibling modules (``encounters``, ``materialization``) — this class owns only the activation loop and delegates the rest. """ @@ -27,7 +27,7 @@ class ActivationManager: - """Proximity-based activation: anchor players activate nearby creatures, the rest go dormant. + """Proximity-based activation: anchors activate nearby creatures, the rest go dormant. Operates on shared entity and state references owned by EntitiesLayer. Encounter rolling and materialization are delegated to the ``encounters`` / ``materialization`` sibling modules. @@ -64,89 +64,68 @@ def update_activation( query_fn: QueryFn | None = None, emit_fn: EmitFn | None = None, ) -> None: - """Activate creatures near players, dormify the rest. + """Activate creatures near awake anchors, dormify the rest. Rules: - - PlayerCharacter without a timed intent is always active (anchor). - - PlayerCharacter with a timed intent is dormant until its timer expires. + - Any living creature explicitly marked as an anchor holds its location active. + - An anchor with a timed intent is dormant until its timer expires. - Creatures at an anchor's location are active. - Creatures in combat are active (don't interrupt fights). - - Creatures activated by proximity have timed intents cleared (woken early). + - Proximity never cancels a timed intent. - Everyone else is dormant (active=False). When query_fn/emit_fn are provided, also handles squad materialization: squads at active locations are spawned as creatures, squads no longer at active locations are dematerialized with strength updates. - No-op if no players exist (e.g. in tests without PlayerCharacter). """ - from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.layers.entities.models import Npc now = time.to_total_seconds() + hour = time.hour + + # Snapshot active creature IDs before re-evaluation (for encounter checks). + previously_active: set[str] = {e.id for e in self._entities.values() if isinstance(e, Creature) and e.active} - # First pass: expire wake_at timers, collect anchor locations - player_locations: set[str] = set() - has_players = False + # First pass: expire timers and collect locations held by awake anchors. + anchor_locations: set[str] = set() for e in self._entities.values(): - if not isinstance(e, PlayerCharacter): + if not isinstance(e, Creature): continue - has_players = True if not e.is_alive: e.active = False e.current_intent = None continue - # Check timed intent expiry if e.current_intent is not None and now >= e.current_intent.wake_at_seconds: e.current_intent = None logger.info("activation_wake_timer", entity_id=e.id) - # Player is anchor only if not waiting - if e.current_intent is None: - e.active = True - player_locations.add(e.location_id) - else: - e.active = False - - if not has_players: - return - - # Snapshot active creature IDs before re-evaluation (for encounter checks) - previously_active: set[str] = {e.id for e in self._entities.values() if isinstance(e, Creature) and e.active} + if e.is_anchor and e.current_intent is None: + effective_location = e.current_location(hour) if isinstance(e, Npc) else e.location_id + anchor_locations.add(effective_location) - # Second pass: activate/dormify non-player creatures - hour = time.hour + # Second pass: recompute every creature from anchors, intents, and combat. for e in self._entities.values(): - if not isinstance(e, Creature) or isinstance(e, PlayerCharacter): + if not isinstance(e, Creature): continue if not e.is_alive: e.active = False continue - # Expire timed intents for non-players too - if e.current_intent is not None and now >= e.current_intent.wake_at_seconds: - e.current_intent = None - logger.info("activation_wake_timer", entity_id=e.id) - effective_location = e.location_id if isinstance(e, Npc): effective_location = e.current_location(hour) - should_activate = effective_location in player_locations or e.in_combat + should_activate = e.in_combat or (e.current_intent is None and effective_location in anchor_locations) e.active = should_activate # Move NPC to their scheduled location when activated if should_activate and effective_location != e.location_id: e.location_id = effective_location - # Proximity wakeup: clear pending wait timer - if should_activate and e.current_intent is not None: - logger.info("activation_wake_proximity", entity_id=e.id) - e.current_intent = None - # Third pass: check for encounter spawns from creatures that were active check_encounters(self, time, previously_active, query_fn) # Fourth pass: squad + lair materialization/dematerialization if query_fn is not None: - update_squad_materialization(self, player_locations, query_fn, emit_fn) - update_lair_materialization(self, now, player_locations, query_fn, emit_fn) + update_squad_materialization(self, anchor_locations, query_fn, emit_fn) + update_lair_materialization(self, now, anchor_locations, query_fn, emit_fn) diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 31f067e7..5e9981a6 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -166,7 +166,7 @@ def update_activation( query_fn: QueryFn | None = None, emit_fn: EmitFn | None = None, ) -> None: - """Activate creatures near players, dormify the rest.""" + """Activate creatures near awake anchors, dormify the rest.""" self._activation.update_activation(time, query_fn, emit_fn) # -- Combat (delegated to CombatManager) -- diff --git a/src/dnd_simulator/round.py b/src/dnd_simulator/round.py index 115605b8..94d071a4 100644 --- a/src/dnd_simulator/round.py +++ b/src/dnd_simulator/round.py @@ -539,7 +539,7 @@ def run_loop(self, max_rounds: int | None = None) -> None: break def _fast_forward(self) -> bool: - """Advance time to the nearest wake_at when no creatures are active. + """Advance time to the nearest intent wake point when no creatures are active. Returns True if time was advanced (loop should continue), False if there's nobody to wake up (loop should exit). diff --git a/tests/unit/test_activation_manager.py b/tests/unit/test_activation_manager.py index 044bcfcc..af3a1d68 100644 --- a/tests/unit/test_activation_manager.py +++ b/tests/unit/test_activation_manager.py @@ -12,6 +12,7 @@ DamageComponent, DamageType, ) +from dnd_simulator.core.intent import IntentType, TimedIntent from dnd_simulator.core.models import ( ActionResult, Answer, @@ -106,6 +107,54 @@ def test_moving_player_reactivates(self) -> None: assert npc_a.active is False assert npc_b.active is True + def test_generic_anchor_activates_scene_without_player(self) -> None: + anchor = Creature(id="anchor", name="Oracle", location_id="temple", is_anchor=True) + nearby = Creature(id="nearby", name="Acolyte", location_id="temple", active=False) + distant = Creature(id="distant", name="Farmer", location_id="farm", active=True) + + EntitiesLayer([anchor, nearby, distant]).update_activation(_TIME_0) + + assert anchor.active is True + assert nearby.active is True + assert distant.active is False + + def test_player_without_anchor_capability_does_not_activate_scene(self) -> None: + player = PlayerCharacter(id="p1", name="Hero", location_id="village", is_anchor=False) + nearby = Creature(id="nearby", name="Guard", location_id="village", active=True) + + EntitiesLayer([player, nearby]).update_activation(_TIME_0) + + assert player.active is False + assert nearby.active is False + + def test_active_non_anchor_does_not_activate_another_location(self) -> None: + anchor = Creature(id="anchor", name="Anchor", location_id="square", is_anchor=True) + nearby = Creature(id="nearby", name="Courier", location_id="square", active=True) + elsewhere = Creature(id="elsewhere", name="Friend", location_id="gate", active=True) + + EntitiesLayer([anchor, nearby, elsewhere]).update_activation(_TIME_0) + + assert nearby.active is True + assert elsewhere.active is False + + def test_proximity_does_not_cancel_timed_intent(self) -> None: + now = _TIME_0.to_total_seconds() + anchor = Creature(id="anchor", name="Anchor", location_id="square", is_anchor=True) + sleeper = Creature(id="sleeper", name="Sleeper", location_id="square", active=True) + sleeper.current_intent = TimedIntent(IntentType.SLEEP, now, now + 3600) + + EntitiesLayer([anchor, sleeper]).update_activation(_TIME_0) + + assert sleeper.active is False + assert sleeper.current_intent == TimedIntent(IntentType.SLEEP, now, now + 3600) + + def test_combat_stays_active_without_anchor(self) -> None: + fighter = Creature(id="fighter", name="Fighter", location_id="arena", active=False, in_combat=True) + + EntitiesLayer([fighter]).update_activation(_TIME_0) + + assert fighter.active is True + class TestEncounterCooldown: """Encounter cooldown prevents re-rolling within 600 game-seconds.""" diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index 2a284593..9b4192bf 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -6,6 +6,7 @@ from dnd_simulator.core.awareness import CombatAwareness, PeacefulAwareness, PerceivedEvent from dnd_simulator.core.brain import Brain, PlayerBrain from dnd_simulator.core.character import Creature +from dnd_simulator.core.intent import IntentType, TimedIntent from dnd_simulator.core.location import Location, LocationGraph from dnd_simulator.core.models import GameDateTime from dnd_simulator.core.player import PlayerCharacter @@ -77,7 +78,7 @@ def on_turn( class TestRoundTimeAdvancement: def test_time_advances_by_one_round_per_round(self) -> None: """After all creatures act, world time advances by 6 seconds.""" - npc = Creature(id="npc1", name="Guard", location_id="r1", brain=_EndTurnBrain()) + npc = Creature(id="npc1", name="Guard", location_id="r1", is_anchor=True, brain=_EndTurnBrain()) world = _make_world([npc]) entities_layer = next(la for la in world.layers if isinstance(la, EntitiesLayer)) @@ -90,7 +91,7 @@ def test_time_advances_by_one_round_per_round(self) -> None: def test_multiple_creatures_still_one_round(self) -> None: """Multiple creatures acting in one round = still only 6 seconds.""" - c1 = Creature(id="c1", name="A", location_id="r1", brain=_EndTurnBrain()) + c1 = Creature(id="c1", name="A", location_id="r1", is_anchor=True, brain=_EndTurnBrain()) c2 = Creature(id="c2", name="B", location_id="r1", brain=_EndTurnBrain()) world = _make_world([c1, c2]) entities_layer = next(la for la in world.layers if isinstance(la, EntitiesLayer)) @@ -254,8 +255,8 @@ def test_dead_player_ends_loop(self) -> None: assert player.active is False assert npc.active is False - def test_no_player_no_change(self) -> None: - """Without a PlayerCharacter, activation is unchanged (tests still work).""" + def test_no_anchor_dormifies_noncombat_creature(self) -> None: + """Without an anchor, an ordinary peaceful creature does not keep the loop alive.""" npc = Creature(id="npc", name="Guard", location_id="r1", active=True, brain=_EndTurnBrain()) world = _make_world([npc]) entities_layer = next(la for la in world.layers if isinstance(la, EntitiesLayer)) @@ -263,7 +264,7 @@ def test_no_player_no_change(self) -> None: game_round = Round(world, entities_layer) game_round.run_round() - assert npc.active is True + assert npc.active is False class TestRoundStopFlag: @@ -364,12 +365,31 @@ def on_turn( el = next(la for la in world.layers if isinstance(la, EntitiesLayer)) game_round = Round(world, el) + initial = world.time.to_total_seconds() game_round.run_loop(max_rounds=2) # After fast-forward and wake, both should be active assert player.active is True assert npc.active is True assert call_count == 2 # called once for wait, once after waking + assert world.time.to_total_seconds() - initial == 3600 + 6 + + def test_fast_forward_stops_at_earliest_intent(self) -> None: + now = GameDateTime(year=1, month=1, day=1, hour=10).to_total_seconds() + early = Creature(id="early", name="Early", location_id="r1", is_anchor=True, brain=_EndTurnBrain()) + late = Creature(id="late", name="Late", location_id="r1", is_anchor=True, brain=_EndTurnBrain()) + early.current_intent = TimedIntent(IntentType.WAIT, now, now + 60) + late.current_intent = TimedIntent(IntentType.SLEEP, now, now + 120) + world = _make_world([early, late], hour=10) + el = next(la for la in world.layers if isinstance(la, EntitiesLayer)) + + Round(world, el).run_loop(max_rounds=1) + + assert world.time.to_total_seconds() - now == 60 + 6 + assert early.current_intent is None + assert early.active is True + assert late.current_intent == TimedIntent(IntentType.SLEEP, now, now + 120) + assert late.active is False def test_no_wake_at_means_loop_exits(self) -> None: """Without any wake_at, run_loop exits when no active creatures.""" diff --git a/tests/unit/test_initiative.py b/tests/unit/test_initiative.py index c9cf18b7..e9da8528 100644 --- a/tests/unit/test_initiative.py +++ b/tests/unit/test_initiative.py @@ -493,7 +493,15 @@ def choose_action( id="c1", name="A", location_id="r1", max_hp=20, current_hp=20, attacks=(_SWORD,), brain=LogBrain() ) c_combat2 = Character(id="c2", name="B", location_id="r1", max_hp=15, current_hp=15, brain=LogBrain()) - c_peaceful = Character(id="c3", name="C", location_id="r2", max_hp=10, current_hp=10, brain=LogBrain()) + c_peaceful = Character( + id="c3", + name="C", + location_id="r2", + max_hp=10, + current_hp=10, + is_anchor=True, + brain=LogBrain(), + ) layer = EntitiesLayer([c_combat1, c_combat2, c_peaceful]) world = World( From 30becfc25bf9ce6d9b5ec5b6c7730eafd35cf84f Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 13:58:29 +0300 Subject: [PATCH 11/33] sprint 022 phase 2 task 3: complete timed rests on wake --- docs/STATUS.md | 4 +-- docs/sprints/022-intents-travel/sprint.md | 2 +- .../phase2-task3-wait-sleep-lifecycle.md | 26 +++++++++----- src/dnd_simulator/core/intent.py | 5 +++ .../layers/entities/activation_manager.py | 2 ++ .../layers/entities/entity_serialization.py | 1 + .../layers/entities/intent_completion.py | 28 +++++++++++++++ src/dnd_simulator/layers/entities/layer.py | 5 ++- .../layers/entities/save_models.py | 1 + src/dnd_simulator/rules/handlers/rest.py | 18 ++++------ tests/unit/test_activation_manager.py | 32 +++++++++++++++++ tests/unit/test_entities_serialization.py | 7 +++- tests/unit/test_handlers_movement.py | 3 ++ tests/unit/test_rest.py | 20 +++++------ tests/unit/test_ws.py | 35 +++++++++++++++++++ 15 files changed, 153 insertions(+), 36 deletions(-) create mode 100644 src/dnd_simulator/layers/entities/intent_completion.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 56ccbab2..de2f2de1 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 2 — Anchors, wait and sleep intents (task 2 done, task 3 pending) — 2026-07-11 +**Phase:** 2 — Anchors, wait and sleep intents (all tasks done, ready to close) — 2026-07-11 -Task 2 завершена: activation больше не знает `PlayerCharacter`, активность не транзитивна, timed intent не стирается соседством, fast-forward останавливается на ближайшей wake-точке. Full check: backend 2451 passed, frontend 274 passed. Ready for task 3. +Task 3 завершена: WAIT и sleep используют сохраняемый timed intent, эффекты отдыха применяются один раз при wake boundary. WebSocket wait рядом с RuleBrain NPC возвращает следующий ход через fast-forward. Full check: backend 2453 passed, frontend 274 passed. Phase 2 ready to close. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index c242f8e8..2fbae9dd 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -63,7 +63,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 2 tasks generated. Ready to start task 1. +**Current:** Phase 2 implementation complete. Ready to close phase. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md b/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md index 2f3bc889..01ee36f1 100644 --- a/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md +++ b/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md @@ -28,15 +28,23 @@ Likely files: `rules/handlers/movement.py`, `rules/handlers/rest.py`, the intent ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] WAIT, SHORT_REST, and LONG_REST use the persisted intent lifecycle. -- [ ] Rest effects happen once, after elapsed game time. -- [ ] Save/load/reconnect preserves an in-progress timed intent. -- [ ] The websocket wait-with-NPC regression reaches the next player turn via fast-forward. -- [ ] Travel compatibility remains isolated for Phase 3 removal. +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] WAIT, SHORT_REST, and LONG_REST use the persisted intent lifecycle. +- [x] Rest effects happen once, after elapsed game time. +- [x] Save/load/reconnect preserves an in-progress timed intent. +- [x] The websocket wait-with-NPC regression reaches the next player turn via fast-forward. +- [x] Travel compatibility remains isolated for Phase 3 removal. ## Status -`pending` +`done` + +## Developer Notes + +Timed intents now persist an optional rest completion type. Rest handlers only start sleep; activation applies healing +and resource resets at the wake boundary before clearing the intent, so a second activation cannot repeat them. The +strict save model preserves this completion data across save/load. A TestClient WebSocket regression covers wait next +to a RuleBrain NPC and verifies that the next player turn arrives after a single fast-forward. Legacy `WAIT + travel_to` +continues to use its isolated immediate-travel branch and never creates a timed intent. diff --git a/src/dnd_simulator/core/intent.py b/src/dnd_simulator/core/intent.py index 9a5b7c1e..c5857de0 100644 --- a/src/dnd_simulator/core/intent.py +++ b/src/dnd_simulator/core/intent.py @@ -5,6 +5,8 @@ from dataclasses import dataclass from enum import StrEnum +from dnd_simulator.core.resource import RestType + class IntentType(StrEnum): """Kinds of timed intent supported by the current simulation.""" @@ -20,7 +22,10 @@ class TimedIntent: kind: IntentType started_at_seconds: int wake_at_seconds: int + rest_type: RestType | None = None def __post_init__(self) -> None: if self.wake_at_seconds < self.started_at_seconds: raise ValueError("wake time cannot precede intent start time") + if self.rest_type is not None and self.kind is not IntentType.SLEEP: + raise ValueError("rest completion requires a sleep intent") diff --git a/src/dnd_simulator/layers/entities/activation_manager.py b/src/dnd_simulator/layers/entities/activation_manager.py index febfbf45..b6d8b465 100644 --- a/src/dnd_simulator/layers/entities/activation_manager.py +++ b/src/dnd_simulator/layers/entities/activation_manager.py @@ -16,6 +16,7 @@ from dnd_simulator.core.models import Event from dnd_simulator.core.monster import EncounterEntry from dnd_simulator.layers.entities.encounters import check_encounters +from dnd_simulator.layers.entities.intent_completion import complete_timed_intent from dnd_simulator.layers.entities.materialization import update_lair_materialization, update_squad_materialization if TYPE_CHECKING: @@ -97,6 +98,7 @@ def update_activation( e.current_intent = None continue if e.current_intent is not None and now >= e.current_intent.wake_at_seconds: + complete_timed_intent(e, e.current_intent) e.current_intent = None logger.info("activation_wake_timer", entity_id=e.id) if e.is_anchor and e.current_intent is None: diff --git a/src/dnd_simulator/layers/entities/entity_serialization.py b/src/dnd_simulator/layers/entities/entity_serialization.py index 67ce8d80..166dd1c1 100644 --- a/src/dnd_simulator/layers/entities/entity_serialization.py +++ b/src/dnd_simulator/layers/entities/entity_serialization.py @@ -206,6 +206,7 @@ def _creature_fields(entity: Creature) -> dict[str, object]: kind=entity.current_intent.kind, started_at_seconds=entity.current_intent.started_at_seconds, wake_at_seconds=entity.current_intent.wake_at_seconds, + rest_type=entity.current_intent.rest_type, ) if entity.current_intent is not None else None diff --git a/src/dnd_simulator/layers/entities/intent_completion.py b/src/dnd_simulator/layers/entities/intent_completion.py new file mode 100644 index 00000000..b8b3537b --- /dev/null +++ b/src/dnd_simulator/layers/entities/intent_completion.py @@ -0,0 +1,28 @@ +"""Completion effects for timed creature intentions.""" + +from __future__ import annotations + +import structlog + +from dnd_simulator.core.character import Creature +from dnd_simulator.core.intent import TimedIntent +from dnd_simulator.core.resource import RestType +from dnd_simulator.rules.resources import reset_resources + +logger = structlog.get_logger(domain="intent") + + +def complete_timed_intent(creature: Creature, intent: TimedIntent) -> None: + """Apply completion effects for an elapsed intent exactly once.""" + if intent.rest_type is None: + return + + reset_ids = reset_resources(creature, intent.rest_type) + healed = creature.heal(creature.max_hp) if intent.rest_type is RestType.LONG_REST else 0 + logger.info( + "rest_complete", + entity_id=creature.id, + rest_type=intent.rest_type, + reset_pools=reset_ids, + healed=healed, + ) diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index 5e9981a6..b6cefc2b 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -27,6 +27,7 @@ from dnd_simulator.core.monster import EncounterEntry, MonsterTemplate from dnd_simulator.core.npc_memory import NpcMemory from dnd_simulator.core.player import PlayerCharacter +from dnd_simulator.core.resource import RestType 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 @@ -512,10 +513,12 @@ def load_state(self, state: dict[str, object]) -> None: entity.is_anchor = bool(edata.get("is_anchor", entity.is_anchor)) intent_raw = edata.get("current_intent") if isinstance(intent_raw, dict): + rest_type_raw = intent_raw.get("rest_type") entity.current_intent = TimedIntent( kind=IntentType(str(intent_raw["kind"])), started_at_seconds=int(intent_raw["started_at_seconds"]), wake_at_seconds=int(intent_raw["wake_at_seconds"]), + rest_type=RestType(str(rest_type_raw)) if rest_type_raw is not None else None, ) else: entity.current_intent = None @@ -545,7 +548,7 @@ def load_state(self, state: dict[str, object]) -> None: setattr(entity, field_name, deserialize_item(eq_data)) pools_raw = edata.get("resource_pools") if isinstance(pools_raw, list): - from dnd_simulator.core.resource import ResourcePool, RestType + from dnd_simulator.core.resource import ResourcePool saved_pools = {str(d["id"]): d for d in pools_raw} existing_ids = {pool.id for pool in entity.resource_pools} diff --git a/src/dnd_simulator/layers/entities/save_models.py b/src/dnd_simulator/layers/entities/save_models.py index 3006cb0e..5df675b1 100644 --- a/src/dnd_simulator/layers/entities/save_models.py +++ b/src/dnd_simulator/layers/entities/save_models.py @@ -112,6 +112,7 @@ class TimedIntentSave(SaveModel): kind: IntentType started_at_seconds: int wake_at_seconds: int + rest_type: RestType | None = None class EntitySaveBase(SaveModel): diff --git a/src/dnd_simulator/rules/handlers/rest.py b/src/dnd_simulator/rules/handlers/rest.py index 177bb497..7d936427 100644 --- a/src/dnd_simulator/rules/handlers/rest.py +++ b/src/dnd_simulator/rules/handlers/rest.py @@ -10,7 +10,6 @@ from dnd_simulator.core.intent import IntentType, TimedIntent from dnd_simulator.core.models import ActionResult from dnd_simulator.core.resource import RestType -from dnd_simulator.rules.resources import reset_resources if TYPE_CHECKING: from dnd_simulator.core.character import Creature @@ -27,27 +26,22 @@ def handle_long_rest( actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionContext, world: World ) -> ActionResult: - """Long rest: reset all resource pools, heal to full, advance 8 hours.""" - reset_ids = reset_resources(actor, RestType.LONG_REST) - healed = actor.heal(actor.max_hp) - + """Long rest: sleep for eight hours, then recover on completion.""" now = world.time.to_total_seconds() - actor.current_intent = TimedIntent(IntentType.SLEEP, now, now + _LONG_REST_SECONDS) + actor.current_intent = TimedIntent(IntentType.SLEEP, now, now + _LONG_REST_SECONDS, rest_type=RestType.LONG_REST) actor.active = False - logger.info("long_rest", healed=healed, reset_pools=reset_ids, wake_at=actor.current_intent.wake_at_seconds) + logger.info("long_rest", wake_at=actor.current_intent.wake_at_seconds) return ActionResult() def handle_short_rest( actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionContext, world: World ) -> ActionResult: - """Short rest: reset short-rest pools only, advance 1 hour. No healing.""" - reset_ids = reset_resources(actor, RestType.SHORT_REST) - + """Short rest: sleep for one hour, then recover on completion.""" now = world.time.to_total_seconds() - actor.current_intent = TimedIntent(IntentType.SLEEP, now, now + _SHORT_REST_SECONDS) + actor.current_intent = TimedIntent(IntentType.SLEEP, now, now + _SHORT_REST_SECONDS, rest_type=RestType.SHORT_REST) actor.active = False - logger.info("short_rest", reset_pools=reset_ids, wake_at=actor.current_intent.wake_at_seconds) + logger.info("short_rest", wake_at=actor.current_intent.wake_at_seconds) return ActionResult() diff --git a/tests/unit/test_activation_manager.py b/tests/unit/test_activation_manager.py index af3a1d68..c7f00465 100644 --- a/tests/unit/test_activation_manager.py +++ b/tests/unit/test_activation_manager.py @@ -26,6 +26,7 @@ from dnd_simulator.core.monster import EncounterEntry, MonsterTemplate from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.core.queries import SquadInfo +from dnd_simulator.core.resource import ResourcePool, RestType from dnd_simulator.core.squad import SquadBehavior, SquadType from dnd_simulator.layers.entities.layer import EntitiesLayer @@ -155,6 +156,37 @@ def test_combat_stays_active_without_anchor(self) -> None: assert fighter.active is True + def test_completed_long_rest_applies_rewards_once(self) -> None: + now = _TIME_0.to_total_seconds() + pool = ResourcePool("spell_slot_1", 2, 0, RestType.LONG_REST) + sleeper = Creature( + id="sleeper", + name="Sleeper", + location_id="inn", + is_anchor=True, + max_hp=20, + current_hp=5, + resource_pools=[pool], + ) + sleeper.current_intent = TimedIntent( + IntentType.SLEEP, + now - 8 * 3600, + now, + rest_type=RestType.LONG_REST, + ) + layer = EntitiesLayer([sleeper]) + + layer.update_activation(_TIME_0) + assert sleeper.current_hp == 20 + assert pool.current_uses == 2 + assert sleeper.current_intent is None + + sleeper.current_hp = 10 + pool.current_uses = 0 + layer.update_activation(_TIME_0) + assert sleeper.current_hp == 10 + assert pool.current_uses == 0 + class TestEncounterCooldown: """Encounter cooldown prevents re-rolling within 600 game-seconds.""" diff --git a/tests/unit/test_entities_serialization.py b/tests/unit/test_entities_serialization.py index cbc94a56..1921571a 100644 --- a/tests/unit/test_entities_serialization.py +++ b/tests/unit/test_entities_serialization.py @@ -432,7 +432,12 @@ def test_anchor_and_timed_intents_round_trip_for_every_creature_kind(self) -> No player = PlayerCharacter(id="hero", name="Hero", location_id="square", is_anchor=True) player.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=100, wake_at_seconds=3700) npc = _make_fighter_npc(is_anchor=True) - npc.current_intent = TimedIntent(IntentType.SLEEP, started_at_seconds=200, wake_at_seconds=29000) + npc.current_intent = TimedIntent( + IntentType.SLEEP, + started_at_seconds=200, + wake_at_seconds=29000, + rest_type=RestType.LONG_REST, + ) creature = _make_creature("wolf") creature.is_anchor = False creature.current_intent = TimedIntent(IntentType.WAIT, started_at_seconds=300, wake_at_seconds=900) diff --git a/tests/unit/test_handlers_movement.py b/tests/unit/test_handlers_movement.py index 9d6b971d..9c3c3781 100644 --- a/tests/unit/test_handlers_movement.py +++ b/tests/unit/test_handlers_movement.py @@ -347,6 +347,7 @@ def test_wait_travel_unreachable_returns_failure(self) -> None: assert not result.success assert result.error assert mover.location_id == "starting_town" + assert mover.current_intent is None def test_wait_travel_misnamed_target_returns_failure(self) -> None: """Travel by name when no location matches returns success=False and location unchanged.""" @@ -370,6 +371,7 @@ def test_wait_travel_misnamed_target_returns_failure(self) -> None: assert not result.success assert result.error assert mover.location_id == "starting_town" + assert mover.current_intent is None def test_wait_travel_name_match_unreachable_returns_failure(self) -> None: """Travel where name matches but route to that location is unreachable returns failure.""" @@ -391,3 +393,4 @@ def test_wait_travel_name_match_unreachable_returns_failure(self) -> None: assert not result.success assert result.error assert mover.location_id == "starting_town" + assert mover.current_intent is None diff --git a/tests/unit/test_rest.py b/tests/unit/test_rest.py index dc6ac5a9..a7119459 100644 --- a/tests/unit/test_rest.py +++ b/tests/unit/test_rest.py @@ -27,8 +27,8 @@ def _creature( class TestLongRestHandler: - def test_long_rest_resets_all_resource_pools(self) -> None: - """Long rest resets both SHORT_REST and LONG_REST pools.""" + def test_long_rest_defers_resource_reset_until_wake(self) -> None: + """Long rest records sleep without granting its rewards up front.""" from dnd_simulator.rules.handlers.rest import handle_long_rest short_pool = ResourcePool("second_wind", 1, 0, RestType.SHORT_REST) @@ -40,11 +40,11 @@ def test_long_rest_resets_all_resource_pools(self) -> None: result = handle_long_rest(creature, Action(name=ActionType.LONG_REST), lambda *a: None, ctx, world) assert result.success - assert short_pool.current_uses == 1 - assert long_pool.current_uses == 2 + assert short_pool.current_uses == 0 + assert long_pool.current_uses == 0 - def test_long_rest_heals_to_full(self) -> None: - """Long rest restores HP to max_hp.""" + def test_long_rest_defers_healing_until_wake(self) -> None: + """Long rest does not restore HP when sleep begins.""" from dnd_simulator.rules.handlers.rest import handle_long_rest creature = _creature(hp=5, max_hp=20) @@ -52,7 +52,7 @@ def test_long_rest_heals_to_full(self) -> None: world = _stub_world() handle_long_rest(creature, Action(name=ActionType.LONG_REST), lambda *a: None, ctx, world) - assert creature.current_hp == creature.max_hp + assert creature.current_hp == 5 def test_long_rest_advances_8_hours(self) -> None: """Long rest sets a sleep intent 8 hours ahead and marks dormant.""" @@ -69,8 +69,8 @@ def test_long_rest_advances_8_hours(self) -> None: class TestShortRestHandler: - def test_short_rest_resets_only_short_rest_pools(self) -> None: - """Short rest resets SHORT_REST pools, leaves LONG_REST pools untouched.""" + def test_short_rest_defers_resource_reset_until_wake(self) -> None: + """Short rest records sleep without granting its rewards up front.""" from dnd_simulator.rules.handlers.rest import handle_short_rest short_pool = ResourcePool("second_wind", 1, 0, RestType.SHORT_REST) @@ -82,7 +82,7 @@ def test_short_rest_resets_only_short_rest_pools(self) -> None: result = handle_short_rest(creature, Action(name=ActionType.SHORT_REST), lambda *a: None, ctx, world) assert result.success - assert short_pool.current_uses == 1 + assert short_pool.current_uses == 0 assert long_pool.current_uses == 0 # NOT reset def test_short_rest_does_not_heal(self) -> None: diff --git a/tests/unit/test_ws.py b/tests/unit/test_ws.py index bf9633cb..60ab02b3 100644 --- a/tests/unit/test_ws.py +++ b/tests/unit/test_ws.py @@ -9,6 +9,10 @@ from dnd_simulator.adapters.api.app import app from dnd_simulator.adapters.api.deps import set_service +from dnd_simulator.core.character import Creature +from dnd_simulator.core.player import PlayerCharacter +from dnd_simulator.layers.entities.layer import EntitiesLayer +from dnd_simulator.rules.rule_brain import RuleBrain from dnd_simulator.service import GameService from dnd_simulator.storage.store import JsonFileStore @@ -66,6 +70,37 @@ def test_no_player(self, tmp_path: object) -> None: class TestWebSocketTurnCycle: + def test_wait_fast_forwards_past_nearby_rule_npc(self, tmp_path: object) -> None: + client, service = _make_client(tmp_path) + sid = _create_session_with_player(client) + session = service.get_session(sid) + entities = next(layer for layer in session.world.layers if isinstance(layer, EntitiesLayer)) + player = next(entity for entity in entities._entities.values() if isinstance(entity, PlayerCharacter)) + entities.add_entity( + Creature( + id="bystander", + name="Bystander", + location_id=player.location_id, + brain=RuleBrain(), + ) + ) + started_at = session.world.time.to_total_seconds() + + with client.websocket_connect(f"/api/ws/{sid}") as ws: + assert ws.receive_json()["type"] == "turn" + ws.send_json({"type": "action", "name": "wait", "params": {"hours": 1}}) + + messages = [] + for _ in range(6): + message = ws.receive_json() + messages.append(message) + if message["type"] == "turn": + break + + assert messages[-1]["type"] == "turn" + assert len(messages) <= 4 + assert session.world.time.to_total_seconds() >= started_at + 3600 + def test_receive_turn_send_end_turn(self, tmp_path: object) -> None: """Connect WS, receive turn awareness, send end_turn, receive round_result.""" client, _ = _make_client(tmp_path) From 01857a4113b02c4891a2feb4f537dcf7041521fb Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:07:34 +0300 Subject: [PATCH 12/33] sprint 022 phase 2: close anchors and timed intents --- docs/STATUS.md | 4 +- .../022-intents-travel/e2e/phase2-report.md | 48 +++++++++++++++++++ docs/sprints/022-intents-travel/sprint.md | 4 +- 3 files changed, 52 insertions(+), 4 deletions(-) create mode 100644 docs/sprints/022-intents-travel/e2e/phase2-report.md diff --git a/docs/STATUS.md b/docs/STATUS.md index de2f2de1..3c849ea5 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 2 — Anchors, wait and sleep intents (all tasks done, ready to close) — 2026-07-11 +**Phase:** 2 — Anchors, wait and sleep intents (COMPLETE) — 2026-07-11 -Task 3 завершена: WAIT и sleep используют сохраняемый timed intent, эффекты отдыха применяются один раз при wake boundary. WebSocket wait рядом с RuleBrain NPC возвращает следующий ход через fast-forward. Full check: backend 2453 passed, frontend 274 passed. Phase 2 ready to close. +WAIT и sleep используют сохраняемый timed intent, эффекты отдыха применяются один раз при wake boundary. Integration: 160 passed. Phase-scoped E2E: 4/4 passed. Ready for Phase 3 task generation. ### Phases diff --git a/docs/sprints/022-intents-travel/e2e/phase2-report.md b/docs/sprints/022-intents-travel/e2e/phase2-report.md new file mode 100644 index 00000000..4bcff79a --- /dev/null +++ b/docs/sprints/022-intents-travel/e2e/phase2-report.md @@ -0,0 +1,48 @@ +# E2E Report: sprint022-phase2 + +**Date:** 2026-07-11 +**Flags:** --no-llm +**Sections tested:** 2 +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs + +## Summary + +- Scenarios: 4 tested, 4 passed, 0 failed +- Quick fixes: 0 applied +- Blockers: 0 found + +## Results + +### Section 2: Peaceful Mode + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 2.3 | Wait and time advance | pass | Wait at The Salty Anchor advanced 10:00 → 11:00 and returned the player turn while Marta, a RuleBrain NPC, was nearby. | +| 2.4 | Move between locations | pass | Moving to Silverport Market Square updated the location panel and nearby list. | + +### Auto-discovered scenarios + +| Scenario | Reason | Status | Notes | +|----------|--------|--------|-------| +| Short rest wake lifecycle | Timed rest completion moved to the intent wake boundary | pass | Short Rest advanced 11:00 → 12:00; the action bar returned after wake. | +| Long rest wake lifecycle | Timed rest completion moved to the intent wake boundary | pass | Long Rest advanced 12:00 → 20:00; the action bar returned after wake. | + +## Quick Fixes + +None. + +## Findings + +### Blockers + +None. + +### Minor + +- Vite dev mode logged one transient WebSocket warning while the initial connection was being replaced. The active connection succeeded and all actions completed without reconnect symptoms. + +## Log Analysis + +- Backend log contains no errors, exceptions, or tracebacks. +- Round logs show direct fast-forward deltas of 3594 seconds for wait/short rest and 28794 seconds for long rest after the six-second action round. +- Entity logs show one wake boundary and one rest completion for each tested intent. diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 2fbae9dd..45e835e9 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -27,7 +27,7 @@ Save, load, autosave, evict и round loop согласованы одной sess 3. [Consistent save and eviction paths](tasks/phase1-task3-consistent-save-paths.md) 4. [Atomic load and connection-driven resume](tasks/phase1-task4-atomic-load-resume.md) -## Phase 2: Anchors, wait and sleep intents +## Phase 2: Anchors, wait and sleep intents ✓ Якорь становится свойством существа, активация больше не проверяет `PlayerCharacter`. Wait и sleep представлены сохраняемыми намерениями с длительностью и wake-точкой. Проверка: любое назначенное якорем существо удерживает локальную сцену активной; wait/sleep переживает save/load; без бодрствующего якоря мир быстро переходит к ближайшему пробуждению. @@ -63,7 +63,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 2 implementation complete. Ready to close phase. +**Current:** Phase 2 complete. Ready for Phase 3 task generation. ## Decisions From f216c97d85811efc6e65d9e857598faeb9458078 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:13:11 +0300 Subject: [PATCH 13/33] sprint 022 phase 3: task breakdown --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 6 ++- .../phase3-task1-travel-route-contract.md | 37 +++++++++++++++++ .../phase3-task2-travel-action-progression.md | 41 +++++++++++++++++++ .../tasks/phase3-task3-journey-ui.md | 38 +++++++++++++++++ 5 files changed, 122 insertions(+), 4 deletions(-) create mode 100644 docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md create mode 100644 docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 3c849ea5..c181c56a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 2 — Anchors, wait and sleep intents (COMPLETE) — 2026-07-11 +**Phase:** 3 — Travel as an intent (tasks generated) — 2026-07-11 -WAIT и sleep используют сохраняемый timed intent, эффекты отдыха применяются один раз при wake boundary. Integration: 160 passed. Phase-scoped E2E: 4/4 passed. Ready for Phase 3 task generation. +Фаза разбита на 3 задачи: строгий сохраняемый маршрут, пошаговое движение по рёбрам через travel intent и отображение путешествия в общем REST/WS-контракте и UI. Ready to start task 1. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 45e835e9..8466aa2b 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -47,7 +47,9 @@ Save, load, autosave, evict и round loop согласованы одной sess **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [Travel route contract](tasks/phase3-task1-travel-route-contract.md) +2. [Travel action and leg progression](tasks/phase3-task2-travel-action-progression.md) +3. [Journey status and UI](tasks/phase3-task3-journey-ui.md) ## Phase 4: Interruptible journeys and E2E closure @@ -63,7 +65,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 2 complete. Ready for Phase 3 task generation. +**Current:** Phase 3 tasks generated. Ready to start task 1. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md b/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md new file mode 100644 index 00000000..dc164757 --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md @@ -0,0 +1,37 @@ +# Task: Travel route contract + +**Date:** 2026-07-11 +**Sprint:** 022-intents-travel +**Phase:** 3 — Travel as an intent + +## Description + +Introduce a first-class travel intent that records a creature's destination, ordered route, current leg, and next arrival boundary. Travel state must be strict, typed, and persisted for every creature kind alongside the existing wait and sleep intents. Add deterministic weighted routing over `LocationGraph`, using edge distance as cost, so a destination several edges away produces one concrete route rather than an immediate teleport. + +Keep route calculation in the graph/core boundary and intent state in `core/`. This task establishes the contract and save/load behavior only. It does not start travel from an action or advance creatures through the route. + +## Tests First + +- On a branching graph, requesting a distant destination selects the reachable route with the shortest total distance and returns each intermediate location in order. +- An unreachable or unknown destination is rejected instead of producing a partial route; equal-cost alternatives resolve deterministically. +- A player, NPC, and generic creature saved during different legs of travel restore the same destination, remaining route, and next leg arrival boundary. +- The strict entity save schema rejects malformed travel state, including an empty remaining route, unknown fields, or a travel payload shaped like wait/sleep. + +## Implementation + +Extend the intent model to a typed union: keep the existing timed wait/sleep representation and add a focused travel representation with enough state to resume without recalculating a possibly changed route. Extend the strict Pydantic save union and entity serialization/restoration accordingly. Add shortest-path routing to `LocationGraph`; do not reuse battle-map pathfinding, which operates on combat grid cells and different costs. + +Likely files: `core/intent.py`, `core/location.py`, `layers/entities/save_models.py`, `layers/entities/entity_serialization.py`, `layers/entities/layer.py`, graph tests, and entity serialization tests. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Multi-edge routes are selected by total edge distance with deterministic tie-breaking. +- [ ] Travel progress has one strict runtime and save representation for all creature kinds. +- [ ] A mid-route save restores the exact remaining journey without recomputing it. + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md b/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md new file mode 100644 index 00000000..219f923f --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md @@ -0,0 +1,41 @@ +# Task: Travel action and leg progression + +**Date:** 2026-07-11 +**Sprint:** 022-intents-travel +**Phase:** 3 — Travel as an intent + +## Description + +Replace the `WAIT + travel_to` compatibility branch with `ActionType.TRAVEL`. Starting travel resolves a route, records a travel intent, and leaves the creature at its origin until the first leg's game-time boundary. Each boundary moves the creature to exactly the next graph node and schedules the following leg; the final boundary clears the intent and returns an eligible anchor to play. + +Route progression must use the same activation and fast-forward lifecycle as timed wait/sleep. It must never call `World.advance_time()` inside the action handler and must not teleport directly to the final destination. + +## Tests First + +- A creature starting a three-edge journey remains at the origin immediately after the action, then appears at each intermediate node only after that leg's travel time has elapsed. +- With no other awake anchor, the round loop fast-forwards one leg at a time and returns control after final arrival; with another awake anchor, normal rounds continue while the traveler remains dormant between boundaries. +- Saving after an intermediate arrival and loading the session continues along the stored remaining route and arrives once, without skipping or replaying a leg. +- Two traveling creatures whose routes share an intermediate node can occupy that node at the same game time without either being teleported to its destination. +- Unknown and unreachable destinations fail without changing location or leaving an intent; WAIT no longer accepts `travel_to`. + +## Implementation + +Add the action definition, dispatcher registration, provider exposure, and a travel handler that validates the destination and starts the first leg. Generalize the entities wake-boundary query and intent completion path so the next travel-leg arrival participates in `Round._fast_forward`. At a leg boundary, update `location_id`, then either schedule the next boundary through the graph's existing distance-to-travel-time contract or complete the intent. + +Keep built-in interruption rules out of this task and Phase 3. Phase 4 will define what combat, bodily events, or scene entry do to an in-progress journey. + +Likely files: `core/action.py`, `core/action_defs.py`, `service/action_dispatcher.py`, `rules/handlers/movement.py`, `layers/entities/intent_completion.py`, `layers/entities/activation_manager.py`, `layers/entities/layer.py`, `round.py`, and handler/round/session tests. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] `TRAVEL` is a peaceful action and `WAIT` has no travel parameter or teleport branch. +- [ ] Location changes occur only at graph-edge time boundaries, one edge at a time. +- [ ] Fast-forward, save/load, and concurrent travelers preserve route progress. +- [ ] Invalid travel is atomic from the caller's perspective. + +## Status + +`pending` diff --git a/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md b/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md new file mode 100644 index 00000000..1fd68e39 --- /dev/null +++ b/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md @@ -0,0 +1,38 @@ +# Task: Journey status and UI + +**Date:** 2026-07-11 +**Sprint:** 022-intents-travel +**Phase:** 3 — Travel as an intent + +## Description + +Expose the player's current travel state through the existing REST and WebSocket player-status contract, and update the location UI to send the first-class travel action. While a journey is active, the UI must show the final destination and route progress; after arrival it must return to the ordinary location/path view with no stale journey state. + +All labels and user-visible journey text must use the existing frontend i18n catalogs, with RU as the default locale. + +## Tests First + +- Starting travel from `LocationPanel` sends `name: "travel"` with a destination parameter, never a zero-hour wait payload. +- A mid-journey status payload identifies the final destination, current leg/progress, and arrival boundary; a completed journey omits active travel state and reports the arrived location. +- The UI renders an in-progress multi-edge journey from status data and switches cleanly to the destination's neighboring paths when the arrival turn is delivered. +- A save/load/reconnect during travel returns the same journey status and allows the UI to continue displaying progress until the one final completion. + +## Implementation + +Add a compact optional journey view to the shared `PlayerStatusData` contract and its TypeScript counterpart. Build it from the persisted travel intent in `service/session.py`, resolving location IDs to user-facing names at the service boundary. Update `LocationPanel` and focused component/store tests; add English and Russian gettext/i18n entries. Do not make the frontend infer route progress from event-log strings. + +Likely files: `service/dto.py`, `service/session.py`, adapter schema tests, `frontend/src/types/game.ts`, `frontend/src/components/game/LocationPanel.tsx`, its tests, and `frontend/src/i18n/locales/{en,ru}/game.json`. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] REST and WebSocket status use one typed journey contract. +- [ ] The location UI sends `TRAVEL` and shows persisted multi-leg progress. +- [ ] Arrival clears journey presentation and refreshes the visible location paths. +- [ ] New user-visible strings are localized in English and Russian. + +## Status + +`pending` From ab5e1280dc888675523b3b638ad5b81cc9f826c7 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:45:14 +0300 Subject: [PATCH 14/33] sprint 022 phase 3 task 1: persist travel routes --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../phase3-task1-travel-route-contract.md | 18 +++-- src/dnd_simulator/core/character.py | 4 +- src/dnd_simulator/core/intent.py | 27 ++++++++ src/dnd_simulator/core/location.py | 28 ++++++++ .../layers/entities/activation_manager.py | 3 +- .../layers/entities/entity_serialization.py | 32 ++++++--- src/dnd_simulator/layers/entities/layer.py | 31 ++++++--- .../layers/entities/save_models.py | 25 ++++++- tests/unit/test_divine_smite_combat.py | 3 +- tests/unit/test_entities_serialization.py | 66 ++++++++++++++++++- tests/unit/test_location_graph.py | 35 ++++++++++ 13 files changed, 241 insertions(+), 37 deletions(-) create mode 100644 tests/unit/test_location_graph.py diff --git a/docs/STATUS.md b/docs/STATUS.md index c181c56a..eb1fa52a 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 3 — Travel as an intent (tasks generated) — 2026-07-11 +**Phase:** 3 — Travel as an intent (task 1 done, task 2 pending) — 2026-07-11 -Фаза разбита на 3 задачи: строгий сохраняемый маршрут, пошаговое движение по рёбрам через travel intent и отображение путешествия в общем REST/WS-контракте и UI. Ready to start task 1. +Travel intent и его строгая save-модель сохраняют оставшийся маршрут и следующую границу прибытия. `LocationGraph` детерминированно выбирает кратчайший по суммарной длине путь. Ready to start task 2. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 8466aa2b..6d0a29a7 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -65,7 +65,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 3 tasks generated. Ready to start task 1. +**Current:** Phase 3 task 1 complete. Ready to start task 2. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md b/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md index dc164757..da73230f 100644 --- a/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md +++ b/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md @@ -25,13 +25,17 @@ Likely files: `core/intent.py`, `core/location.py`, `layers/entities/save_models ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] Multi-edge routes are selected by total edge distance with deterministic tie-breaking. -- [ ] Travel progress has one strict runtime and save representation for all creature kinds. -- [ ] A mid-route save restores the exact remaining journey without recomputing it. +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] Multi-edge routes are selected by total edge distance with deterministic tie-breaking. +- [x] Travel progress has one strict runtime and save representation for all creature kinds. +- [x] A mid-route save restores the exact remaining journey without recomputing it. ## Status -`pending` +`done` + +## Developer Notes + +Added deterministic Dijkstra routing whose tie-break is the lexicographic node route, plus a strict discriminated save union for timed and travel intents. `TravelIntent` persists the final destination, remaining nodes, journey start, and next arrival boundary; Phase 2 activation intentionally handles only `TimedIntent` until task 2 wires travel progression. The full check exposed an unrelated flaky smite test whose “guaranteed hit” could roll a natural 1; its combat RNG is now seeded, with no product behavior change. diff --git a/src/dnd_simulator/core/character.py b/src/dnd_simulator/core/character.py index be988767..0b233552 100644 --- a/src/dnd_simulator/core/character.py +++ b/src/dnd_simulator/core/character.py @@ -10,7 +10,7 @@ from dnd_simulator.core.class_features import ClassFeatures from dnd_simulator.core.conditions import Condition -from dnd_simulator.core.intent import TimedIntent +from dnd_simulator.core.intent import CreatureIntent from dnd_simulator.core.items import EquipmentSlot, Item from dnd_simulator.core.resource import ResourcePool from dnd_simulator.core.turn_budget import TurnBudget @@ -230,7 +230,7 @@ class Creature(Entity): xp_value: int = 0 # XP awarded to Character attacker on kill (0 for most creatures, set from CR for monsters) squad_id: str | None = None # which squad this creature belongs to (if materialized) is_anchor: bool = False - current_intent: TimedIntent | None = None + current_intent: CreatureIntent | None = None combat_position: tuple[int, int] | None = None # fixed starting position on battle map (x, y in feet) brain: Brain | None = field(default=None, repr=False) diff --git a/src/dnd_simulator/core/intent.py b/src/dnd_simulator/core/intent.py index c5857de0..0b6d6cf4 100644 --- a/src/dnd_simulator/core/intent.py +++ b/src/dnd_simulator/core/intent.py @@ -13,6 +13,7 @@ class IntentType(StrEnum): WAIT = "wait" SLEEP = "sleep" + TRAVEL = "travel" @dataclass(frozen=True) @@ -25,7 +26,33 @@ class TimedIntent: rest_type: RestType | None = None def __post_init__(self) -> None: + if self.kind not in (IntentType.WAIT, IntentType.SLEEP): + raise ValueError("timed intent must be wait or sleep") if self.wake_at_seconds < self.started_at_seconds: raise ValueError("wake time cannot precede intent start time") if self.rest_type is not None and self.kind is not IntentType.SLEEP: raise ValueError("rest completion requires a sleep intent") + + +@dataclass(frozen=True) +class TravelIntent: + """A persisted multi-leg journey waiting for its next edge arrival.""" + + started_at_seconds: int + destination_id: str + remaining_route: tuple[str, ...] + next_arrival_seconds: int + kind: IntentType = IntentType.TRAVEL + + def __post_init__(self) -> None: + if self.kind is not IntentType.TRAVEL: + raise ValueError("travel intent must use the travel kind") + if not self.remaining_route: + raise ValueError("travel route cannot be empty") + if self.remaining_route[-1] != self.destination_id: + raise ValueError("travel destination must be the final route node") + if self.next_arrival_seconds < self.started_at_seconds: + raise ValueError("arrival time cannot precede journey start time") + + +CreatureIntent = TimedIntent | TravelIntent diff --git a/src/dnd_simulator/core/location.py b/src/dnd_simulator/core/location.py index b0828f7a..ebf9a9e2 100644 --- a/src/dnd_simulator/core/location.py +++ b/src/dnd_simulator/core/location.py @@ -7,6 +7,7 @@ from __future__ import annotations +import heapq from dataclasses import dataclass @@ -82,3 +83,30 @@ def travel_seconds(self, from_id: str, to_id: str, speed_kmh: float = 5.0) -> in distance_km = edge.distance_m / 1000.0 hours = distance_km / speed_kmh return max(60, int(hours * 3600)) # minimum 1 minute + + def shortest_route(self, from_id: str, to_id: str) -> tuple[str, ...]: + """Return destination nodes for the shortest route by total edge distance.""" + if not self.has(from_id): + raise ValueError(f"Unknown route origin '{from_id}'") + if not self.has(to_id): + raise ValueError(f"Unknown route destination '{to_id}'") + if from_id == to_id: + return () + + queue: list[tuple[int, tuple[str, ...], str]] = [(0, (), from_id)] + best: dict[str, tuple[int, tuple[str, ...]]] = {from_id: (0, ())} + while queue: + distance, route, location_id = heapq.heappop(queue) + if best.get(location_id) != (distance, route): + continue + if location_id == to_id: + return route + for edge in sorted(self.neighbors(location_id), key=lambda item: item.target_id): + if not self.has(edge.target_id): + continue + candidate = (distance + edge.distance_m, (*route, edge.target_id)) + if edge.target_id not in best or candidate < best[edge.target_id]: + best[edge.target_id] = candidate + heapq.heappush(queue, (*candidate, edge.target_id)) + + raise ValueError(f"No route from '{from_id}' to '{to_id}'") diff --git a/src/dnd_simulator/layers/entities/activation_manager.py b/src/dnd_simulator/layers/entities/activation_manager.py index b6d8b465..072a9e90 100644 --- a/src/dnd_simulator/layers/entities/activation_manager.py +++ b/src/dnd_simulator/layers/entities/activation_manager.py @@ -13,6 +13,7 @@ import structlog from dnd_simulator.core.character import Creature, Entity +from dnd_simulator.core.intent import TimedIntent from dnd_simulator.core.models import Event from dnd_simulator.core.monster import EncounterEntry from dnd_simulator.layers.entities.encounters import check_encounters @@ -97,7 +98,7 @@ def update_activation( e.active = False e.current_intent = None continue - if e.current_intent is not None and now >= e.current_intent.wake_at_seconds: + if isinstance(e.current_intent, TimedIntent) and now >= e.current_intent.wake_at_seconds: complete_timed_intent(e, e.current_intent) e.current_intent = None logger.info("activation_wake_timer", entity_id=e.id) diff --git a/src/dnd_simulator/layers/entities/entity_serialization.py b/src/dnd_simulator/layers/entities/entity_serialization.py index 166dd1c1..4a865117 100644 --- a/src/dnd_simulator/layers/entities/entity_serialization.py +++ b/src/dnd_simulator/layers/entities/entity_serialization.py @@ -7,6 +7,7 @@ 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.intent import CreatureIntent, IntentType, TravelIntent from dnd_simulator.core.items import Item from dnd_simulator.core.models import EntityKind from dnd_simulator.core.player import PlayerCharacter @@ -25,6 +26,7 @@ PlayerSave, ResourcePoolSave, TimedIntentSave, + TravelIntentSave, TurnBudgetSave, ) @@ -201,21 +203,31 @@ def _creature_fields(entity: Creature) -> dict[str, object]: "xp_value": entity.xp_value, "squad_id": entity.squad_id, "is_anchor": entity.is_anchor, - "current_intent": ( - TimedIntentSave( - kind=entity.current_intent.kind, - started_at_seconds=entity.current_intent.started_at_seconds, - wake_at_seconds=entity.current_intent.wake_at_seconds, - rest_type=entity.current_intent.rest_type, - ) - if entity.current_intent is not None - else None - ), + "current_intent": _intent_save(entity.current_intent), "combat_position": entity.combat_position, } return data +def _intent_save(intent: CreatureIntent | None) -> TimedIntentSave | TravelIntentSave | None: + if intent is None: + return None + if isinstance(intent, TravelIntent): + return TravelIntentSave( + kind=IntentType.TRAVEL, + started_at_seconds=intent.started_at_seconds, + destination_id=intent.destination_id, + remaining_route=intent.remaining_route, + next_arrival_seconds=intent.next_arrival_seconds, + ) + return TimedIntentSave( + kind=IntentType.WAIT if intent.kind is IntentType.WAIT else IntentType.SLEEP, + started_at_seconds=intent.started_at_seconds, + wake_at_seconds=intent.wake_at_seconds, + rest_type=intent.rest_type, + ) + + def _item_save(item: Item | None) -> ItemSave | None: if item is None: return None diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index b6cefc2b..c84d5d86 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -154,10 +154,12 @@ def get_merchants_at(self, location_id: str, hour: int) -> list[Npc]: def get_nearest_wake_time(self) -> int | None: """Return the minimum intent wake time across all creatures, or None.""" + from dnd_simulator.core.intent import TimedIntent + wake_times = [ e.current_intent.wake_at_seconds for e in self._entities.values() - if isinstance(e, Creature) and e.current_intent is not None + if isinstance(e, Creature) and isinstance(e.current_intent, TimedIntent) ] return min(wake_times) if wake_times else None @@ -508,18 +510,29 @@ def load_state(self, state: dict[str, object]) -> None: ) elif budget_raw is None: entity.turn_budget = None - from dnd_simulator.core.intent import IntentType, TimedIntent + from dnd_simulator.core.intent import IntentType, TimedIntent, TravelIntent entity.is_anchor = bool(edata.get("is_anchor", entity.is_anchor)) intent_raw = edata.get("current_intent") if isinstance(intent_raw, dict): - rest_type_raw = intent_raw.get("rest_type") - entity.current_intent = TimedIntent( - kind=IntentType(str(intent_raw["kind"])), - started_at_seconds=int(intent_raw["started_at_seconds"]), - wake_at_seconds=int(intent_raw["wake_at_seconds"]), - rest_type=RestType(str(rest_type_raw)) if rest_type_raw is not None else None, - ) + kind = IntentType(str(intent_raw["kind"])) + if kind is IntentType.TRAVEL: + route_raw = intent_raw["remaining_route"] + assert isinstance(route_raw, list | tuple) + entity.current_intent = TravelIntent( + started_at_seconds=int(intent_raw["started_at_seconds"]), + destination_id=str(intent_raw["destination_id"]), + remaining_route=tuple(str(node) for node in route_raw), + next_arrival_seconds=int(intent_raw["next_arrival_seconds"]), + ) + else: + rest_type_raw = intent_raw.get("rest_type") + entity.current_intent = TimedIntent( + kind=kind, + started_at_seconds=int(intent_raw["started_at_seconds"]), + wake_at_seconds=int(intent_raw["wake_at_seconds"]), + rest_type=RestType(str(rest_type_raw)) if rest_type_raw is not None else None, + ) else: entity.current_intent = None position_raw = edata.get("combat_position") diff --git a/src/dnd_simulator/layers/entities/save_models.py b/src/dnd_simulator/layers/entities/save_models.py index 5df675b1..6f08279d 100644 --- a/src/dnd_simulator/layers/entities/save_models.py +++ b/src/dnd_simulator/layers/entities/save_models.py @@ -4,7 +4,7 @@ from typing import Annotated, Any, Literal -from pydantic import BaseModel, ConfigDict, Field, TypeAdapter +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, model_validator from dnd_simulator.core.action import ActionType from dnd_simulator.core.brain import BrainType @@ -109,12 +109,31 @@ class TurnBudgetSave(SaveModel): class TimedIntentSave(SaveModel): - kind: IntentType + kind: Literal[IntentType.WAIT, IntentType.SLEEP] started_at_seconds: int wake_at_seconds: int rest_type: RestType | None = None +class TravelIntentSave(SaveModel): + kind: Literal[IntentType.TRAVEL] + started_at_seconds: int + destination_id: str + remaining_route: tuple[str, ...] = Field(min_length=1) + next_arrival_seconds: int + + @model_validator(mode="after") + def validate_route(self) -> TravelIntentSave: + if self.remaining_route[-1] != self.destination_id: + raise ValueError("travel destination must be the final route node") + if self.next_arrival_seconds < self.started_at_seconds: + raise ValueError("arrival time cannot precede journey start time") + return self + + +IntentSave = Annotated[TimedIntentSave | TravelIntentSave, Field(discriminator="kind")] + + class EntitySaveBase(SaveModel): id: str name: str @@ -149,7 +168,7 @@ class CreatureFields(EntitySaveBase): xp_value: int = 0 squad_id: str | None = None is_anchor: bool = False - current_intent: TimedIntentSave | None = None + current_intent: IntentSave | None = None combat_position: tuple[int, int] | None = None diff --git a/tests/unit/test_divine_smite_combat.py b/tests/unit/test_divine_smite_combat.py index 09e02277..27015b63 100644 --- a/tests/unit/test_divine_smite_combat.py +++ b/tests/unit/test_divine_smite_combat.py @@ -6,6 +6,7 @@ from __future__ import annotations +import random from collections import defaultdict from dnd_simulator.core.action import ActionType @@ -108,7 +109,7 @@ def _target(*, hp: int = 50, ac: int = 5) -> Creature: def _setup_combat(attacker: Creature, target: Creature) -> tuple[CombatManager, dict[str, list[Event]]]: entities: dict[str, Creature] = {attacker.id: attacker, target.id: target} log: dict[str, list[Event]] = defaultdict(list) - cm = CombatManager(entities, log) # type: ignore[arg-type] + cm = CombatManager(entities, log, rng=random.Random(42)) # type: ignore[arg-type] cm.start_combat("arena") return cm, log diff --git a/tests/unit/test_entities_serialization.py b/tests/unit/test_entities_serialization.py index 1921571a..16d3cb64 100644 --- a/tests/unit/test_entities_serialization.py +++ b/tests/unit/test_entities_serialization.py @@ -8,7 +8,7 @@ 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.intent import IntentType, TimedIntent +from dnd_simulator.core.intent import IntentType, TimedIntent, TravelIntent from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.core.resource import ResourcePool, RestType from dnd_simulator.core.turn_budget import TurnBudget @@ -458,6 +458,42 @@ def test_anchor_and_timed_intents_round_trip_for_every_creature_kind(self) -> No assert restored_creature.is_anchor is False assert restored_creature.current_intent == creature.current_intent + def test_travel_progress_round_trips_for_every_creature_kind(self) -> None: + player = PlayerCharacter(id="hero", name="Hero", location_id="crossroads", is_anchor=True) + player.current_intent = TravelIntent( + started_at_seconds=100, + destination_id="harbor", + remaining_route=("bridge", "harbor"), + next_arrival_seconds=220, + ) + npc = _make_fighter_npc(location_id="bridge") + npc.current_intent = TravelIntent( + started_at_seconds=50, + destination_id="harbor", + remaining_route=("harbor",), + next_arrival_seconds=260, + ) + creature = _make_creature("wolf", location_id="forest") + creature.current_intent = TravelIntent( + started_at_seconds=70, + destination_id="cave", + remaining_route=("ridge", "cave"), + next_arrival_seconds=190, + ) + + restored_layer = EntitiesLayer() + restored_layer.load_state(EntitiesLayer(entities=[player, npc, creature]).get_state()) + + restored_player = restored_layer.get_entity("hero") + restored_npc = restored_layer.get_entity("fighter_npc") + restored_creature = restored_layer.get_entity("wolf") + assert isinstance(restored_player, PlayerCharacter) + assert isinstance(restored_npc, Npc) + assert isinstance(restored_creature, Creature) + assert restored_player.current_intent == player.current_intent + assert restored_npc.current_intent == npc.current_intent + assert restored_creature.current_intent == creature.current_intent + def test_idle_creature_round_trips_without_fabricated_wake_time(self) -> None: creature = _make_creature("idle") @@ -483,3 +519,31 @@ def test_invalid_timed_intent_payload_is_rejected(self, invalid_intent: dict[str with pytest.raises(ValidationError): EntitiesLayer().load_state(state) + + @pytest.mark.parametrize( + "invalid_intent", + [ + { + "kind": "travel", + "started_at_seconds": 10, + "destination_id": "harbor", + "remaining_route": [], + "next_arrival_seconds": 20, + }, + { + "kind": "travel", + "started_at_seconds": 10, + "destination_id": "harbor", + "remaining_route": ["harbor"], + "next_arrival_seconds": 20, + "extra": True, + }, + {"kind": "travel", "started_at_seconds": 10, "wake_at_seconds": 20}, + ], + ) + def test_invalid_travel_intent_payload_is_rejected(self, invalid_intent: dict[str, object]) -> None: + state = EntitiesLayer(entities=[_make_creature("broken")]).get_state() + state["entities"]["broken"]["current_intent"] = invalid_intent + + with pytest.raises(ValidationError): + EntitiesLayer().load_state(state) diff --git a/tests/unit/test_location_graph.py b/tests/unit/test_location_graph.py new file mode 100644 index 00000000..21acc77d --- /dev/null +++ b/tests/unit/test_location_graph.py @@ -0,0 +1,35 @@ +"""Product-level navigation behavior over the world location graph.""" + +import pytest + +from dnd_simulator.core.location import Location, LocationEdge, LocationGraph + + +def _graph() -> LocationGraph: + return LocationGraph( + [ + Location( + id="start", + name="Start", + region_id="r", + edges=(LocationEdge("long", 5), LocationEdge("alpha", 2), LocationEdge("beta", 2)), + ), + Location(id="long", name="Long Road", region_id="r", edges=(LocationEdge("goal", 5),)), + Location(id="alpha", name="Alpha Road", region_id="r", edges=(LocationEdge("goal", 3),)), + Location(id="beta", name="Beta Road", region_id="r", edges=(LocationEdge("goal", 3),)), + Location(id="goal", name="Goal", region_id="r"), + Location(id="island", name="Island", region_id="r"), + ] + ) + + +def test_shortest_route_uses_total_distance_and_stable_tie_breaking() -> None: + graph = _graph() + + assert graph.shortest_route("start", "goal") == ("alpha", "goal") + + +@pytest.mark.parametrize(("start", "destination"), [("missing", "goal"), ("start", "missing"), ("start", "island")]) +def test_shortest_route_rejects_unknown_or_unreachable_destinations(start: str, destination: str) -> None: + with pytest.raises(ValueError): + _graph().shortest_route(start, destination) From 69e2c7997a8fc0e6936287021372009346a514ac Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 14:55:02 +0300 Subject: [PATCH 15/33] sprint 022 phase 3 task 2: advance travel intents --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../phase3-task2-travel-action-progression.md | 20 +-- src/dnd_simulator/core/action.py | 1 + src/dnd_simulator/core/action_defs.py | 17 ++- src/dnd_simulator/core/creature_host.py | 2 + .../layers/entities/activation_manager.py | 10 +- .../layers/entities/intent_completion.py | 28 +++- src/dnd_simulator/layers/entities/layer.py | 19 ++- src/dnd_simulator/round.py | 8 +- src/dnd_simulator/rules/handlers/__init__.py | 2 + src/dnd_simulator/rules/handlers/movement.py | 75 ++++++----- .../service/action_dispatcher.py | 2 + tests/unit/test_action_dispatcher.py | 8 +- tests/unit/test_action_provider_isolated.py | 10 +- tests/unit/test_game_loop.py | 84 +++++++++++- tests/unit/test_handlers_movement.py | 122 ++++++------------ 17 files changed, 262 insertions(+), 152 deletions(-) diff --git a/docs/STATUS.md b/docs/STATUS.md index eb1fa52a..b0128ade 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 3 — Travel as an intent (task 1 done, task 2 pending) — 2026-07-11 +**Phase:** 3 — Travel as an intent (task 2 done, task 3 pending) — 2026-07-11 -Travel intent и его строгая save-модель сохраняют оставшийся маршрут и следующую границу прибытия. `LocationGraph` детерминированно выбирает кратчайший по суммарной длине путь. Ready to start task 2. +`TRAVEL` запускает сохраняемый маршрут без телепортации; activation передвигает существо по одному ребру на временной границе, а fast-forward останавливается на каждом прибытии. Ready to start task 3. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index 6d0a29a7..aa51a551 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -65,7 +65,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 3 task 1 complete. Ready to start task 2. +**Current:** Phase 3 task 2 complete. Ready to start task 3. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md b/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md index 219f923f..848dce76 100644 --- a/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md +++ b/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md @@ -28,14 +28,18 @@ Likely files: `core/action.py`, `core/action_defs.py`, `service/action_dispatche ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] `TRAVEL` is a peaceful action and `WAIT` has no travel parameter or teleport branch. -- [ ] Location changes occur only at graph-edge time boundaries, one edge at a time. -- [ ] Fast-forward, save/load, and concurrent travelers preserve route progress. -- [ ] Invalid travel is atomic from the caller's perspective. +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] `TRAVEL` is a peaceful action and `WAIT` has no travel parameter or teleport branch. +- [x] Location changes occur only at graph-edge time boundaries, one edge at a time. +- [x] Fast-forward, save/load, and concurrent travelers preserve route progress. +- [x] Invalid travel is atomic from the caller's perspective. ## Status -`pending` +`done` + +## Developer Notes + +`ActionType.TRAVEL` now resolves and stores the route without moving the actor or advancing world time. Activation advances every elapsed leg boundary, schedules the next one from the persisted boundary, and clears the intent only on final arrival; the round loop passes the world graph into activation and treats travel arrivals as fast-forward wake points. Legacy `WAIT + travel_to` behavior and schema were removed. Product tests cover intermediate nodes, invalid atomic starts, multi-leg fast-forward, two travelers sharing a node, and save/load continuation. diff --git a/src/dnd_simulator/core/action.py b/src/dnd_simulator/core/action.py index 663e199d..a9e17331 100644 --- a/src/dnd_simulator/core/action.py +++ b/src/dnd_simulator/core/action.py @@ -17,6 +17,7 @@ class ActionType(StrEnum): MOVE = "move" DASH = "dash" WAIT = "wait" + TRAVEL = "travel" USE_ITEM = "use_item" BLESS = "bless" EQUIP = "equip" diff --git a/src/dnd_simulator/core/action_defs.py b/src/dnd_simulator/core/action_defs.py index 208fa5ca..5904bbe9 100644 --- a/src/dnd_simulator/core/action_defs.py +++ b/src/dnd_simulator/core/action_defs.py @@ -274,14 +274,23 @@ def get_action_def(action_type: ActionType) -> ActionDef: cost_type=CostType.FREE, combat_mode=CombatMode.PEACEFUL_ONLY, ends_peaceful_turn=True, - params=( - ParamDef("hours", "integer", N_("How many hours to wait (default: 1)")), - ParamDef("travel_to", "string", N_("Location ID to travel to instead of waiting in place")), - ), + params=(ParamDef("hours", "integer", N_("How many hours to wait (default: 1)")),), llm_hint="Wait and do nothing for a period of time. Useful when nothing is happening.", ) ) +_reg( + ActionDef( + action_type=ActionType.TRAVEL, + description=N_("Travel to a location through the world map."), + cost_type=CostType.FREE, + combat_mode=CombatMode.PEACEFUL_ONLY, + ends_peaceful_turn=True, + params=(ParamDef("destination_id", "string", N_("Location ID to travel to"), required=True),), + llm_hint="Travel along the shortest known route to a location. Time passes for every route leg.", + ) +) + _reg( ActionDef( action_type=ActionType.USE_ITEM, diff --git a/src/dnd_simulator/core/creature_host.py b/src/dnd_simulator/core/creature_host.py index 879d66e1..0892b001 100644 --- a/src/dnd_simulator/core/creature_host.py +++ b/src/dnd_simulator/core/creature_host.py @@ -24,6 +24,7 @@ ) from dnd_simulator.core.character import Character, Creature, Entity from dnd_simulator.core.combat import CombatState + from dnd_simulator.core.location import LocationGraph from dnd_simulator.core.models import EmitFn, GameDateTime, QueryFn from dnd_simulator.core.turn_budget import TurnBudget @@ -53,6 +54,7 @@ def update_activation( time: GameDateTime, query_fn: QueryFn | None = None, emit_fn: EmitFn | None = None, + location_graph: LocationGraph | None = None, ) -> None: """Refresh active/dormant state based on proximity to awake anchors.""" ... diff --git a/src/dnd_simulator/layers/entities/activation_manager.py b/src/dnd_simulator/layers/entities/activation_manager.py index 072a9e90..b432a7c5 100644 --- a/src/dnd_simulator/layers/entities/activation_manager.py +++ b/src/dnd_simulator/layers/entities/activation_manager.py @@ -13,14 +13,15 @@ import structlog from dnd_simulator.core.character import Creature, Entity -from dnd_simulator.core.intent import TimedIntent +from dnd_simulator.core.intent import TimedIntent, TravelIntent from dnd_simulator.core.models import Event from dnd_simulator.core.monster import EncounterEntry from dnd_simulator.layers.entities.encounters import check_encounters -from dnd_simulator.layers.entities.intent_completion import complete_timed_intent +from dnd_simulator.layers.entities.intent_completion import advance_travel_intent, complete_timed_intent from dnd_simulator.layers.entities.materialization import update_lair_materialization, update_squad_materialization if TYPE_CHECKING: + from dnd_simulator.core.location import LocationGraph from dnd_simulator.core.models import EmitFn, GameDateTime, QueryFn from dnd_simulator.core.monster import MonsterTemplate from dnd_simulator.layers.entities.combat_manager import CombatManager @@ -65,6 +66,7 @@ def update_activation( time: GameDateTime, query_fn: QueryFn | None = None, emit_fn: EmitFn | None = None, + location_graph: LocationGraph | None = None, ) -> None: """Activate creatures near awake anchors, dormify the rest. @@ -102,6 +104,10 @@ def update_activation( complete_timed_intent(e, e.current_intent) e.current_intent = None logger.info("activation_wake_timer", entity_id=e.id) + elif isinstance(e.current_intent, TravelIntent) and now >= e.current_intent.next_arrival_seconds: + if location_graph is None: + raise RuntimeError("location graph is required to advance travel") + e.current_intent = advance_travel_intent(e, e.current_intent, now, location_graph) if e.is_anchor and e.current_intent is None: effective_location = e.current_location(hour) if isinstance(e, Npc) else e.location_id anchor_locations.add(effective_location) diff --git a/src/dnd_simulator/layers/entities/intent_completion.py b/src/dnd_simulator/layers/entities/intent_completion.py index b8b3537b..d37a9703 100644 --- a/src/dnd_simulator/layers/entities/intent_completion.py +++ b/src/dnd_simulator/layers/entities/intent_completion.py @@ -5,7 +5,8 @@ import structlog from dnd_simulator.core.character import Creature -from dnd_simulator.core.intent import TimedIntent +from dnd_simulator.core.intent import TimedIntent, TravelIntent +from dnd_simulator.core.location import LocationGraph from dnd_simulator.core.resource import RestType from dnd_simulator.rules.resources import reset_resources @@ -26,3 +27,28 @@ def complete_timed_intent(creature: Creature, intent: TimedIntent) -> None: reset_pools=reset_ids, healed=healed, ) + + +def advance_travel_intent( + creature: Creature, + intent: TravelIntent, + now_seconds: int, + location_graph: LocationGraph, +) -> TravelIntent | None: + """Advance every route leg whose arrival boundary has elapsed.""" + current = intent + while now_seconds >= current.next_arrival_seconds: + arrived_at = current.remaining_route[0] + creature.location_id = arrived_at + remaining = current.remaining_route[1:] + logger.info("travel_leg_arrive", entity_id=creature.id, location_id=arrived_at) + if not remaining: + return None + next_arrival = current.next_arrival_seconds + location_graph.travel_seconds(arrived_at, remaining[0]) + current = TravelIntent( + started_at_seconds=current.started_at_seconds, + destination_id=current.destination_id, + remaining_route=remaining, + next_arrival_seconds=next_arrival, + ) + return current diff --git a/src/dnd_simulator/layers/entities/layer.py b/src/dnd_simulator/layers/entities/layer.py index c84d5d86..91a6f83b 100644 --- a/src/dnd_simulator/layers/entities/layer.py +++ b/src/dnd_simulator/layers/entities/layer.py @@ -39,6 +39,7 @@ from dnd_simulator.layers.entities.save_models import EntitiesState if TYPE_CHECKING: + from dnd_simulator.core.location import LocationGraph from dnd_simulator.core.models import EmitFn, GameDateTime, QueryFn, TimeDelta from dnd_simulator.llm.summarizer import MemorySummarizer @@ -154,13 +155,16 @@ def get_merchants_at(self, location_id: str, hour: int) -> list[Npc]: def get_nearest_wake_time(self) -> int | None: """Return the minimum intent wake time across all creatures, or None.""" - from dnd_simulator.core.intent import TimedIntent + from dnd_simulator.core.intent import TimedIntent, TravelIntent - wake_times = [ - e.current_intent.wake_at_seconds - for e in self._entities.values() - if isinstance(e, Creature) and isinstance(e.current_intent, TimedIntent) - ] + wake_times: list[int] = [] + for entity in self._entities.values(): + if not isinstance(entity, Creature): + continue + if isinstance(entity.current_intent, TimedIntent): + wake_times.append(entity.current_intent.wake_at_seconds) + elif isinstance(entity.current_intent, TravelIntent): + wake_times.append(entity.current_intent.next_arrival_seconds) return min(wake_times) if wake_times else None def update_activation( @@ -168,9 +172,10 @@ def update_activation( time: GameDateTime, query_fn: QueryFn | None = None, emit_fn: EmitFn | None = None, + location_graph: LocationGraph | None = None, ) -> None: """Activate creatures near awake anchors, dormify the rest.""" - self._activation.update_activation(time, query_fn, emit_fn) + self._activation.update_activation(time, query_fn, emit_fn, location_graph) # -- Combat (delegated to CombatManager) -- diff --git a/src/dnd_simulator/round.py b/src/dnd_simulator/round.py index 94d071a4..053ea6cd 100644 --- a/src/dnd_simulator/round.py +++ b/src/dnd_simulator/round.py @@ -465,7 +465,9 @@ def run_round(self, *, skip_activation: bool = False) -> RoundResult: # Activate creatures near players, dormify the rest, materialize squads if not skip_activation: - self._host.update_activation(time, query_fn=query_fn, emit_fn=emit_fn) + self._host.update_activation( + time, query_fn=query_fn, emit_fn=emit_fn, location_graph=self._world.location_graph + ) active_count = len(self._host.get_active_creatures()) combat_locations = list(self._host.get_combat_locations()) @@ -508,7 +510,9 @@ def _activate(self) -> None: qfn = self._world.make_query_fn("entities") efn = self._world.make_emit_fn("entities") with self._mutation_scope(): - self._host.update_activation(self._world.time, query_fn=qfn, emit_fn=efn) + self._host.update_activation( + self._world.time, query_fn=qfn, emit_fn=efn, location_graph=self._world.location_graph + ) def run_loop(self, max_rounds: int | None = None) -> None: """Run rounds until no active creatures remain or stop() is called.""" diff --git a/src/dnd_simulator/rules/handlers/__init__.py b/src/dnd_simulator/rules/handlers/__init__.py index 1273832c..4e6433e0 100644 --- a/src/dnd_simulator/rules/handlers/__init__.py +++ b/src/dnd_simulator/rules/handlers/__init__.py @@ -23,6 +23,7 @@ handle_disengage, handle_move, handle_move_to, + handle_travel, handle_wait, ) from dnd_simulator.rules.handlers.reactions import handle_opportunity_attack @@ -53,6 +54,7 @@ "handle_sell", "handle_short_rest", "handle_take", + "handle_travel", "handle_unequip", "handle_use_item", "handle_wait", diff --git a/src/dnd_simulator/rules/handlers/movement.py b/src/dnd_simulator/rules/handlers/movement.py index 3b787311..26b8e013 100644 --- a/src/dnd_simulator/rules/handlers/movement.py +++ b/src/dnd_simulator/rules/handlers/movement.py @@ -220,43 +220,42 @@ def handle_disengage( def handle_wait(actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionContext, world: World) -> ActionResult: - """Wait: creature goes dormant until wake_at, or travels to a location. - - Travel: immediate move + time advance. - Plain wait: set a timed intent and mark dormant. Fast-forward in run_loop - handles the actual time advancement. - """ + """Wait in place until the requested wake boundary.""" from dnd_simulator.core.intent import IntentType, TimedIntent - from dnd_simulator.core.models import TimeDelta - - travel_to = action.params.get("travel_to") - if travel_to: - target_id = str(travel_to) - graph = world.location_graph - resolved_id: str | None = None - try: - graph.travel_seconds(actor.location_id, target_id) - resolved_id = target_id - except ValueError: - # No direct path — try by name match - for loc_id in graph.all_ids(): - loc = graph.get(loc_id) - if loc.name.lower() == target_id.lower(): - resolved_id = loc_id - break - if resolved_id is None: - return ActionResult(success=False, error=_("Unknown travel destination")) - try: - seconds = graph.travel_seconds(actor.location_id, resolved_id) - except ValueError: - return ActionResult(success=False, error=_("No route to destination")) - actor.location_id = resolved_id - world.advance_time(TimeDelta(seconds=seconds)) - else: - hours = int(str(action.params.get("hours", 1))) - if hours > 0: - now = world.time.to_total_seconds() - actor.current_intent = TimedIntent(IntentType.WAIT, now, now + hours * 3600) - actor.active = False - logger.info("wait_sleep", hours=hours, wake_at=actor.current_intent.wake_at_seconds) + + hours = int(str(action.params.get("hours", 1))) + if hours > 0: + now = world.time.to_total_seconds() + actor.current_intent = TimedIntent(IntentType.WAIT, now, now + hours * 3600) + actor.active = False + logger.info("wait_sleep", hours=hours, wake_at=actor.current_intent.wake_at_seconds) + return ActionResult() + + +def handle_travel(actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionContext, world: World) -> ActionResult: + """Start a persisted journey without moving or advancing world time inline.""" + from dnd_simulator.core.intent import TravelIntent + + destination_raw = action.params.get("destination_id") + if destination_raw is None: + return ActionResult(success=False, error=_("Travel requires a destination")) + destination_id = str(destination_raw) + try: + route = world.location_graph.shortest_route(actor.location_id, destination_id) + except ValueError: + return ActionResult(success=False, error=_("No route to destination")) + if not route: + return ActionResult(success=False, error=_("Already at destination")) + + now = world.time.to_total_seconds() + next_arrival = now + world.location_graph.travel_seconds(actor.location_id, route[0]) + actor.current_intent = TravelIntent(now, destination_id, route, next_arrival) + actor.active = False + logger.info( + "travel_start", + entity_id=actor.id, + destination_id=destination_id, + route=route, + next_arrival=next_arrival, + ) return ActionResult() diff --git a/src/dnd_simulator/service/action_dispatcher.py b/src/dnd_simulator/service/action_dispatcher.py index d66264e8..ed74e18e 100644 --- a/src/dnd_simulator/service/action_dispatcher.py +++ b/src/dnd_simulator/service/action_dispatcher.py @@ -48,6 +48,7 @@ handle_sell, handle_short_rest, handle_take, + handle_travel, handle_use_item, handle_wait, ) @@ -164,6 +165,7 @@ def create_dispatcher(world: World) -> ActionDispatcher: dispatcher.register(ActionType.DASH, handle_dash) dispatcher.register(ActionType.DISENGAGE, handle_disengage) dispatcher.register(ActionType.WAIT, handle_wait) + dispatcher.register(ActionType.TRAVEL, handle_travel) dispatcher.register(ActionType.USE_ITEM, handle_use_item) dispatcher.register(ActionType.BLESS, handle_bless) for _equip_action, _equip_handler in EQUIPMENT_HANDLERS.items(): diff --git a/tests/unit/test_action_dispatcher.py b/tests/unit/test_action_dispatcher.py index c9bd960f..ca78d318 100644 --- a/tests/unit/test_action_dispatcher.py +++ b/tests/unit/test_action_dispatcher.py @@ -357,16 +357,16 @@ def test_wait_emits_no_event(self) -> None: handle_wait(_creature(), action, emit, _PEACEFUL, world) assert len(emitted) == 0 - def test_wait_travel(self) -> None: + def test_wait_does_not_travel(self) -> None: world = MagicMock() - world.location_graph.travel_seconds.return_value = 600 + world.time.to_total_seconds.return_value = 0 creature = _creature() creature.location_id = "loc_a" action = Action(name=ActionType.WAIT, params={"travel_to": "loc_b"}) result = handle_wait(creature, action, _noop_emit, _PEACEFUL, world) assert result.success - assert creature.location_id == "loc_b" - world.advance_time.assert_called_once() + assert creature.location_id == "loc_a" + world.advance_time.assert_not_called() class TestHandleDash: diff --git a/tests/unit/test_action_provider_isolated.py b/tests/unit/test_action_provider_isolated.py index 1b8692c1..bb0ed4e7 100644 --- a/tests/unit/test_action_provider_isolated.py +++ b/tests/unit/test_action_provider_isolated.py @@ -40,7 +40,15 @@ } ) _PEACEFUL_BASE = frozenset( - {ActionType.IDLE, ActionType.SAY, ActionType.WAIT, ActionType.ATTACK, ActionType.END_TURN, ActionType.SKIP} + { + ActionType.IDLE, + ActionType.SAY, + ActionType.WAIT, + ActionType.TRAVEL, + ActionType.ATTACK, + ActionType.END_TURN, + ActionType.SKIP, + } ) _ALL_BASE = _COMBAT_BASE | _PEACEFUL_BASE diff --git a/tests/unit/test_game_loop.py b/tests/unit/test_game_loop.py index 9b4192bf..154ca89a 100644 --- a/tests/unit/test_game_loop.py +++ b/tests/unit/test_game_loop.py @@ -6,9 +6,9 @@ from dnd_simulator.core.awareness import CombatAwareness, PeacefulAwareness, PerceivedEvent from dnd_simulator.core.brain import Brain, PlayerBrain from dnd_simulator.core.character import Creature -from dnd_simulator.core.intent import IntentType, TimedIntent -from dnd_simulator.core.location import Location, LocationGraph -from dnd_simulator.core.models import GameDateTime +from dnd_simulator.core.intent import IntentType, TimedIntent, TravelIntent +from dnd_simulator.core.location import Location, LocationEdge, LocationGraph +from dnd_simulator.core.models import GameDateTime, TimeDelta from dnd_simulator.core.player import PlayerCharacter from dnd_simulator.core.world import World from dnd_simulator.layers.entities.layer import EntitiesLayer @@ -47,6 +47,18 @@ def _make_world(entities: list[Creature], hour: int = 10) -> World: ) +def _travel_world(entities: list[Creature], hour: int = 10) -> World: + world = _make_world(entities, hour) + world.location_graph = LocationGraph( + [ + Location("start", "Start", "r1", edges=(LocationEdge("road", 1000),)), + Location("road", "Road", "r1", edges=(LocationEdge("goal", 2000),)), + Location("goal", "Goal", "r1"), + ] + ) + return world + + class _EndTurnBrain(Brain): """Brain that immediately ends its turn (for single-round tests).""" @@ -402,3 +414,69 @@ def test_no_wake_at_means_loop_exits(self) -> None: game_round.run_loop() # should exit immediately assert world.time.to_total_seconds() == initial # no time passed + + +class TestTravelAndFastForward: + def test_journey_reaches_each_node_only_at_its_boundary(self) -> None: + now = GameDateTime(year=1, month=1, day=1, hour=10).to_total_seconds() + traveler = Creature(id="traveler", name="Traveler", location_id="start", is_anchor=True) + traveler.current_intent = TravelIntent(now, "goal", ("road", "goal"), now + 720) + world = _travel_world([traveler]) + layer = next(la for la in world.layers if isinstance(la, EntitiesLayer)) + + world.advance_time(TimeDelta(seconds=719)) + layer.update_activation(world.time, location_graph=world.location_graph) + assert traveler.location_id == "start" + + world.advance_time(TimeDelta(seconds=1)) + layer.update_activation(world.time, location_graph=world.location_graph) + assert traveler.location_id == "road" + assert traveler.current_intent == TravelIntent(now, "goal", ("goal",), now + 2160) + + world.advance_time(TimeDelta(seconds=1440)) + layer.update_activation(world.time, location_graph=world.location_graph) + assert traveler.location_id == "goal" + assert traveler.current_intent is None + assert traveler.active is True + + def test_two_travelers_share_intermediate_node_and_resume_after_save(self) -> None: + now = GameDateTime(year=1, month=1, day=1, hour=10).to_total_seconds() + first = Creature(id="first", name="First", location_id="start", is_anchor=True) + second = Creature(id="second", name="Second", location_id="start", is_anchor=True) + for traveler in (first, second): + traveler.current_intent = TravelIntent(now, "goal", ("road", "goal"), now + 720) + world = _travel_world([first, second]) + layer = next(la for la in world.layers if isinstance(la, EntitiesLayer)) + + world.advance_time(TimeDelta(seconds=720)) + layer.update_activation(world.time, location_graph=world.location_graph) + assert first.location_id == second.location_id == "road" + + restored = EntitiesLayer() + restored.load_state(layer.get_state()) + world.advance_time(TimeDelta(seconds=1440)) + restored.update_activation(world.time, location_graph=world.location_graph) + restored_first = restored.get_entity("first") + restored_second = restored.get_entity("second") + assert isinstance(restored_first, Creature) + assert isinstance(restored_second, Creature) + assert restored_first.location_id == restored_second.location_id == "goal" + assert restored_first.current_intent is None + assert restored_second.current_intent is None + + def test_round_loop_fast_forwards_all_legs_then_returns_control(self) -> None: + traveler = PlayerCharacter( + id="traveler", + name="Traveler", + location_id="start", + brain=_make_player_brain(Action(name=ActionType.TRAVEL, params={"destination_id": "goal"})), + ) + world = _travel_world([traveler]) + layer = next(la for la in world.layers if isinstance(la, EntitiesLayer)) + started_at = world.time.to_total_seconds() + + Round(world, layer).run_loop(max_rounds=2) + + assert traveler.location_id == "goal" + assert traveler.current_intent is None + assert world.time.to_total_seconds() - started_at == 2160 + 6 diff --git a/tests/unit/test_handlers_movement.py b/tests/unit/test_handlers_movement.py index 9c3c3781..0fb897eb 100644 --- a/tests/unit/test_handlers_movement.py +++ b/tests/unit/test_handlers_movement.py @@ -10,10 +10,12 @@ from dnd_simulator.core.action import Action, ActionType from dnd_simulator.core.character import Creature from dnd_simulator.core.combat import BattleMap, CombatState, Position +from dnd_simulator.core.intent import TravelIntent +from dnd_simulator.core.location import Location, LocationEdge, LocationGraph from dnd_simulator.core.models import ActionResult, Event from dnd_simulator.core.turn_budget import TurnBudget from dnd_simulator.i18n import set_language -from dnd_simulator.rules.handlers.movement import handle_dash, handle_disengage, handle_move, handle_wait +from dnd_simulator.rules.handlers.movement import handle_dash, handle_disengage, handle_move, handle_travel, handle_wait from dnd_simulator.rules.validation import ActionContext @@ -294,12 +296,11 @@ def test_wait_default_one_hour(self) -> None: assert mover.current_intent is not None assert mover.current_intent.wake_at_seconds == 5000 + 3600 - def test_wait_travel(self) -> None: - """Wait with travel_to changes location and advances time.""" + def test_wait_ignores_removed_travel_parameter(self) -> None: mover = _creature("mover") mover.location_id = "town_square" world = MagicMock() - world.location_graph.travel_seconds.return_value = 600 + world.time.to_total_seconds.return_value = 100 ctx = _ctx(mover) action = Action(name=ActionType.WAIT, params={"travel_to": "tavern"}) emit_fn = MagicMock(return_value=ActionResult()) @@ -307,90 +308,53 @@ def test_wait_travel(self) -> None: result = handle_wait(mover, action, emit_fn, ctx, world) assert result.success - assert mover.location_id == "tavern" - world.advance_time.assert_called_once() - - def test_wait_travel_no_direct_route_by_name_succeeds(self) -> None: - """Travel by name finds target and succeeds when a route exists.""" - mover = _creature("mover") - mover.location_id = "town_square" - world = MagicMock() - # Direct route fails, name-match route succeeds - loc = MagicMock() - loc.name = "Tavern" - world.location_graph.travel_seconds.side_effect = [ValueError("no direct route"), 300] - world.location_graph.all_ids.return_value = ["tavern_id"] - world.location_graph.get.return_value = loc - ctx = _ctx(mover) - action = Action(name=ActionType.WAIT, params={"travel_to": "tavern"}) - emit_fn = MagicMock(return_value=ActionResult()) - - result = handle_wait(mover, action, emit_fn, ctx, world) - - assert result.success - assert mover.location_id == "tavern_id" - world.advance_time.assert_called_once() - - def test_wait_travel_unreachable_returns_failure(self) -> None: - """Travel to an ID with no route returns success=False and location unchanged.""" - mover = _creature("mover") - mover.location_id = "starting_town" - world = MagicMock() - world.location_graph.travel_seconds.side_effect = ValueError("no route") - world.location_graph.all_ids.return_value = [] - ctx = _ctx(mover) - action = Action(name=ActionType.WAIT, params={"travel_to": "nowhere_island"}) - emit_fn = MagicMock(return_value=ActionResult()) - - result = handle_wait(mover, action, emit_fn, ctx, world) + assert mover.location_id == "town_square" + assert mover.current_intent is not None + world.advance_time.assert_not_called() - assert not result.success - assert result.error - assert mover.location_id == "starting_town" - assert mover.current_intent is None - def test_wait_travel_misnamed_target_returns_failure(self) -> None: - """Travel by name when no location matches returns success=False and location unchanged.""" +class TestHandleTravel: + def test_starts_multi_leg_journey_without_moving_or_advancing_time(self) -> None: mover = _creature("mover") - mover.location_id = "starting_town" + mover.location_id = "start" world = MagicMock() - # Direct route by ID fails (no such ID), name scan finds no match - world.location_graph.travel_seconds.side_effect = ValueError("no direct route") - loc_a = MagicMock() - loc_a.name = "Market Square" - loc_b = MagicMock() - loc_b.name = "The Docks" - world.location_graph.all_ids.return_value = ["market", "docks"] - world.location_graph.get.side_effect = lambda lid: {"market": loc_a, "docks": loc_b}[lid] - ctx = _ctx(mover) - action = Action(name=ActionType.WAIT, params={"travel_to": "Nonexistent Place"}) - emit_fn = MagicMock(return_value=ActionResult()) - - result = handle_wait(mover, action, emit_fn, ctx, world) + world.time.to_total_seconds.return_value = 100 + world.location_graph = LocationGraph( + [ + Location("start", "Start", "r", edges=(LocationEdge("road", 1000),)), + Location("road", "Road", "r", edges=(LocationEdge("goal", 2000),)), + Location("goal", "Goal", "r"), + ] + ) + + result = handle_travel( + mover, + Action(name=ActionType.TRAVEL, params={"destination_id": "goal"}), + MagicMock(return_value=ActionResult()), + _ctx(mover), + world, + ) - assert not result.success - assert result.error - assert mover.location_id == "starting_town" - assert mover.current_intent is None + assert result.success + assert mover.location_id == "start" + assert mover.current_intent == TravelIntent(100, "goal", ("road", "goal"), 820) + assert mover.active is False + world.advance_time.assert_not_called() - def test_wait_travel_name_match_unreachable_returns_failure(self) -> None: - """Travel where name matches but route to that location is unreachable returns failure.""" + def test_invalid_destination_does_not_mutate_creature(self) -> None: mover = _creature("mover") - mover.location_id = "starting_town" + mover.location_id = "start" world = MagicMock() - # Both direct and name-based route raise ValueError - world.location_graph.travel_seconds.side_effect = ValueError("no route") - loc = MagicMock() - loc.name = "Dead End" - world.location_graph.all_ids.return_value = ["dead_end_id"] - world.location_graph.get.return_value = loc - ctx = _ctx(mover) - action = Action(name=ActionType.WAIT, params={"travel_to": "Dead End"}) - emit_fn = MagicMock(return_value=ActionResult()) + world.location_graph = LocationGraph([Location("start", "Start", "r")]) - result = handle_wait(mover, action, emit_fn, ctx, world) + result = handle_travel( + mover, + Action(name=ActionType.TRAVEL, params={"destination_id": "missing"}), + MagicMock(return_value=ActionResult()), + _ctx(mover), + world, + ) assert not result.success - assert result.error - assert mover.location_id == "starting_town" + assert mover.location_id == "start" assert mover.current_intent is None From 1b647d4a7bebe54822a2b8f450bab912e89d681f Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Sat, 11 Jul 2026 15:18:49 +0300 Subject: [PATCH 16/33] sprint 022 phase 3 task 3: show journey status --- docs/STATUS.md | 4 +- docs/sprints/022-intents-travel/sprint.md | 2 +- .../tasks/phase3-task3-journey-ui.md | 23 +++-- .../src/components/game/LocationPanel.tsx | 13 ++- .../game/__tests__/LocationPanel.test.tsx | 94 +++++++++++++++++++ frontend/src/i18n/locales/en/game.json | 2 + frontend/src/i18n/locales/ru/game.json | 2 + frontend/src/types/game.ts | 11 +++ src/dnd_simulator/adapters/api/schemas.py | 10 ++ src/dnd_simulator/service/commands_player.py | 2 +- src/dnd_simulator/service/dto.py | 13 +++ src/dnd_simulator/service/session.py | 27 +++++- tests/unit/test_session_awareness.py | 35 +++++++ 13 files changed, 221 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/game/__tests__/LocationPanel.test.tsx diff --git a/docs/STATUS.md b/docs/STATUS.md index b0128ade..389e655e 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,9 +12,9 @@ **Sprint:** 022-intents-travel **Goal:** Любое существо может быть якорем и сохраняемым носителем намерения; ожидание, сон и путешествие исполняются во времени, travel движется по графу без телепортации, а save/load согласован с жизненным циклом раунда. **Started:** 2026-07-10 -**Phase:** 3 — Travel as an intent (task 2 done, task 3 pending) — 2026-07-11 +**Phase:** 3 — Travel as an intent (all tasks done, ready to close) — 2026-07-11 -`TRAVEL` запускает сохраняемый маршрут без телепортации; activation передвигает существо по одному ребру на временной границе, а fast-forward останавливается на каждом прибытии. Ready to start task 3. +Общий REST/WS player status отдаёт типизированный journey view с именами точек и оставшимся маршрутом; location UI отправляет `TRAVEL`, показывает прогресс и очищает его после прибытия. Фаза готова к закрытию. ### Phases diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md index aa51a551..2c228dd9 100644 --- a/docs/sprints/022-intents-travel/sprint.md +++ b/docs/sprints/022-intents-travel/sprint.md @@ -65,7 +65,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 3 task 2 complete. Ready to start task 3. +**Current:** Phase 3 tasks complete. Ready to close the phase. ## Decisions diff --git a/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md b/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md index 1fd68e39..d54902f6 100644 --- a/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md +++ b/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md @@ -25,14 +25,21 @@ Likely files: `service/dto.py`, `service/session.py`, adapter schema tests, `fro ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] REST and WebSocket status use one typed journey contract. -- [ ] The location UI sends `TRAVEL` and shows persisted multi-leg progress. -- [ ] Arrival clears journey presentation and refreshes the visible location paths. -- [ ] New user-visible strings are localized in English and Russian. +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] REST and WebSocket status use one typed journey contract. +- [x] The location UI sends `TRAVEL` and shows persisted multi-leg progress. +- [x] Arrival clears journey presentation and refreshes the visible location paths. +- [x] New user-visible strings are localized in English and Russian. ## Status -`pending` +`done` + +## Developer Notes + +`PlayerStatusData` теперь содержит опциональный типизированный journey view с разрешёнными именами текущей +точки, следующей остановки, оставшегося маршрута и финальной цели. REST и все WS round payloads строят его одним +`build_player_status`; после завершения сохранённого intent поле становится `null`. `LocationPanel` отправляет +`travel` с `destination_id`, показывает persisted route и возвращается к обычным путям после arrival update. diff --git a/frontend/src/components/game/LocationPanel.tsx b/frontend/src/components/game/LocationPanel.tsx index 9b7dfd20..bb877f0a 100644 --- a/frontend/src/components/game/LocationPanel.tsx +++ b/frontend/src/components/game/LocationPanel.tsx @@ -7,12 +7,13 @@ import { MapPin } from "lucide-react" export function LocationPanel() { const { t } = useTranslation(["game"]) const location = useGameStore((s) => s.location) + const journey = useGameStore((s) => s.player?.journey) const isMyTurn = useGameStore((s) => s.isMyTurn) if (!location) return null const sendGo = (locationId: string) => { - wsClient.send({ type: "action", name: "wait", params: { hours: 0, travel_to: locationId } }) + wsClient.send({ type: "action", name: "travel", params: { destination_id: locationId } }) useGameStore.getState().setWaitingForAction(true) } @@ -28,6 +29,16 @@ export function LocationPanel() {
{location.description}
)} + {journey && ( +{t("game:journey_to", { destination: journey.destination_name })}
++ {t("game:journey_route", { + route: [journey.current_location_name, ...journey.remaining_route].join(" → "), + })} +
+{t("game:paths")}
diff --git a/frontend/src/components/game/__tests__/LocationPanel.test.tsx b/frontend/src/components/game/__tests__/LocationPanel.test.tsx new file mode 100644 index 00000000..a90fbe5c --- /dev/null +++ b/frontend/src/components/game/__tests__/LocationPanel.test.tsx @@ -0,0 +1,94 @@ +import { act, render, screen } from "@testing-library/react" +import userEvent from "@testing-library/user-event" +import { beforeEach, describe, expect, it, vi } from "vitest" +import "@/i18n" +import { useGameStore } from "@/store/gameStore" +import { wsClient } from "@/transport/wsClient" +import type { LocationData, PlayerStatus } from "@/types/game" +import { LocationPanel } from "../LocationPanel" + +vi.mock("@/transport/wsClient", () => ({ + wsClient: { + send: vi.fn(), + onStatus: vi.fn(), + onMessage: vi.fn(), + getStatus: vi.fn(() => "disconnected"), + connect: vi.fn(), + disconnect: vi.fn(), + }, +})) + +const sendMock = wsClient.send as unknown as ReturnType