diff --git a/.claude/skills/update-docs/state.json b/.claude/skills/update-docs/state.json
index 7787285a..37fd58ce 100644
--- a/.claude/skills/update-docs/state.json
+++ b/.claude/skills/update-docs/state.json
@@ -1,6 +1,6 @@
{
- "last_run": "2026-07-10",
+ "last_run": "2026-07-12",
"last_run_mode": "full",
- "last_commit": "cb81afb",
- "last_full_run": "2026-07-10"
-}
\ No newline at end of file
+ "last_commit": "7575bd3",
+ "last_full_run": "2026-07-12"
+}
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 85e23d6f..99537b71 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -135,7 +135,7 @@ Round orchestrator (round.py):
run_loop:
while not stopped:
update_activation → get active creatures
- if none active → fast_forward to nearest wake_at or exit
+ if none active → fast_forward to nearest intent boundary or exit
run_round → on_round_end callback
Player input flow (service/, command-based):
@@ -176,7 +176,7 @@ Calendar: 30 days/month, 12 months/year.
```
Entity (id, name, location_id, active, on_tick)
-└── Creature (ability_scores, HP, AC, in_combat, is_dodging, is_disengaging, wake_at_seconds, brain, turn_budget, combat_position, equipped_weapon, equipped_armor, equipped_shield, equipped_head, equipped_feet, equipped_ring, resource_pools, faction_id, reputation, squad_id, xp_value, execute_action)
+└── Creature (ability_scores, HP, AC, in_combat, is_dodging, is_disengaging, is_anchor, current_intent, brain, turn_budget, combat_position, equipment, resource_pools, faction_id, reputation, squad_id, xp_value, execute_action)
└── Character (race, class, alignment, gold, appearance, class_features, level, experience, level_up_available, perceive_by_id, get_npc_data)
├── PlayerCharacter (interactive I/O, overrides take_turn directly)
└── Npc (role, personality, schedule, memory: NpcMemory, ai_type — brain assigned by content_loader/adapter)
@@ -184,7 +184,7 @@ Entity (id, name, location_id, active, on_tick)
All tracked entities live on the `EntitiesLayer`. The layer's `tick()` is a no-op — the Round orchestrator calls `run_creature_turn` directly for both combat and peaceful turns. `Entity.on_tick(hour)` is called by the Round to update NPC activity based on daily schedule.
-**Activation system:** `EntitiesLayer.update_activation(time)` runs at the start of each round. Players without `wake_at_seconds` are anchors; creatures at an anchor's location become active, all others go dormant. Creatures in combat stay active. NPCs are moved to their scheduled location when activated. The `wait` action sets `wake_at_seconds` on the creature and marks it dormant. When no active creatures exist, `Round.run_loop()` fast-forwards `World.advance_time()` to the nearest `wake_at`, re-runs activation, and continues. If nobody has a `wake_at`, the loop exits.
+**Activation system:** `EntitiesLayer.update_activation(time)` runs at the start of each round. Any living creature with `is_anchor=True` and no `current_intent` holds its location active; creatures at an anchor's location become active, all others go dormant. Creatures in combat stay active. NPCs are moved to their scheduled location when activated. Wait and sleep use persisted `TimedIntent`; travel uses persisted `TravelIntent` with a destination, remaining route, and next edge-arrival boundary. `Round.run_loop()` fast-forwards to the nearest intent boundary when no creature needs a turn. Travel advances one graph edge per boundary and can be interrupted by damage, combat, or arrival in an occupied scene.
`World.location_graph` (`LocationGraph`) provides a flat graph of all locations. Each `Location` node has a `region_id` tag (for weather/terrain lookups) and an optional `settlement_id` tag (for economy/NPC binding). Entities hold a `location_id` and the graph resolves which region/settlement they are in. Edges between locations carry distances in meters; `travel_seconds()` computes travel time.
@@ -277,9 +277,9 @@ Centralized derived stat computation replacing ad-hoc logic scattered across com
**Save format** (Sprint 021): one versioned Pydantic envelope — `SaveGame(schema_version=1, meta, world)` in `storage/save_schema.py`. `WorldSave` carries the world seed, dice RNG state, time, last tick times, and typed layer states: each layer owns a state model (`layers/*/state.py`, `layers/entities/save_models.py`) that is the authoritative format (`extra="forbid"`), while the `Layer` ABC keeps its dict-facing `get_state()/load_state()` signatures (core stays pydantic-free). Entity payloads are a discriminated union on `entity_type` (`PlayerSave`/`NpcSave`/`CreatureSave`/`ContainerSave`) built directly from live objects in `entity_serialization.py`; combat state persists turn order, round, battle map, and sides. `save_game()` and `autosave_session()` build the same envelope; `load_game()` validates it and rejects legacy saves without `schema_version`.
-**Reproducibility**: `DND_WORLD_SEED` (env; random + logged when absent) seeds the world in `game_service` — per-layer seeds are derived deterministically and passed to layer constructors, which own their `random.Random` streams (weather, politics, ecology roam/retreat/lair depletion, entity encounters). The dice RNG (`rules/dice.py`, `DND_DICE_SEED`) is a separate stream. All RNG states are serialized into the save, so a loaded game continues the same random sequences — same seed, same content → identical world evolution (pinned by `tests/unit/test_world_seed.py`).
+**Reproducibility**: `DND_WORLD_SEED` (env; random + logged when absent) seeds the world in `game_service`; per-layer seeds are derived deterministically and passed to layer-owned `random.Random` streams. Each `GameSession` owns its dice RNG (`DND_DICE_SEED` supplies the initial seed), so concurrent sessions cannot shift one another's rolls. All RNG states are serialized into the save, so a loaded game continues the same random sequences. Same seed plus the same content produces identical world evolution.
-**Autosave**: per-action (create_player), on session evict, on shutdown, plus a periodic lifespan task every `DND_AUTOSAVE_SECONDS` (default 120, cancelled before the final shutdown autosave). Autosave failures are logged, never suppressed.
+**Autosave and round lifecycle**: per-action (create_player), session evict, shutdown, and periodic autosave all take a session snapshot through the same world-mutation gate used by round actions. File I/O happens after the snapshot is built. Load stops the old round before replacing world/RNG state and resumes only after a player reconnects. Round shutdown has a bounded timeout (`DND_ROUND_STOP_TIMEOUT_SECONDS`, default 5); on timeout the session retains the live round/thread references and load or eviction aborts safely. Autosave failures are logged, never suppressed.
## Logging
diff --git a/CLAUDE.md b/CLAUDE.md
index c0dd42aa..06572cf6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -99,7 +99,7 @@ frontend/ — React + TypeScript SPA (Vite, shadcn/ui, Zustand)
### Entity Hierarchy
-`Entity` (id, name, location_id, active, on_tick) → `Creature` (ability scores, HP, AC, speed, attacks, conditions, inventory, gold, in_combat, is_dodging, is_disengaging, wake_at_seconds, brain, turn_budget, combat_position, equipped_weapon, equipped_armor, equipped_shield, equipped_head, equipped_feet, equipped_ring, resource_pools, faction_id, reputation, xp_value) → `Character` (race, class, alignment, class_features, level, experience, level_up_available) → `PlayerCharacter` / `Npc`. Creature delegates decisions to `brain.choose_action()`. `Container` (`core/container.py`) is a separate `Entity` sibling of `Creature` — inventory + gold, no HP/turn/brain — used for lair treasuries and other lootable world objects (`EntityKind.CONTAINER`). The `perceive()` method controls what information an observer sees about a target — LLM prompts never receive raw character data. All tracked entities live on the `EntitiesLayer`. `World.location_graph` (`LocationGraph`) maps locations to regions/settlements; entities reference `location_id`, and the graph resolves which region/settlement a location belongs to. NPCs have structured memory (`NpcMemory`: tags, recent, inner_state, current_conversation) readable by both LLM and RuleBrain; a `MemorySummarizer` compresses events into memory via LLM after combat/conversation ends. Combat is managed via `CombatState` (initiative order, round tracking, combat sides, auto-exit after 2 idle rounds) and `BattleMap` (2D grid with positions, walls, and movement). Movement rules live in `rules/movement.py` (D&D 5e diagonal distance, wall collision, occupied-cell blocking). `move_to(x, y)` action uses BFS pathfinding (`find_path`) and budget-aware stepping (`step_cost`); player-only (frontend click-to-move), excluded from LLM action schemas via `provider_managed=True`.
+`Entity` (id, name, location_id, active, on_tick) → `Creature` (physical stats, combat state, `is_anchor`, persisted `current_intent`, brain, turn budget, equipment and resources) → `Character` (race, class, alignment, class features, level and XP) → `PlayerCharacter` / `Npc`. Creature delegates decisions to `brain.choose_action()`. `Container` (`core/container.py`) is a separate `Entity` sibling of `Creature`: inventory + gold, no HP/turn/brain, used for lair treasuries and other lootable world objects (`EntityKind.CONTAINER`). The `perceive()` method controls what an observer sees; LLM prompts never receive raw character data. All tracked entities live on `EntitiesLayer`. `World.location_graph` maps locations to regions/settlements and supplies weighted routes for travel intents. NPCs have structured memory (`NpcMemory`) readable by both LLM and RuleBrain. Combat is managed via `CombatState` and `BattleMap`; grid movement rules live in `rules/movement.py`.
### Multi-Action Turns
@@ -133,7 +133,7 @@ Centralized derived stat computation (`core/modifiers.py` data types, `rules/mod
### Activation & Fast-Forward
-Proximity-based activation: `EntitiesLayer.update_activation(time)` runs at the start of each round. Players without `wake_at_seconds` are anchors — creatures at an anchor's location become active, all others go dormant. Creatures in combat stay active regardless. `wait` action sets `creature.wake_at_seconds` and marks it dormant. When no active creatures exist, `Round.run_loop()` fast-forwards time to the nearest `wake_at`, then re-checks activation. Content requires explicit locations — no auto-generation from regions. On entering a location with an encounter table, `ActivationManager` rolls encounters (see Lairs, Encounters & Loot).
+Anchor-based activation: `EntitiesLayer.update_activation(time)` runs at the start of each round. Any living creature with `is_anchor=True` and no current intent holds its scene active; creatures at an anchor's location become active, all others go dormant. Creatures in combat stay active regardless. Wait, sleep, and travel are typed, persisted `current_intent` values. When no creature needs a turn, `Round.run_loop()` fast-forwards to the nearest timer or travel-leg boundary. Travel follows deterministic shortest paths one graph edge at a time and can be interrupted by damage, combat, or an occupied scene. Content requires explicit locations. On entering a location with an encounter table, `ActivationManager` rolls encounters (see Lairs, Encounters & Loot).
### Lairs, Encounters & Loot
@@ -166,6 +166,7 @@ Kill reputation drop (`rules/reputation.py`): omniscient, delta scaled by victim
- `DND_LANGUAGE` env var selects game language (default: `ru`); locale files in `src/dnd_simulator/locale/`
- `DND_WORLD_SEED` env var seeds world simulation layers; when absent, `GameService` logs the generated seed.
- `DND_AUTOSAVE_SECONDS` env var controls periodic autosave interval (default: `120`; must be greater than `0`).
+- `DND_ROUND_STOP_TIMEOUT_SECONDS` bounds round-thread shutdown before load/eviction aborts safely (default: `5`).
- Save files: `saves/` directory (JSON)
- Backend API: `make serve` → http://localhost:8001/docs (Swagger UI)
- Frontend: `make frontend` → http://localhost:5173 (entry point, proxies /api to :8001)
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 0e029e50..3ca6d822 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -15,8 +15,8 @@
Эпики новой модели из [simulation-core.md](brainstorms/simulation-core.md). Порядок зависимостей: `save-schema` → `anchor-as-property` + `intents` → `trigger-table` → `inner-self` → `detail-ladder` → `quest-system`.
- [x] **must** `save-schema` — CLOSED Sprint 021: единая схема сейва (Pydantic по образцу контента) вместо рукописного формата в нескольких местах. Предусловие всей модели: «мир заморожен на полушаге» требует lossless-сейва (намерения, планы мозгов, триггеры, лог мыслей, зародыши субъектности). Стартовый кусок — «дедуп сериализации» в Sprint 020 phase 3, добивается отдельным спринтом
-- [ ] **must** `anchor-as-property` — якорь материализации как свойство существа, не тип: активация без `isinstance(PlayerCharacter)`. Player-agnostic движок (идея 1 вижна). Дешёвый первый шаг, можно брать рано
-- [ ] **must** `intents` — намерение как первоклассная сущность на Creature: спит/идёт/ждёт, каждое действие несёт длительность, встроенные прерывания (телесное, втягивание в сцену, прибытие, таймер), wake-точки как обобщение `wake_at_seconds`. Убивает travel-as-wait хак; входной таск — `travel-action-type`
+- [x] **must** `anchor-as-property` — FIXED Sprint 022: `is_anchor` на любом Creature, активация больше не зависит от `PlayerCharacter`
+- [x] **must** `intents` — FIXED Sprint 022: строгие сохраняемые wait/sleep/travel intent, встроенные прерывания и границы пробуждения/прибытия
- [ ] **must** `trigger-table` — парные декларативные триггеры `{on, until}` на существе (YAML + ручка ГМ), матчинг при эмиссии событий, активация/гашение dormant↔active, самогашение «моя роль сыграна» как действие мозга. Требует типизированной таксономии событий (фундамент заложен Sprint 020 phase 2). Поглощает `spawn-event-trigger`
- [ ] **should** `brain-gate-decide` — контракт Brain: дешёвый гейт (чистые ифы, «продолжаю намерение?», хоть каждый раунд) + дорогое решение (только границы: завершение, прерывание, триггер, ГМ). Инвариант: LLM никогда не вызывается на 6-секундном пути
- [ ] **should** `llm-turn-plan` — план хода внутри LlmBrain: 1 вызов на ход вместо 3-5, шаги плана со сверкой awareness дешёвой проверкой, перепланирование при сюрпризе. Плюс иерархия командир/исполнитель для сцен: LLM-стратегия на входе, правила разыгрывают раунды, reassess-триггеры (союзник упал, HP < 1/2, враг сдаётся, появился поименованный) поднимают момент до сюжетного решения. Поглощает `combat-reassess`. Движок не меняется — всё внутренности мозга
@@ -73,7 +73,7 @@
- [ ] **could** `drag-resize-panels` — Drag-and-drop / resizable панели на dashboard
- [ ] **could** `mobile-layout` — Мобильная адаптация dashboard
- [ ] **could** `log-filter-tabs` — Фильтрация лога табами (Все/Бой/Диалоги)
-- [ ] **should** `attack-buttons-accessible-names` — кнопки Attack в nearby-списке и action bar имеют одинаковые accessible names: цели неразличимы для автоматизации и скринридеров — E2E-смоук бил не в ту цель (убил торговку Гретту). Добавить aria-label с именем цели. Побочная аномалия того прогона (не отрепрожена чисто): свежая сессия в том же процессе увидела пустой рынок и зависла в «Waiting for turn…» — если всплывёт снова, разбирать отдельно. E2E sprint 021 close
+- [x] **should** `attack-buttons-accessible-names` — кнопки Attack/Talk/Inspect в nearby-списке и в inspect-модалке получили target-aware `aria-label` по уникальному `entity.id` (тот же контракт, что у action-bar `TargetDropdown`); SmiteChoice тоже именует цель по id. Закрыто в Sprint 022 phase 4 task 3: EN/RU уникальность подтверждена браузерным E2E (три NPC на рынке → три различимых имени) + компонентные тесты. E2E sprint 021 close
- [ ] **should** `master-panel-creature-inventory` — `CreatureResponse` / `all_entities` query не включают inventory/equipped_weapon; мастер не видит предметы существ. Добавить поля в схему и query
- [x] `master-give-item-ui` — ~~endpoint для give_item есть, кнопки нет~~ FIXED Sprint 007 phase 2: кнопка «Выдать предмет» в карточке существа
- [x] `inspect-as-idle-param` — ~~inspect шёл как `Action(IDLE, {inspect_target})`~~ FIXED Sprint 009 phase 4: клиентская NpcInspectModal из awareness
@@ -81,13 +81,13 @@
## Engine & Session
-- [ ] **should** `save-round-concurrency` — save/load/autosave снимают `session.world` без синхронизации с живым раунд-тредом (`commands_save.py`, `app.py` periodic autosave, `session.py`): `Round.run_loop()` мутирует мир в фоне, периодический автосейв (Sprint 021) сделал гонку регулярной — риск порванного сейва/`RuntimeError: dict changed size`. Нужна session-уровневая критическая секция сейва/загрузки, согласованная с round lifecycle (manual save, autosave, periodic, evict, load) + concurrency-тест. Audit 2026-07-10, top-кандидат ближайшего спринта
-- [ ] **must** `travel-action-type` — Отдельный `ActionType.TRAVEL` с длительностью и по-рёберным движением по графу локаций: путешественник всегда в конкретной локации, пути активных пересекаются (дорожные встречи), прерывание — всегда в конкретной точке. Первый кирпич эпика `intents` (повышен should→must). Текущий хак: `LocationPanel` шлёт `Action(WAIT, {hours: 0, travel_to})` с телепортом в конце
+- [x] **should** `save-round-concurrency` — FIXED Sprint 022 phase 1: snapshot и раундовые мутации используют единый session-level world gate; load, autosave и eviction согласованы с round lifecycle
+- [x] **must** `travel-action-type` — FIXED Sprint 022 phase 3: `ActionType.TRAVEL` движется по рёбрам графа и сохраняет маршрут без телепорта
- [ ] **should** `npc-instant-say-response` — dormant отвечает пассивно, не просыпаясь (simulation-core): после `say` дать существам в локации отреагировать в том же запросе (1 раунд). Сейчас NPC отвечают только при `advance_time`
- [ ] **could** `list-npcs-iterate-entities` — `list_npcs` итерирует по регионам; NPC в несуществующем регионе выпадает из списка. Итерировать по entities напрямую
- [x] **should** `periodic-autosave-scheduler` — FIXED Sprint 021 phase 3 (`DND_AUTOSAVE_SECONDS`, cancel до финального сейва): фоновый asyncio таск в FastAPI lifespan каждые ~2 мин вызывает `autosave_all_sessions()`; cancel на shutdown перед финальным autosave. Дополняет per-action и shutdown автосейв. Повышен could→should: «мир заморожен на полушаге» (simulation-core) требует надёжного автосейва
- [ ] **could** `control-interfaces-donor` — Донор-ветка `sprint/020-control-interfaces` и PR #15: identity/roles, три линзы и admin park реализованы против до-thermo control-plane. При планировании control-interfaces использовать как референс задач и тестов; raw merge невозможен из-за конфликтов с thermo-sweep. Grace-period и spectator уже перенесены отдельно
-- [ ] **should?** `wait-no-fastforward-with-npc` — **требует проверки: баг или медленно-но-корректно.** `wait` не делает fast-forward, когда в локации игрока сидит активный rule-NPC — вместо прыжка к `wake_at` раунд тикает по 6 c, и управление к игроку возвращается только через ~600 раундов (1 час игрового времени). Ожидание (playbook 2.3): время сдвигается на 1 час, ход быстро возвращается. **Repro (E2E sprint 020 phase 2):** мир «Долина Мечей», сессия 283d42a2, локация «Солёный Якорь» (`silverport_city_tavern`) с co-located rule-NPC «Марта»; игрок Grimwald QA (Fighter L1) жмёт «Ждать» → action bar застревает на «Ожидание хода…». Бэклог: `wait_sleep` hours=1, `wake_at=46326855600`, но `round_end` показывает `game_time` сдвинутым лишь на 6 c (`second=6`) — fast-forward «нет активных существ → прыжок к ближайшему wake_at» не срабатывает, т.к. Марта остаётся активной. Гипотеза: при `wait` игрок получает `wake_at` и перестаёт быть anchor'ом, значит co-located NPC тоже должен уйти в dormant и включить fast-forward — но не уходит. Проверить `ActivationManager.update_activation` / anchor-логику и путь fast-forward в `Round.run_loop`. NB: наблюдалось после evict→reconnect (см. `session-disconnect-debounce`), но поведение `wait` от этого не зависит. Смежно: `test-gap-ws-fastforward`. По simulation-core гипотеза корректна: расписание-NPC без якоря рядом не должен оставаться активным; тот самый путь, который перепишут `intents`/`anchor-as-property`, — но проверить/починить стоит уже сейчас
+- [x] **should** `wait-no-fastforward-with-npc` — FIXED Sprint 022 phase 2: intent делает якорь dormant, соседний RuleBrain NPC больше не удерживает сцену, мир быстро доходит до ближайшей wake-точки
- [ ] **could** `saved-session-accumulation` — тест-гигиена закрыта Sprint 021 phase 3 (интеграционный стек чистит saves/ через session fixture); остаётся UX-половина (пагинация/фильтр/TTL в Sessions-вкладке). Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке
- [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` **сразу** ставит раунд на паузу (`stop_round`), а откладывает только выселение из реестра: ставится grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`), reconnect в окне отменяет timer, а вернувшийся игрок перезапускает раунд через `start_round`. Раньше откладывались И stop_round, И evict — из-за этого player-less раунд-луп продолжал крутить ходы NPC в grace-окне (при сетевом блипе игрок молча терял боевые ходы), а поскольку все сессии тянут один процесс-глобальный RNG костей, пересекающиеся «осиротевшие» раунды делали seed-зависимые integration-тесты недетерминированными (`test_player_state_xp::test_rest_status_updated_after_kill` флакал в CI). Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`.
@@ -174,6 +174,7 @@
- [x] **should** `silent-failure-autosave` — FIXED Sprint 021 phase 3 (+ гвард на evict-после-DELETE): 3x contextlib.suppress(Exception) вокруг autosave. Логировать ошибки, не глушить
- [x] `silent-failure-awareness` — ~~awareness_builder.py 6x broad except Exception~~ FIXED Sprint 012 phase 4: narrowed to KeyError/LookupError
- [x] `silent-failure-movement` — ~~handle_wait except ValueError: pass~~ FIXED Sprint 020 phase 1 task 2: недостижимый/несуществующий travel-таргет → `ActionResult(success=False)`
+- [ ] **should** `action-error-kills-round-loop` — ожидаемая ошибка параметров action не изолирована от игрового цикла: post-audit E2E Sprint 022 отправил `travel` без обязательного `destination_id`, `ActionDispatcher` выбросил `ValueError`, а `GameSession.run_round_loop()` завершил весь round loop и показал игроку `GAME OVER`. UI-путь исправлен в `ed32eff`, но malformed WS payload или будущая клиентская ошибка всё ещё могут убить сессию. Валидировать action до передачи в round либо преобразовывать ожидаемые dispatch/handler errors в неуспешный `ActionResult`; добавить WS/session regression, подтверждающий, что после отклонённого action следующий ход остаётся играбельным. Связан с `action-params-validation`, но тот пункт закрывает входную схему, а этот — failure containment.
- [x] `schema-form-growing` — ~~frontend SchemaForm.tsx 488 строк, 30+ nested helpers~~ FIXED Sprint 020 phase 4: `FieldShell`, `schemaResolve.ts`, `localizedCodec.ts`, один `buildDefaults`; `SchemaForm.tsx` 373 строки
- [ ] **should** `llm-imports-layer-models` — llm/brain.py и llm/summarizer.py импортируют из layers.entities.models (Npc, NpcMemory). llm не должен зависеть от layers
- [ ] **should** `round-imports-entities-layer-v2` — round.py:31 напрямую импортирует EntitiesLayer (sprint 012 re-introduced coupling). Взаимодействовать через World/Layer interface
@@ -226,7 +227,7 @@
- [ ] **should** `test-gap-save-commands` — autosave_all_sessions, delete_save, list_saves без unit-тестов (commands_save round-trip частично закрыт Sprint 019 phase 1)
- [ ] **should** `test-gap-content-routes` — list_catalog_entries, list_schemas, get_schema, list_refs без тестов
- [ ] **should** `test-gap-master-routes` — list_library_templates, fork_world_layer без тестов
-- [ ] **could** `test-gap-ws-fastforward` — player wait → time skip → NPC resume не тестируется (см. живой repro в `wait-no-fastforward-with-npc`)
+- [x] **could** `test-gap-ws-fastforward` — FIXED Sprint 022 phase 2: реальный websocket path проверяет wait → fast-forward → возврат хода без NPC flood
- [ ] **could** `test-gap-ws-disconnect-npc` — disconnect during NPC turn + reconnect не тестируется
- [ ] **could** `test-gap-ws-npc-combat-turn` — NPC full multi-action RuleBrain combat turn только indirect
- [ ] **should** `test-gap-action-provider` — rules/action_provider.py без unit-тестов
diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md
index 77f1e4ef..94d474bd 100644
--- a/docs/ROADMAP.md
+++ b/docs/ROADMAP.md
@@ -29,7 +29,7 @@ BattleMap (2D grid), инициатива, auto-exit после 2 idle раун
React + TypeScript + shadcn/ui, dark theme. Игровой экран (EventLog, BattleMap, ActionBar, Nearby/Location/Character панели) + мастер-панель (World/Creatures/Time/Saves). WebSocket для real-time взаимодействия.
### Level 0 — Фундамент game loop
-Proximity-based активация существ: NPC рядом с игроком active, остальные dormant. Wait + fast-forward: `wake_at_seconds` на существе, Round.run_loop() мотает время до ближайшего пробуждения. Explicit locations — каждый мир обязан определить locations явно, убрана автогенерация из регионов. NPC перемещаются по расписанию при активации. Round lifecycle в GameSession.
+Anchor-based активация существ: NPC рядом с бодрствующим якорем active, остальные dormant. Wait/sleep/travel хранятся как типизированные intent; Round.run_loop() мотает время до ближайшей границы намерения. Explicit locations — каждый мир обязан определить locations явно, убрана автогенерация из регионов. NPC перемещаются по расписанию при активации. Round lifecycle в GameSession.
→ [брейншторм](brainstorms/ecs-and-content.md)
### Level 1 — Conditions, BrainFactory, валидация
@@ -127,6 +127,10 @@ Paladin L1-L2 как первый caster-класс. Phase 1: spell slots как
Первый эпик цепочки simulation-core. Phase 1: единый `DND_WORLD_SEED` — слоевые сиды выводятся детерминированно в `game_service`, слои владеют своими `random.Random` (погода, политика, roam/retreat/деплит логова, encounter rolls), процесс-глобальный `random` из `layers/` убран, сквозной пин детерминизма (`test_world_seed.py`: один сид → идентичный `World.save()`). Phase 2: версионированная Pydantic-схема сейва — `SaveGame(schema_version=1)` в `storage/save_schema.py`, типизированные state-модели слоёв (`extra="forbid"`), entity-сейвы как discriminated union, построение напрямую из объектов, combat sides в сейве (закрыт lossless-пробел), состояние RNG (слоевые + dice) сериализуется и продолжает последовательности после load, legacy-форматы отклоняются. Phase 3: периодический автосейв (`DND_AUTOSAVE_SECONDS`, cancel до финального сейва), ошибки автосейва логируются вместо suppress, гвард на evict-после-DELETE (заодно закрыл воскрешение удалённой сессии), интеграционный стек чистит `saves/`. Закрыты backlog: `save-schema`, `layer-rng-threading`, `test-gap-world-rng-determinism`, `periodic-autosave-scheduler`, `silent-failure-autosave`.
→ [план спринта](sprints/021-save-schema/sprint.md)
+### Sprint 022 — Intentions & Travel (фазы 1-5)
+Второй эпик simulation-core. Якорь стал свойством любого существа; wait, sleep и travel представлены строгими сохраняемыми intent. Travel идёт по кратчайшему маршруту по рёбрам графа, сохраняется посреди пути и прерывается телесным событием, боем или встречей в активной сцене. Session-owned dice RNG, единый world-mutation gate и bounded round shutdown согласовали save/load/autosave с живым раундом. Post-audit E2E: 8/8, integration: 160/160.
+→ [план спринта](sprints/022-intents-travel/sprint.md)
+
## Planned
### Level 2 — Расходуемые ресурсы
@@ -137,8 +141,8 @@ Spell slots, ki, rage. Дополнительные типы брони и ор
Заклинания как YAML, интерактивные объекты (двери, сундуки).
→ [брейншторм](brainstorms/ecs-and-content.md)
-### Simulation Core — намерения, триггеры, внутреннее я, лестница детализации
-Заменяет прежний план «Phase 3 — Автономные тики» (периодические тики отброшены в пользу decision-точек). Первый эпик (единая схема сейва + воспроизводимость) закрыт Sprint 021. Цепочка эпиков: ~~единая схема сейва~~ → якорь-как-свойство + намерения (спит/идёт/ждёт, travel по рёбрам) → парные триггеры `{on, until}` активации/гашения → внутреннее я NPC (цели, отношения, живой alignment, переваривание + правиловый близнец) → лестница детализации поселений (событийная запись, храповик субъектности) → квесты как контент поверх целей и триггеров.
+### Simulation Core — триггеры, внутреннее я, лестница детализации
+Заменяет прежний план «Phase 3 — Автономные тики» (периодические тики отброшены в пользу decision-точек). Единая схема сейва и воспроизводимость закрыты Sprint 021, якоря и намерения закрыты Sprint 022. Цепочка эпиков: ~~единая схема сейва~~ → ~~якорь-как-свойство + намерения~~ → парные триггеры `{on, until}` активации/гашения → внутреннее я NPC (цели, отношения, живой alignment, переваривание + правиловый близнец) → лестница детализации поселений (событийная запись, храповик субъектности) → квесты как контент поверх целей и триггеров.
→ [брейншторм](brainstorms/simulation-core.md), эпики в [BACKLOG](BACKLOG.md#simulation-core-брейншторм-2026-07-04)
### World Builder (advanced)
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 4ad8f9a9..49ef9354 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -2,9 +2,9 @@
Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас".
-**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` планировать только после триггеров и целей.
+**Last updated:** 2026-07-12
+**Position:** Sprint 022 закрыл второй эпик simulation-core: player-agnostic якоря, сохраняемые wait/sleep/travel intent, по-рёберное путешествие и согласованный с round lifecycle save/load/autosave. Классовые механики на уровне D&D L2 (Fighter / Rogue / Paladin).
+**Next:** Активного спринта нет. Главный следующий кандидат: `trigger-table`; смежные кандидаты — `brain-gate-decide` и containment ожидаемых action errors.
**Blockers:** нет.
## Current Sprint
@@ -25,6 +25,7 @@ No active sprint.
| Sprint | Goal | Started | Completed |
|--------|------|---------|-----------|
+| 022-intents-travel | Player-agnostic якоря и сохраняемые wait/sleep/travel intent; travel по рёбрам; согласованный lifecycle save/load/autosave | 2026-07-10 | 2026-07-12 |
| 021-save-schema | Версионированная Pydantic-схема сейва (schema_version=1, RNG в сейве, combat sides), воспроизводимость мира от DND_WORLD_SEED, периодический автосейв | 2026-07-10 | 2026-07-10 |
| 020-thermo-sweep | Закрыть структурный долг из термоядерного ревью: корректность + чистота rules, типизация границ, backend/frontend decomposition, сверка с simulation-core | 2026-06-30 | 2026-07-10 |
| 019-control-plane-prep | Отвердить control-plane под разрез на роли: GameService 1044→357 (миксины WorldBuilderCommands/PlayerCommands), тест-сетка на session, развязка core/adapter (action_parsing seam, public World query API), видимые дырки (combat-log i18n, encounter-перцептор, труп-кнопки) | 2026-06-28 | 2026-06-29 |
diff --git a/docs/audit.md b/docs/audit.md
index 2506e462..e6648dc3 100644
--- a/docs/audit.md
+++ b/docs/audit.md
@@ -1,91 +1,86 @@
# Code Audit
-> **Date**: 2026-07-10
-> **Scope**: full codebase, post Sprint 021 (`b5cfc5d`)
+> **Date**: 2026-07-12
+> **Scope**: full codebase, post Sprint 022 Phase 5 (`9f84fd4`)
> Transient snapshot. Canonical tracking lives in [BACKLOG.md](BACKLOG.md); known backlog items are marked `known` and should not be duplicated during triage.
## Summary
- Dead code: 0 issues
-- Code smells: 5 issues
+- Code smells: 4 issues
- Security: 4 issues
- Architecture violations: 0 issues
- Convention violations: 3 issues
- Layer contract: 0 issues
-- Test gaps: 4 issues
+- Test gaps: 2 issues
- Vision drift: 0 issues
-**Total: 16 issues.** Sprint 021 closed the important Sprint 020 audit risk: world randomness is now owned by per-layer RNGs, layer and dice RNG state are in the versioned `SaveGame` envelope, legacy saves are rejected, and periodic autosave logs failures instead of suppressing them. The remaining fresh risk is concurrency around save/load/autosave while a round thread can mutate the same session world.
+**Total: 13 issues.** Phase 5 closed the previous audit's only fresh operational risk: round shutdown now uses a bounded deadline, preserves live lifecycle references after timeout, and prevents disconnect, load, or eviction from continuing an unsafe operation. Focused unit tests cover timeout, retry, reconnect, load, and deferred eviction; Phase 5 E2E is green. No new sprint blocker was found.
## Dead Code
| File | Issue | Action |
|------|-------|--------|
-| none | `uv run ruff check src/ --select F401` is clean. No `TODO/FIXME/HACK/XXX` in `src/`. | none |
+| none | `uv run ruff check src/ --select F401` is clean. No `TODO/FIXME/HACK/XXX` in `src/`; no Phase 5 lifecycle branch is orphaned. | none |
## Code Smells
| File | Issue | Suggestion |
|------|-------|------------|
-| `src/dnd_simulator/service/commands_save.py:18`, `src/dnd_simulator/adapters/api/app.py:50`, `src/dnd_simulator/service/session.py:461` | `fresh`: saves and autosaves snapshot `session.world` without taking a session/world lock, while `Round.run_loop()` can mutate the same world on a background thread. The new periodic autosave makes this path regular, not just shutdown/evict. | Add one session-level save/load critical section or a world snapshot API that coordinates with the round lifecycle. Cover manual save, autosave, periodic autosave, evict autosave, and load. |
-| `src/dnd_simulator/service/session.py` (606 lines) | `known`: grew from 521 to 606 lines after spectator/grace/autosave lifecycle hardening. It now owns listener lifecycle, thread lifecycle, payload construction, player status helpers, movement resolution, and eviction timers. | Continue `long-func-start-round` / `test-gap-session`; avoid adding new session behavior until lifecycle and payload builders are split. |
-| `src/dnd_simulator/layers/entities/layer.py` (584 lines), `layers/entities/perception.py` (572 lines), `round.py` (548 lines), `core/action_defs.py` (545 lines), `service/commands_worldbuilder.py` (535 lines) | `known`: large modules remain after Sprint 020 decomposition. Sprint 021 added save/load state to the entities layer, but no new god-object boundary was introduced. | Keep existing backlog: `entities-layer-regrowth`, `perception-fail-fast`, `round-growing`, `action-defs-growing`. |
+| `src/dnd_simulator/service/session.py` (726 lines) | `known, worsened`: grew from 694 to 726 lines. It owns listener and round transitions, two locking domains, snapshot construction, payload composition, player status, movement resolution, eviction timers, journey presentation, and bounded-stop policy. | Split lifecycle/locking from transport payload builders before adding trigger activation. Keep `long-func-start-round` and `test-gap-session` as the canonical backlog items. |
+| `src/dnd_simulator/layers/entities/layer.py` (616 lines), `layers/entities/perception.py` (572 lines), `round.py` (571 lines), `core/action_defs.py` (554 lines), `service/commands_worldbuilder.py` (535 lines) | `known`: large modules remain. Phase 5 did not increase these files. | Keep `entities-layer-regrowth`, `perception-fail-fast`, `round-growing`, and `action-defs-growing`; avoid folding trigger-table logic into the same files. |
| `frontend/src/components/game/EventLog.tsx:244`, `frontend/src/components/master/SchemaForm.tsx:62` | `known`: two `eslint-disable react-hooks/exhaustive-deps` suppressions remain. | Keep `event-log-eslint-suppress` and `schema-form-eslint-suppress`; remove only with targeted tests. |
-| `src/dnd_simulator/layers/entities/entity_serialization.py:38`, `layers/entities/save_models.py` | `known/accepted`: entities save models are now authoritative, but `player_to_save_data()` still emits a parse-player compatibility subset. This is not a second save format for `SaveGame`, but it is still a compatibility bridge to watch as schema v1 evolves. | Do not reintroduce hand-written save envelopes. When player parsing moves fully onto save models, remove the bridge. |
+| `frontend/src/transport/apiClient.ts` (391 lines) | `known`: the client is close to the 400-line audit threshold and contains session, player, content, save, and master operations. | Keep `api-client-growing`; split by domain when the next control-interface surface lands. |
+
+The former unbounded `thread.join()` finding is closed. `_stop_round()` waits at most `DND_ROUND_STOP_TIMEOUT_SECONDS`, retains the same round, brain, and thread on timeout, and clears them only after that thread exits.
## Security
| File:Line | Issue | Severity |
|-----------|-------|----------|
-| `src/dnd_simulator/adapters/api/app.py:153-160` | `known`: CORS origins are configurable, but default origins, methods, and headers are wildcard. Acceptable for local dev, unsafe for non-local deployment. | low |
-| `src/dnd_simulator/adapters/api/routes_ws.py:149-154` | `known`: WS origin validation is optional and disabled by default. Session IDs are still the only access handle. | medium |
-| `src/dnd_simulator/adapters/api/schemas.py:69-93` | `known`: `GiveItemRequest` item fields (`price`, `reach`, `base_ac`, `max_dex_bonus`, `strength_req`, `ac_bonus`) still lack bounds. Master-only surface, but impossible game data can be created. | low |
-| `src/dnd_simulator/adapters/api/app.py:177-180` | `known`: `/api/frontend-error` accepts arbitrary JSON without a Pydantic schema or size limit. | low |
+| `src/dnd_simulator/adapters/api/app.py:150-154` | `known`: CORS origins are configurable, but the default origins, methods, and headers are wildcard. Acceptable for local development, unsafe for non-local deployment. | low |
+| `src/dnd_simulator/adapters/api/routes_ws.py:149-154` | `known`: WS origin validation is optional and disabled by default. Session IDs remain the only access handle. | medium |
+| `src/dnd_simulator/adapters/api/schemas.py:69-103` | `known`: item creation fields including `price`, `reach`, `base_ac`, `max_dex_bonus`, `strength_req`, and `ac_bonus` lack bounds. | low |
+| `src/dnd_simulator/adapters/api/app.py:174-177` | `known`: `/api/frontend-error` accepts arbitrary JSON without a Pydantic schema or size limit. | low |
-Verified clean/hardened: no hardcoded secrets, `.env` is gitignored, no `subprocess`, no `dangerouslySetInnerHTML`, WS has token-bucket rate limiting, and autosave errors now log.
+Verified clean/hardened: no hardcoded secrets, `.env` is gitignored, no subprocess calls, no `dangerouslySetInnerHTML`, and WS input has token-bucket rate limiting. Phase 5 changed internal thread lifecycle only and added no external input surface.
## Architecture Violations
| File:Line | Violation | Should Be | Severity |
|-----------|-----------|-----------|----------|
-| none | No cross-layer imports across different layers. `core/` imports neither layers nor adapters. `rules/` imports neither layers/service/adapters/storage nor I/O libraries. `adapters/` do not import layers. | none | none |
+| none | No cross-layer imports across different layers. `core/` imports neither layers nor adapters. `rules/` imports neither layers/service/adapters/storage nor I/O libraries. Adapters do not import layers. | none | none |
-Accepted boundary imports: API schemas/routes import core enums and creation constants; `app.py` constructs `LlmClient` at the service injection point; `storage/save_schema.py` imports layer state models because the versioned save envelope intentionally lives outside `core`.
+The bounded-stop policy remains in `GameSession`, where round lifecycle and locks are owned. Load still crosses the boundary through `replace_world_state()` and fails before invoking the loader when the old round cannot stop.
## Convention Violations
| File:Line | Violation | Rule |
|-----------|-----------|------|
-| `src/dnd_simulator/storage/save_schema.py:41`, `layers/*/state.py`, `layers/entities/save_models.py:228`, `layers/common/rng_state.py` | `fresh/accepted`: RNG state uses `list[Any]` because `random.Random.getstate()` is nested tuple/list data with implementation-defined shape. | Prefer `object` over `Any`, but keep this exception localized unless a typed RNG-state codec is introduced. |
+| `src/dnd_simulator/storage/save_schema.py:41`, `layers/*/state.py`, `layers/entities/save_models.py:256`, `layers/common/rng_state.py` | `known/accepted`: RNG state uses `list[Any]` because `random.Random.getstate()` has an implementation-defined nested shape. | Prefer `object` over `Any`; keep the exception localized unless a typed RNG-state codec is introduced. |
| `src/dnd_simulator/content_loader/monsters.py:129`, `content_loader/refs.py:62` | `known`: raw-YAML/dynamic-model helpers still use `Any`. | Existing `any-to-object-sweep` / `any-encounter-entries`. |
-| `src/dnd_simulator/service/session.py`, `round.py`, `core/character.py`, `core/combat.py`, `core/resource.py`, layer model files | `known/accepted`: mutable dataclasses remain for runtime state (`GameSession`, `Round`, creatures, combat state, resource pools, lairs, world models). | Fine for stateful objects; revisit only when a model is meant to be a value object. |
+| `src/dnd_simulator/service/session.py`, `round.py`, `core/character.py`, `core/combat.py`, `core/resource.py`, layer model files | `known/accepted`: mutable dataclasses remain for explicit runtime state. | Fine for stateful objects; require `frozen=True` for value objects. |
Line length is clean.
## Layer Contract
| Layer | Issue |
|-------|-------|
-| none | All concrete layers implement the `Layer` ABC surface: `name`, `tick_interval`, `tick`, `handle_event`, `query`, `get_state`, `load_state`. Sprint 021 state models preserve the dict-facing Layer interface while validating through Pydantic internally. |
+| none | All concrete layers implement `name`, `tick_interval`, `tick`, `handle_event`, `query`, `get_state`, and `load_state`. Phase 5 did not change a layer contract. |
## Test Gaps
| Source File | Expected Test | Status |
|-------------|---------------|--------|
-| `src/dnd_simulator/service/commands_save.py`, `service/session.py`, `adapters/api/app.py` | Concurrency test for save/autosave/load while a round thread is active and mutating world state. | `fresh`, missing |
-| `src/dnd_simulator/service/commands_save.py:65-75`, `service/session.py:461-578` | Load while connected/in combat should pause or restart round lifecycle deterministically before the player reconnects. | `known`: `load-combat-round-resume` |
-| `src/dnd_simulator/adapters/api/routes_ws.py` | Malformed non-object JSON from client, not only invalid JSON / unknown message type. | `known`: extends `test-gap-ws-malformed-json` / `action-params-validation` |
-| `tests/unit/test_periodic_autosave.py` | Shutdown path where final `service.autosave_all_sessions()` itself raises. | `fresh`, missing; likely low severity because startup/shutdown should expose hard failures, but the behavior is currently unpinned |
+| `src/dnd_simulator/adapters/api/routes_ws.py` | Malformed non-object JSON and malformed action parameter shapes, not only invalid JSON and unknown message types. | `known`: `test-gap-ws-malformed-json` / `action-params-validation` |
+| `tests/unit/test_periodic_autosave.py` | Shutdown path where final `service.autosave_all_sessions()` raises. | `known`, missing; `test-gap-shutdown-autosave-failure` |
-No skipped or xfailed tests. The mechanical "rules module name -> test_rules_*.py" script still reports false positives because this repo uses semantic test names (`test_combat.py`, `test_movement.py`, `test_action_provider_isolated.py`, etc.); I did not count those as direct gaps.
+The former round-stop gap is closed. Tests now cover a blocked callback, bounded failure, lifecycle-reference preservation, idempotent start, successful retry, parked-player happy path, disconnect logging, deferred eviction, and load fail-fast with unchanged world/RNG/cache.
## Vision Drift
| Change | Invariant Violated | Impact |
|--------|-------------------|--------|
-| none | Sprint 021 aligns with simulation-core: save schema v1, seeded layer RNG, RNG state persistence, and periodic autosave all support the "world frozen on a half-step" direction. | none |
-
-Classic mode still works without LLM, time remains a single session timeline, layers remain callback-separated, and master control still goes through service endpoints.
-
-## Sprint 021 Backlog Reconciliation
-| Item | Audit Result |
-|------|--------------|
-| `save-schema` | Substantively closed for v1: `SaveGame(schema_version=1)` is the single envelope, typed layer states are authoritative, legacy saves are rejected. Future simulation-core fields will extend v1/v2 rather than reopening the old manual format. |
-| `layer-rng-threading` | Closed: encounter rolls, squad movement, retreat, lair depletion, weather, politics, ecology, and entities use owned RNG streams rather than process-global `random`. |
-| `test-gap-world-rng-determinism` | Closed: `tests/unit/test_world_seed.py` pins same-seed full-world replay, different-seed divergence, layer seed streams, and encounter spawn replay. |
-| `periodic-autosave-scheduler` | Closed: FastAPI lifespan starts `_periodic_autosave()` with `DND_AUTOSAVE_SECONDS`, cancels it before final shutdown autosave, and tests interval/error/cancel behavior. |
-| `silent-failure-autosave` | Closed for the three Sprint 021 targets: create-player autosave, empty-session autosave, and periodic autosave log exceptions. |
-| `saved-session-accumulation` | Partially addressed: integration suite cleanup is in place; UX pagination/filter/TTL remains a separate product/debt question. |
-| `load-combat-round-resume` | Still known and not duplicated: phase-2 E2E showed save JSON correct, but load/reconnect can advance combat before the UI stabilizes. |
+| none | Bounded shutdown preserves the single global round: a live old thread blocks replacement and a second round cannot start. | none |
+
+Classic mode remains complete without LLM, active creatures share one round, layers remain callback-separated, brains remain swappable, and master mutations still go through service endpoints.
+
+## Sprint 022 Phase 5 Reconciliation
+| Previous Finding | Audit Result |
+|------------------|--------------|
+| Unbounded round-thread join | Closed: bounded join with `RoundStopTimeoutError`, structured logging, and preserved lifecycle references. |
+| Missing stuck-round lifecycle tests | Closed: unit coverage spans stop, reconnect, disconnect, load, eviction, recovery, and the normal parked-player path. |
+| Unsafe continuation after stop timeout | Closed: load does not replace world state; eviction does not autosave or remove the session; reconnect does not create a second loop. |
diff --git a/docs/e2e-playbook.md b/docs/e2e-playbook.md
index df195dbd..098ca3ad 100644
--- a/docs/e2e-playbook.md
+++ b/docs/e2e-playbook.md
@@ -356,3 +356,19 @@
### 15.6 Локализация боевого лога + флейвор встречи (Sprint 019)
- При `DND_LANGUAGE=ru` провести атаку и спровоцировать спавн встречи
- **Ожидание:** строка атаки целиком по-русски, без утечки плейсхолдеров (`{oa}`/`{weapon}`/`{roll}`); спавн встречи даёт флейвор «Поблизости что-то шевелится» (ростер мобов скрыт), НЕ фоллбэк «Something happened»
+
+---
+
+## 16. Intentions & Travel (Sprint 022)
+
+### 16.1 Wait рядом с RuleBrain NPC
+- В мирной локации с NPC нажать «Ждать» на 1 час
+- **Ожидание:** время быстро сдвигается ровно на час, промежуточного потока NPC-ходов нет, игрок получает следующий ход
+
+### 16.2 Путешествие по графу
+- В Location Panel выбрать соседнюю локацию и начать Travel; для длинного маршрута сохранить и загрузить игру посреди пути
+- **Ожидание:** действие передаёт `destination_id`, игрок движется по рёбрам без телепорта, journey status переживает load и очищается один раз по прибытии
+
+### 16.3 Прерывание путешествия
+- Пройти промежуточное ребро в локацию с бодрствующим якорем либо вступить в бой во время intent
+- **Ожидание:** intent очищается, игрок остаётся в достигнутой локации и получает один ход; последующие обновления не повторяют пройденное ребро
diff --git a/docs/e2e-reports/2026-07-12-sprint022-post-audit.md b/docs/e2e-reports/2026-07-12-sprint022-post-audit.md
new file mode 100644
index 00000000..25fe0662
--- /dev/null
+++ b/docs/e2e-reports/2026-07-12-sprint022-post-audit.md
@@ -0,0 +1,45 @@
+# E2E Report: sprint022-post-audit
+
+**Date:** 2026-07-12
+**Flags:** --no-llm
+**Sections tested:** 1.1-1.5, 2.1, 2.3-2.4 + Sprint 022 travel regression
+**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs
+
+## Summary
+
+- Scenarios: 8 tested, 8 passed after 1 quick fix
+- Quick fixes: 1 applied
+- Blockers: 0 found
+
+## Results
+
+| # | Scenario | Status | Notes |
+|---|----------|--------|-------|
+| 1.1 | Landing page Player/DM split | pass | Русские Player и DM entry points видны, переключатель языка доступен. |
+| 1.2 | Quick start in Sword Vale | pass | Новая сессия создалась, персонаж вошёл в игру, WebSocket подключился. |
+| 1.3 | Language surface | pass | Landing, setup и game UI полностью отобразились в RU без raw translation keys. |
+| 1.4 | Fighter point buy | pass | STR 15 и CON 14 оставили 3 очка; preview показал HP 12, AC 19, 1000 gold. |
+| 1.5 | Fighter class UI | pass | Fighting Style и Chain Mail / Longsword / Shield отображались; Defense дал AC 19. |
+| 2.1 | Peaceful perception | pass | Марта видна рядом с Attack/Talk controls. |
+| 2.3 | Wait and time advance | pass | Wait сдвинул время с 10:00 до 11:00 и вернул следующий ход. |
+| A1 | Destination-based travel | pass | После quick fix generic Travel action скрыт; путь в Location Panel передал `destination_id` и переместил игрока на Рыночную площадь без ошибки round loop. |
+
+## Quick Fixes
+
+- `ActionBar` больше не показывает generic `travel` как простую кнопку. Маршрут выбирается через Location Panel, единственный UI, который передаёт обязательный `destination_id`. Добавлен regression test.
+
+## Findings
+
+### Blockers
+
+None.
+
+### Minor
+
+- При первом подключении после создания персонажа браузер один раз сообщил о закрытии первоначального WebSocket до установления соединения. Замещающее соединение открылось сразу, действия Wait и Travel выполнились штатно. Это известный dev-proxy/reconnect artifact.
+
+## Log Analysis
+
+- До quick fix generic Travel button вызвал `ValueError: Action travel missing required param: destination_id` и `GAME OVER`; исправление воспроизведено и проверено через чистую UI-сессию.
+- После исправления повторный travel прошёл без новых backend errors или browser console errors.
+- Focused frontend regression: 22/22 tests passed.
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/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/e2e/phase3-report.md b/docs/sprints/022-intents-travel/e2e/phase3-report.md
new file mode 100644
index 00000000..79041cee
--- /dev/null
+++ b/docs/sprints/022-intents-travel/e2e/phase3-report.md
@@ -0,0 +1,40 @@
+# E2E Report: sprint022-phase3
+
+**Date:** 2026-07-11
+**Flags:** --no-llm
+**Sections tested:** 1, 2
+**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs
+
+## Summary
+
+- Scenarios: 3 tested, 3 passed, 0 failed
+- Quick fixes: 1 applied
+- Blockers: 0 found
+
+## Results
+
+| Scenario | Status | Notes |
+|---|---|---|
+| Landing page player/DM split | pass | Both entry cards render and point to the expected routes. |
+| Travel through Location panel | pass | Clicking an adjacent path starts `travel`; arrival updates the current location and replaces the visible path list. |
+| Travel action localization | pass | The action bar renders `Travel` in EN and `Путешествовать` in RU. |
+
+## Quick Fixes
+
+- Added the missing EN/RU action-bar labels for the new `travel` action.
+
+## Findings
+
+### Blockers
+
+None.
+
+### Minor
+
+None.
+
+## Log Analysis
+
+- No backend errors, exceptions, or tracebacks during the clean run.
+- Browser console had no errors. Reloads produced transient WebSocket-close warnings while replacing the page.
+
diff --git a/docs/sprints/022-intents-travel/e2e/phase4-report.md b/docs/sprints/022-intents-travel/e2e/phase4-report.md
new file mode 100644
index 00000000..41d036a5
--- /dev/null
+++ b/docs/sprints/022-intents-travel/e2e/phase4-report.md
@@ -0,0 +1,43 @@
+# E2E Report: sprint022-phase4
+
+**Date:** 2026-07-11
+**Flags:** --no-llm
+**Sections tested:** phase-4 focus (target accessibility + journey lifecycle)
+**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs
+
+## Summary
+
+- Scenarios: 5 tested, 5 passed, 0 failed
+- Quick fixes: 1 applied (target-aware accessible names now key on the unique entity id)
+- Blockers: 0 found
+
+## Results
+
+| # | Scenario | Status | Notes |
+|---|----------|--------|-------|
+| A1 | Nearby Attack/Talk/Inspect controls have unique target-aware accessible names (EN) | pass | With gretta + guard_a + guard_b at the market, `Attack gretta` / `Attack guard_a` / `Attack guard_b` are all present and unique; Talk/Inspect likewise. Selecting one sends its `target_id`. |
+| A2 | Same, in Russian | pass | `Атаковать gretta` / `Атаковать guard_a` / `Атаковать guard_b` unique; no duplicates among the three attack controls. |
+| A3 | Open inspect modal keeps its Attack control unambiguous | pass | Inspecting `guard_a` opens the modal (`Attack guard_a`); the background nearby list is `aria-hidden` (Radix), so exactly one `Attack guard_a` button is reachable by role + name. |
+| B1 | Multi-leg travel progresses leg-by-leg and clears on arrival | pass | Travel tavern→smithy resolved route `[market, smithy]`; logs show `travel_leg_arrive` at market (intermediate) then smithy (final) — no teleport. On arrival the journey panel is gone, the displayed location is the reached node, and player control returns. |
+| B2 | Combat entry stays coherent and returns control | pass | Attacking `raider_1` starts combat (`combat_start`, round 1): mode flips to combat, CombatPanel + BattleMap render, `player.journey` is null (no stale journey), `isMyTurn` true. |
+
+## Quick Fixes
+
+- Target-aware accessible names now use the unique `entity.id` (matching the existing action-bar `TargetDropdown` contract) instead of the perceived description. The perceived description is the localized **race** (e.g. `человек`), which collides for same-race NPCs and would have produced duplicate `Attack человек` names. `Perception.tsx` (Attack/Talk/Inspect + SmiteChoice) and `NpcInspectModal.tsx` (Attack + SmiteChoice) updated; added `inspect_target` string to EN/RU `game.json`. Verified live: three simultaneous market NPCs yield three unique attack names in EN and RU.
+
+## Findings
+
+### Blockers
+
+None.
+
+### Minor
+
+- **Mid-route SCENE interruption not reproducible through the current control surface.** Stopping a traveler at an intermediate node requires a *second awake anchor* there (the built-in SCENE reason), but `is_anchor` is not exposed by the master API and authored sword_vale NPCs are not awake anchors — a 2-leg journey passing through the market (with gretta + two guards present) fast-forwarded straight through to the destination. The interruption logic itself (SCENE / DAMAGE / COMBAT, idempotent, no double rest/leg, journey cleared, stops at reached node) is covered by the phase-4 task-1/task-2 unit + integration suites and the `LocationPanel` "clears journey progress and shows the reached node when interrupted mid-route" component test. Candidate backlog item: expose anchor toggle (or an authored awake NPC) so a mid-route stop is playable/testable through the UI.
+- **Journey panel is only briefly visible on an unobstructed route.** By design (kenshi-style fast-forward) the round loop advances all legs in one pass, so `player.journey` is set only transiently between the travel action and arrival; a player rarely sees the route panel unless the journey is interrupted. Noted as an observation, not a defect.
+
+## Log Analysis
+
+- Backend log clean: no errors, exceptions, or tracebacks during the run (only the expected `llm_not_configured_fallback` for the no-LLM stack).
+- Browser console: 0 errors across the run (1 benign warning).
+- Travel events well-formed: `travel_start` carries the resolved multi-node `route`, each leg logs a single `travel_leg_arrive`, and `combat_start` records initiative/positions cleanly.
diff --git a/docs/sprints/022-intents-travel/e2e/phase5-report.md b/docs/sprints/022-intents-travel/e2e/phase5-report.md
new file mode 100644
index 00000000..03f33e5d
--- /dev/null
+++ b/docs/sprints/022-intents-travel/e2e/phase5-report.md
@@ -0,0 +1,46 @@
+# E2E Report: sprint022-phase5
+
+**Date:** 2026-07-12
+**Flags:** --no-llm
+**Sections tested:** 6.11 + phase-5 lifecycle scenarios
+**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs
+
+## Summary
+
+- Scenarios: 3 tested, 3 passed, 0 failed
+- Quick fixes: 0 applied
+- Blockers: 0 found
+
+## Results
+
+| # | Scenario | Status | Notes |
+|---|----------|--------|-------|
+| 6.11 | Save, diverge world time, then load | pass | `phase5_e2e` saved at Y1490 M6 D1 10:00; master advanced 24 hours to D2; confirmed load restored D1 10:00 and the session view remained responsive. |
+| A1 | Reload active player session and reconnect | pass | Reloaded `/play/c7921c48`; the same player, location, HP/AC and one playable action surface returned without a duplicate round or stuck waiting state. |
+| A2 | Submit an action after reconnect | pass | Wait advanced time from 10:00 to 11:00, fast-forward completed, and the next player action surface arrived normally. |
+
+## Quick Fixes
+
+None.
+
+## Findings
+
+### Blockers
+
+None.
+
+### Minor
+
+- Reload produced the known transient browser warning that the first WebSocket was closed before establishment;
+ Vite logged two matching `ECONNRESET` proxy lines. The replacement connection succeeded immediately, the backend
+ recorded ordinary disconnect/reconnect events, and the next action completed. This is a dev-proxy/reload artifact,
+ not a lifecycle failure.
+- The forced `RoundStopTimeoutError` branch cannot be triggered from the product UI without an intentionally blocked
+ server callback. Its bounded timing, preserved lifecycle references, load fail-fast behavior and deferred eviction
+ retry are covered by the Phase 5 unit suite.
+
+## Log Analysis
+
+- Backend: no errors, exceptions, tracebacks or lifecycle timeout events during the scenarios.
+- Browser: 0 errors; one unique transient WebSocket warning repeated across reload.
+- Frontend dev proxy: two `ECONNRESET` lines during reload, followed by a healthy replacement connection.
diff --git a/docs/sprints/022-intents-travel/sprint.md b/docs/sprints/022-intents-travel/sprint.md
new file mode 100644
index 00000000..24332ab9
--- /dev/null
+++ b/docs/sprints/022-intents-travel/sprint.md
@@ -0,0 +1,108 @@
+# 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:**
+
+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 ✓
+
+Якорь становится свойством существа, активация больше не проверяет `PlayerCharacter`. Wait и sleep представлены сохраняемыми намерениями с длительностью и wake-точкой. Проверка: любое назначенное якорем существо удерживает локальную сцену активной; wait/sleep переживает save/load; без бодрствующего якоря мир быстро переходит к ближайшему пробуждению.
+
+Почему второй: фаза формирует минимальную модель Intent и исправляет активацию до появления движения по графу. Закрывает `anchor-as-property`, базовую часть `intents`, `wait-no-fastforward-with-npc` и `test-gap-ws-fastforward`.
+
+**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 ✓
+
+Игрок начинает настоящее travel-намерение. Путешественник проходит маршрут по рёбрам графа, остаётся в конкретной локации на каждом шаге и прибывает после игрового времени, а не телепортируется через `WAIT`. UI показывает текущее путешествие и его завершение. Проверка: маршрут из нескольких рёбер виден по шагам; save/load в середине дороги продолжает тот же маршрут; два активных существа могут оказаться в одной промежуточной точке.
+
+Почему третьей: travel переиспользует Intent, wake-точки и player-agnostic активацию из Phase 2. Закрывает `travel-action-type`, основную оставшуюся часть `intents` и старый `WAIT + travel_to` хак.
+
+**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 ✓
+
+Wait, sleep и travel штатно прерываются базовыми встроенными причинами: телесное событие, втягивание в сцену или бой, прибытие и таймер. После прерывания существо остаётся в согласованной локации и получает управление без потери или двойного исполнения намерения. Проверка: сохранение, загрузка, реконнект и прерывание в середине пути дают одно и то же состояние; полный пользовательский сценарий проходит через UI. Здесь же закрывается `attack-buttons-accessible-names`, чтобы E2E однозначно выбирал цель.
+
+Почему последней: прерывания проверяют совместную работу всех предыдущих фаз, не вводя декларативную trigger-table.
+
+**Tasks:**
+
+1. [Built-in intent interruptions](tasks/phase4-task1-built-in-intent-interruptions.md)
+2. [Interruption lifecycle consistency](tasks/phase4-task2-interruption-lifecycle.md)
+3. [Target accessibility and journey E2E](tasks/phase4-task3-accessibility-e2e.md)
+
+## Phase 5: Bounded round shutdown ✓
+
+Остановка round thread получает ограниченное время ожидания и явный отказ вместо бессрочного
+зависания disconnect, load или eviction. Пока старый поток жив, сессия сохраняет его lifecycle-состояние
+и не позволяет запустить новый раунд или заменить world. Проверка: зависший callback быстро возвращает
+контролируемую ошибку, а после освобождения callback повторная остановка штатно очищает сессию.
+
+Почему отдельной refactor-фазой: post-sprint audit обнаружил, что `_stop_round()` делает безлимитный
+`join()` и заранее теряет ссылки на ещё живой поток. Это нарушает атомарный load-контракт Phase 1 и может
+заблокировать disconnect/eviction. Фаза ограничена lifecycle-путём; общая декомпозиция `session.py`,
+`round.py` и entities layer остаётся в существующем backlog.
+
+**Tasks:**
+
+1. [Bounded round-stop contract](tasks/phase5-task1-bounded-round-stop.md)
+2. [Lifecycle boundary failure handling](tasks/phase5-task2-lifecycle-boundary-failures.md)
+
+---
+
+## Status
+
+**Current:** Phase 5 complete. Fresh audit triaged; post-audit E2E green. Ready to close sprint.
+
+## 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).
+- Phase 1: session-level world gate недостаточен, пока dice RNG process-global. Dice RNG переводится во владение сессии до синхронизации snapshot; модульный fallback остаётся только для изолированных правиловых тестов (2026-07-10).
+- Post-sprint audit: bounded round shutdown выделен в Phase 5. Общая декомпозиция lifecycle/payload responsibilities и растущих entities/round modules остаётся в каноническом backlog, без расширения refactor-фазы (2026-07-12).
+
+## Deferred
+
+_(заполняется по ходу спринта)_
+
+## Results
+
+**Completed:** 2026-07-12
+
+Якорь стал player-agnostic свойством Creature. Wait, sleep и travel получили строгие сохраняемые intent; travel идёт по кратчайшему маршруту по рёбрам и штатно прерывается телесным событием, боем или активной сценой. Session-owned dice RNG, единый world-mutation gate, connection-driven resume и bounded round shutdown согласовали save/load/autosave с живым раундом.
+
+Спринт: 5 фаз, 15 задач, 32 коммита, 94 изменённых файла. Финальные гейты: integration 160/160, post-audit E2E 8/8. Audit после Phase 5 не нашёл блокеров.
+
+**Deferred:** декларативные trigger-table, Brain gate/decide, NPC wandering, LLM-планирование, цели и квесты остаются следующими эпиками simulation-core. Декомпозиция `service/session.py` и ожидаемые action errors, способные остановить round loop, остаются в `docs/BACKLOG.md`.
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..122c95d5
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase1-task1-session-dice-rng.md
@@ -0,0 +1,45 @@
+# 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
+
+`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/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..b9b041bf
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase1-task2-world-state-gate.md
@@ -0,0 +1,47 @@
+# 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
+
+`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/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..d9e613e5
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase1-task3-consistent-save-paths.md
@@ -0,0 +1,49 @@
+# 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
+
+`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/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..dadf8047
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase1-task4-atomic-load-resume.md
@@ -0,0 +1,49 @@
+# 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
+
+`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/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..bdf72b88
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase2-task1-persisted-anchors-intents.md
@@ -0,0 +1,40 @@
+# 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
+
+- [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
+
+`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/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..3aaac442
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase2-task2-anchor-activation-fast-forward.md
@@ -0,0 +1,45 @@
+# 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
+
+- [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
+
+`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/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..01ee36f1
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase2-task3-wait-sleep-lifecycle.md
@@ -0,0 +1,50 @@
+# 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
+
+- [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
+
+`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/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..da73230f
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase3-task1-travel-route-contract.md
@@ -0,0 +1,41 @@
+# 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
+
+- [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
+
+`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/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..848dce76
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase3-task2-travel-action-progression.md
@@ -0,0 +1,45 @@
+# 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
+
+- [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
+
+`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/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..d54902f6
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase3-task3-journey-ui.md
@@ -0,0 +1,45 @@
+# 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
+
+- [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
+
+`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/docs/sprints/022-intents-travel/tasks/phase4-task1-built-in-intent-interruptions.md b/docs/sprints/022-intents-travel/tasks/phase4-task1-built-in-intent-interruptions.md
new file mode 100644
index 00000000..519b0f85
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase4-task1-built-in-intent-interruptions.md
@@ -0,0 +1,45 @@
+# Task: Built-in intent interruptions
+
+**Date:** 2026-07-11
+**Sprint:** 022-intents-travel
+**Phase:** 4 — Interruptible journeys and E2E closure
+
+## Description
+
+Define one engine-level operation for interrupting a creature's current wait, sleep, or travel intent and wire it to the built-in reasons already owned by the entities layer. Taking damage and entering combat interrupt immediately. A traveler arriving at a location held by another awake anchor is drawn into that scene and stops there instead of silently continuing to the next edge. Ordinary leg arrival without a scene continues the stored route, while final arrival and elapsed wait/sleep remain successful completion rather than interruption.
+
+The operation must be idempotent and must not apply rest rewards or travel progress twice. Interruption keeps the creature at its last reached location, clears the intent once, and makes an eligible anchor available on the next activation pass. Keep the reason set built in; declarative trigger tables and Brain policy remain out of scope.
+
+## Tests First
+
+- A sleeping or waiting creature that takes damage wakes immediately without receiving rest completion benefits; repeating the interruption is a no-op.
+- Starting combat at a location clears every participant's active intent before initiative proceeds, without moving a traveler or duplicating a combat participant.
+- A traveler arriving at an intermediate location occupied by an awake anchor stops at that location and regains control; the same leg arrival at an otherwise dormant location schedules the next leg normally.
+- Reaching a final destination clears travel as successful arrival, and reaching a wait or sleep timer applies its completion exactly once rather than reporting an interruption.
+
+## Implementation
+
+Add a focused interruption reason and helper beside the existing intent completion functions. Call it from the authoritative damage/combat paths and from activation after a travel leg reaches a scene. Preserve the existing `advance_travel_intent` route state until the reached leg has been committed, then decide whether to continue or interrupt. Emit structured logs/events only from this central operation so callers cannot disagree about semantics.
+
+Likely files: `core/intent.py`, `layers/entities/intent_completion.py`, `layers/entities/activation_manager.py`, `layers/entities/combat_manager.py`, `layers/entities/combat_resolution.py`, and product-level intent/combat/round tests.
+
+## Acceptance Criteria
+
+- [x] Tests written and RED (before implementation)
+- [x] Implementation makes tests GREEN
+- [x] Existing tests still pass (`make check`)
+- [x] Damage, combat entry, and arrival into an awake scene interrupt through one idempotent operation.
+- [x] Interrupted rest grants no completion effects and interrupted travel remains at the last reached location.
+- [x] Timer completion, ordinary leg progression, and final arrival retain their existing successful behavior.
+
+## Status
+
+`done`
+
+## Developer Notes
+
+Added one idempotent interruption helper with built-in damage, combat, and scene reasons. Combat entry interrupts
+all participants before their combat state is initialized; positive attack damage uses the same helper without
+granting rest completion. Activation now commits travel one leg at a time, so arrival at a location already held by
+an awake anchor stops the traveler there, while dormant locations, final arrival, and timed completion keep their
+existing behavior. Full backend and frontend checks passed.
diff --git a/docs/sprints/022-intents-travel/tasks/phase4-task2-interruption-lifecycle.md b/docs/sprints/022-intents-travel/tasks/phase4-task2-interruption-lifecycle.md
new file mode 100644
index 00000000..b18121c7
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase4-task2-interruption-lifecycle.md
@@ -0,0 +1,65 @@
+# Task: Interruption lifecycle consistency
+
+**Date:** 2026-07-11
+**Sprint:** 022-intents-travel
+**Phase:** 4 — Interruptible journeys and E2E closure
+
+## Description
+
+Pin interruption behavior through the real session boundary. Saving and loading immediately before or after an interruption, disconnecting and reconnecting during a journey, and resuming the round must converge on the same world time, location, intent, and turn ownership. A stale round or repeated activation must not replay the reached leg, restore a cleared intent, grant rest twice, or deliver two player turns.
+
+Cover both peaceful scene interruption and combat interruption through the session-owned mutation gate introduced in Phase 1. File snapshots remain outside the gate after their immutable state is built; this task should extend the existing lifecycle contract rather than add a second synchronization mechanism.
+
+## Tests First
+
+- Save during a multi-leg journey, load, reconnect, then enter an occupied intermediate scene: the player stops at that node with no remaining intent and receives exactly one playable turn.
+- Save immediately after a damage or combat interruption and load it: the cleared intent stays cleared, rest rewards are absent, and combat/location state matches the pre-save session.
+- Disconnect while traveling and reconnect after the next boundary: only the connection-driven round resumes, advances the leg at most once, and emits one current player status.
+- Concurrent snapshot and interruption produce either the complete pre-interruption state or the complete post-interruption state, never a cleared intent paired with the wrong location or combat state.
+
+## Implementation
+
+Add integration coverage around `GameSession`, save/load commands, round activation, and the player WebSocket path. Keep all interruption mutations inside the session world-mutation scope and build status from the committed world state. Adjust orchestration only where the tests expose a replay or ordering bug; do not introduce transport-specific intent logic.
+
+Likely files: `service/session.py`, `service/commands_save.py`, `round.py`, API WebSocket integration tests, and session/save concurrency tests.
+
+## Acceptance Criteria
+
+- [x] Tests written and RED (before implementation)
+- [x] Implementation makes tests GREEN
+- [x] Existing tests still pass (`make check`)
+- [x] Save/load before and after interruption restores one internally consistent state.
+- [x] Reconnect resumes travel or the interrupted player turn exactly once.
+- [x] Snapshot and interruption remain atomic under the existing session mutation gate.
+
+## Status
+
+`done`
+
+## Developer Notes
+
+Added `tests/unit/test_interruption_lifecycle.py` (6 tests) pinning interruption behaviour
+through the real session boundary. No orchestration change was needed: the phase 1-3 machinery
+(session world-mutation/read gate, per-entity intent + combat serialization, idempotent
+`interrupt_intent`, leg-by-leg `advance_travel_leg`) already satisfies the contract, so this is a
+regression/characterization task. Consistent with how the repo closes phases, tests pass on first
+run; I verified they are non-vacuous rather than weak:
+
+- Group A drives the real `save_game`/`load_game` commands over on-disk `sword_vale`: mid-journey
+ save then load restores the exact `TravelIntent`, and advancing to an occupied intermediate node
+ stops the traveler with the intent cleared (repeated activation does not replay the leg).
+ Save-after-damage keeps the sleep intent cleared with no long-rest heal/pool reset; save-after
+ combat entry restores `in_combat` + `CombatState` with the intent cleared.
+- Group B drives `Round.run_loop` synchronously over a minimal travel world: a resumed round
+ fast-forwards each leg exactly once, a stale re-entry is a no-op (no leg replay, +6s only, one
+ extra turn), and repeated activation after a scene interruption never re-advances.
+- Group C hammers `build_save_game` from one thread while another toggles combat entry/exit under
+ `session.mutate_world()`; every snapshot is coherent (pre or post, never torn).
+
+Non-vacuousness confirmed by temporary breaks: stubbing `interrupt_intent` to skip clearing fails
+5/6 (the 6th is a non-interrupted journey); bypassing `read_world` in `build_save_game` makes the
+concurrency test observe `intent=True + has_combat=True`. Both breaks reverted.
+
+Note: `test_ws.py::test_wait_fast_forwards_past_nearby_rule_npc` flaked once under the full parallel
+`make check` run (timing in the background round thread, `len(messages)` 5 vs ≤4); it passes in
+isolation and on rerun. Pre-existing WS-timing flake, unrelated to this change.
diff --git a/docs/sprints/022-intents-travel/tasks/phase4-task3-accessibility-e2e.md b/docs/sprints/022-intents-travel/tasks/phase4-task3-accessibility-e2e.md
new file mode 100644
index 00000000..04a748b9
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase4-task3-accessibility-e2e.md
@@ -0,0 +1,61 @@
+# Task: Target accessibility and journey E2E
+
+**Date:** 2026-07-11
+**Sprint:** 022-intents-travel
+**Phase:** 4 — Interruptible journeys and E2E closure
+
+## Description
+
+Make every visible Attack control identify its target in the accessibility tree, then validate the completed sprint through the real UI. The nearby list, NPC inspect modal, action-bar target menu, and smite choices must expose distinct localized names when several attack controls are present. Close `attack-buttons-accessible-names` only after a component test and browser scenario select the intended target by role and accessible name.
+
+Run the phase E2E scenario through travel, save/load or reconnect, interruption at an intermediate location, and return of player control. Confirm the journey panel clears at interruption, the displayed location is the reached node, combat or scene UI is coherent, and a later status update does not replay progress. Use the normal phase report boundary; any non-trivial product failure becomes a finding rather than expanding this task beyond quick-fix scope.
+
+## Tests First
+
+- With two nearby targets and an open inspect modal, every Attack button has a unique localized accessible name containing its target; selecting one sends that target ID.
+- Action-bar and smite menu items remain uniquely selectable by accessible name in both English and Russian.
+- After an interrupted mid-route journey, the UI clears journey progress, shows the reached location, and exposes exactly one player action surface.
+
+## Implementation
+
+Add target-aware gettext strings or `aria-label` values to the remaining bare Attack controls in `Perception` and `NpcInspectModal`, reusing the existing action-bar target naming contract where possible. Update focused frontend tests and the backlog checkbox. Then execute the phase E2E checklist against a clean detached stack and write `docs/sprints/022-intents-travel/e2e/phase4-report.md`.
+
+Likely files: `frontend/src/components/game/Perception.tsx`, `frontend/src/components/game/NpcInspectModal.tsx`, focused component tests, `frontend/src/i18n/locales/{en,ru}/game.json`, `docs/BACKLOG.md`, and the phase E2E report.
+
+## Acceptance Criteria
+
+- [x] Tests written and RED (before implementation)
+- [x] Implementation makes tests GREEN
+- [x] Existing tests still pass (`make check`)
+- [x] Every Attack control exposed at the same time has a unique target-aware accessible name in EN and RU.
+- [x] Browser automation selects the intended target without text or DOM-position ambiguity.
+- [~] Phase E2E covers a persisted or reconnected journey interrupted at an intermediate location and reports no blockers.
+- [x] `attack-buttons-accessible-names` is marked complete only after the browser scenario passes.
+
+## Status
+
+`done`
+
+## Developer Notes
+
+Accessibility: added target-aware `aria-label`s to the Attack/Talk/Inspect controls in `Perception.tsx`
+and the Attack control in `NpcInspectModal.tsx`, plus an `inspect_target` EN/RU string. The label
+target keys on the unique `entity.id` (the same contract the action-bar `TargetDropdown` already uses),
+not the perceived description — the description is the localized **race** (`человек`), which collides
+for same-race NPCs and would leave duplicate `Attack человек` names. `SmiteChoice` also now names its
+target by id in both call sites. Component tests in `Perception.test.tsx` (nearby uniqueness +
+selection + EN/RU + smite menu) and a `LocationPanel.test.tsx` mid-route-interruption test. Frontend
+gate green (282 tests). Backend untouched, unchanged since task 2.
+
+E2E (`e2e/phase4-report.md`): live browser run confirmed unique target-aware attack names for three
+simultaneous market NPCs in EN and RU, the inspect modal's Attack unambiguous (Radix hides the
+background), a genuine multi-leg journey progressing leg-by-leg (`travel_leg_arrive` market→smithy)
+and clearing on arrival with control returned, and coherent combat entry. Backend/browser logs clean.
+
+The one partial criterion (`[~]`): a *mid-route SCENE interruption* is not reproducible through the
+current control surface — stopping a traveler at an intermediate node needs a second awake anchor
+there, but `is_anchor` is not exposed by the master API and authored NPCs are not awake anchors (a
+2-leg journey fast-forwarded straight through the NPC-populated market). The interruption logic
+itself is fully covered by task-1/task-2 unit + integration tests and the `LocationPanel` component
+test; logged as a minor E2E finding (candidate backlog: expose an anchor toggle so the mid-route stop
+is playable). Not a blocker.
diff --git a/docs/sprints/022-intents-travel/tasks/phase5-task1-bounded-round-stop.md b/docs/sprints/022-intents-travel/tasks/phase5-task1-bounded-round-stop.md
new file mode 100644
index 00000000..797bf566
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase5-task1-bounded-round-stop.md
@@ -0,0 +1,65 @@
+# Task: Bounded round-stop contract
+
+**Date:** 2026-07-12
+**Sprint:** 022-intents-travel
+**Phase:** 5 — Bounded round shutdown
+
+## Description
+
+Сделать остановку round thread ограниченной по времени и атомарной относительно lifecycle-состояния
+сессии. Если поток не завершился за заданный срок, `stop_round()` должен вернуть явную ошибку и оставить
+ссылки на тот же round, brain и thread. Пока этот поток жив, новый round не запускается, а вызывающий код
+может повторить остановку после освобождения блокирующего callback.
+
+Успешная остановка сохраняет текущий контракт: round получает stop, очереди player brain разблокируются,
+поток завершается, после чего lifecycle-ссылки очищаются. Таймаут должен быть одной именованной настройкой
+сессии и сопровождаться структурированным логом с session context.
+
+## Tests First
+
+- Round callback блокируется дольше stop timeout: `stop_round()` завершается за ограниченное время с
+ явной lifecycle-ошибкой, а живые round, brain и thread остаются привязаны к сессии.
+- Пока старый поток жив после таймаута, повторный `start_round()` не создаёт второй loop и не заменяет
+ lifecycle-ссылки.
+- После освобождения callback повторный `stop_round()` дожидается того же потока и очищает round, brain
+ и thread; следующий `start_round()` затем создаёт ровно один новый loop.
+- Обычный parked player turn по-прежнему останавливается сразу и не достигает timeout path.
+
+## Implementation
+
+Перестроить `GameSession._stop_round()` в два этапа: под `_lock` получить стабильный lifecycle snapshot,
+затем послать stop/unblock и выполнить bounded join без преждевременной очистки ссылок. После успешного
+join очистить поля только если они всё ещё указывают на тот же snapshot. При живом потоке залогировать
+timeout и выбросить отдельную понятную lifecycle-ошибку.
+
+Не отпускать `_round_transition_lock` между stop request, join и фиксацией результата. Не брать
+`_world_state_lock` вокруг join: round может завершать текущую mutation scope. Не добавлять второй
+механизм остановки в `Round`.
+
+Ключевые файлы: `service/session.py`, `tests/unit/test_session_lifecycle.py`.
+
+## Acceptance Criteria
+
+- [x] Tests written and RED (before implementation)
+- [x] Implementation makes tests GREEN
+- [x] Existing tests still pass (`make check-backend`)
+- [x] Stop ожидание ограничено одной именованной timeout-настройкой
+- [x] Timeout не теряет ссылки на живой round thread
+- [x] Живой старый thread исключает запуск второго round loop
+- [x] Повторная остановка после освобождения callback полностью очищает lifecycle
+
+## Status
+
+`done`
+
+## Developer Notes
+
+`GameSession._stop_round()` теперь сначала фиксирует lifecycle snapshot, останавливает round и разблокирует
+`PlayerBrain`, затем ждёт поток не дольше `DND_ROUND_STOP_TIMEOUT_SECONDS` (5 секунд по умолчанию). При timeout
+выбрасывается `RoundStopTimeoutError`, пишется `stop_round_timeout` с session/thread context, а ссылки на живые
+round, brain и thread остаются в сессии. Успешная повторная остановка очищает только тот же snapshot.
+
+Добавлены два lifecycle-теста: зависший listener callback покрывает timeout, идемпотентный start и recovery;
+обычный parked player turn подтверждает быстрый happy path. Backend gate: lint, format и mypy зелёные, 2475 тестов
+прошли; известный timing-флейк `test_wait_fast_forwards_past_nearby_rule_npc` один раз дал 5 сообщений вместо 4
+и сразу прошёл изолированно.
diff --git a/docs/sprints/022-intents-travel/tasks/phase5-task2-lifecycle-boundary-failures.md b/docs/sprints/022-intents-travel/tasks/phase5-task2-lifecycle-boundary-failures.md
new file mode 100644
index 00000000..50e87be1
--- /dev/null
+++ b/docs/sprints/022-intents-travel/tasks/phase5-task2-lifecycle-boundary-failures.md
@@ -0,0 +1,65 @@
+# Task: Lifecycle boundary failure handling
+
+**Date:** 2026-07-12
+**Sprint:** 022-intents-travel
+**Phase:** 5 — Bounded round shutdown
+
+## Description
+
+Провести bounded-stop контракт через три пользовательских lifecycle-пути: disconnect последнего игрока,
+`load_game()` и deferred eviction. Ни один путь не должен зависать, заменять мир или удалять сессию, пока
+старый round thread жив. Ошибка остановки должна быть видна в логах и оставлять сессию в состоянии, которое
+можно восстановить повторной остановкой или реконнектом после завершения callback.
+
+Успешные пути не меняются: disconnect сразу паузит round и ставит eviction timer, load очищает cached
+transport state и атомарно восстанавливает snapshot только после завершения старого round, eviction делает
+autosave и удаляет только уже остановленную сессию.
+
+## Tests First
+
+- При блокирующем callback disconnect последнего player listener возвращается за ограниченное время,
+ не запускает второй round при конкурентном reconnect и не маскирует timeout в логах.
+- `load_game()` при timeout не вызывает loader: world, dice RNG, brains и cached turn остаются от старого
+ состояния; после завершения callback повторный load полностью восстанавливает snapshot.
+- Deferred eviction при timeout не вызывает `_on_empty`, не autosave-ит потенциально изменяемый мир и не
+ удаляет сессию из registry; следующий успешный empty-check может завершить eviction.
+- Обычные disconnect, load и eviction продолжают проходить существующие happy-path проверки.
+
+## Implementation
+
+Обработать lifecycle-ошибку в `remove_listener()`, `replace_world_state()` и `_run_evict_check()` на границах,
+где различается политика продолжения. Load должен пробрасывать понятную ошибку вызывающему API и не входить
+в loader. Фоновый eviction должен логировать отказ и не вызывать `_on_empty`; при необходимости безопасно
+поставить повторную проверку, не создавая busy loop. Disconnect не должен считать round остановленным и
+разрешать новый loop, пока сохранённый thread жив.
+
+Использовать существующие `_round_transition_lock`, eviction timer и session registry. Не добавлять
+transport-specific флаги и не расширять фазу общей декомпозицией `session.py`.
+
+Ключевые файлы: `service/session.py`, `service/commands_save.py`, `service/game_service.py`,
+`tests/unit/test_session_lifecycle.py`, `tests/unit/test_commands_save.py`, lifecycle/autosave tests.
+
+## Acceptance Criteria
+
+- [x] Tests written and RED (before implementation)
+- [x] Implementation makes tests GREEN
+- [x] Existing tests still pass (`make check-backend`)
+- [x] Failed stop never replaces world or evicts the live session
+- [x] Конкурентный reconnect не создаёт второй round loop
+- [x] Фоновый timeout залогирован и не теряется в Timer thread
+- [x] После завершения callback load и eviction можно успешно повторить
+
+## Status
+
+`done`
+
+## Developer Notes
+
+Disconnect теперь перехватывает `RoundStopTimeoutError`, пишет `disconnect_stop_failed` и всё равно ставит
+deferred empty-check; reconnect сериализован тем же `_round_transition_lock`, а живой loop остаётся привязан к
+сессии. Eviction при timeout пишет `evict_stop_failed`, не вызывает `_on_empty` и планирует повторную проверку
+через обычный grace interval, поэтому autosave и удаление registry entry не происходят на живом round.
+
+Load сохранил правильную fail-fast семантику: `replace_world_state()` пробрасывает timeout до очистки cached turn
+и входа в loader. Новый round-trip тест фиксирует неизменность world, dice RNG, brain/lifecycle и transport cache,
+затем успешную повторную загрузку. Полный `make check` зелёный: backend 2479, frontend 282.
diff --git a/frontend/src/components/game/ActionBar.tsx b/frontend/src/components/game/ActionBar.tsx
index 271eb4e2..079474b0 100644
--- a/frontend/src/components/game/ActionBar.tsx
+++ b/frontend/src/components/game/ActionBar.tsx
@@ -37,7 +37,7 @@ export function ActionBar() {
)
}
- const ALWAYS_HIDDEN = new Set(["buy", "sell", "take", "move", "move_to"])
+ const ALWAYS_HIDDEN = new Set(["buy", "sell", "take", "move", "move_to", "travel"])
const PEACEFUL_ONLY_HIDDEN = new Set(["use_item", "equip", "unequip"])
const available = (awareness?.available_actions ?? []).filter(
(a) => !ALWAYS_HIDDEN.has(a.name) && !(mode === "peaceful" && PEACEFUL_ONLY_HIDDEN.has(a.name)),
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() {