From 96b6d5fa9f7062f57f5600a1f4b46db8e250b6ce Mon Sep 17 00:00:00 2001
From: vladmesh <16962535+vladmesh@users.noreply.github.com>
Date: Thu, 16 Jul 2026 00:09:42 +0300
Subject: [PATCH 01/19] =?UTF-8?q?sprint=20024:=20plan=20=E2=80=94=20playte?=
=?UTF-8?q?st=20quick=20wins=20(combat=20feel,=20gear=20polish,=20item=20p?=
=?UTF-8?q?roperties=20UI)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/STATUS.md | 19 ++++--
.../sprints/024-playtest-quick-wins/sprint.md | 63 +++++++++++++++++++
2 files changed, 78 insertions(+), 4 deletions(-)
create mode 100644 docs/sprints/024-playtest-quick-wins/sprint.md
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 824905d9..102d9d30 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -2,14 +2,25 @@
Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас".
-**Last updated:** 2026-07-14
-**Position:** Sprint 023 закрыл третий эпик simulation-core: typed event contracts, trigger table и событийный write-back смертей логова.
-**Next:** Активного спринта нет.
+**Last updated:** 2026-07-16
+**Position:** Sprint 024 (playtest-quick-wins) в планировании: быстрые UX-фиксы из живой партии 2026-07-15, план готов.
+**Next:** Генерация задач Phase 1.
**Blockers:** Нет.
## Current Sprint
-No active sprint.
+**Sprint:** 024-playtest-quick-wins
+**Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов
+**Started:** 2026-07-16
+**Phase:** Planning (COMPLETE) — 2026-07-16
+
+Ready for Phase 1 task generation.
+
+### Phases
+
+1. Читаемость и тактика боя (movement budget, enemy-error log gate, faction-log spam, hide world travel, second-wind zero heal)
+2. Полировка торговли и экипировки (catalog prices, equip/unequip i18n)
+3. Панель свойств предметов (item-properties-ui)
## Recent activity (non-sprint)
diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md
new file mode 100644
index 00000000..a0a62ebc
--- /dev/null
+++ b/docs/sprints/024-playtest-quick-wins/sprint.md
@@ -0,0 +1,63 @@
+# Sprint 024 — Playtest Quick Wins
+
+**Goal:** Быстрые UX-победы из живой партии 2026-07-15 — чинит боевое движение и чистоту лога/боевого UI, полирует торговлю и i18n снаряжения, добавляет панель свойств предметов в магазине и инвентаре.
+
+**Started:** 2026-07-16
+
+## Context
+
+Активного эпика simulation-core этот спринт не двигает. Запрос оператора — быстрые выигрыши пользовательского опыта без архитектурных переделок и крупного контента: мелкие фиксы и фичи с ясным эффектом на качество игры. Живая партия 2026-07-15 оставила плотный кластер таких находок в [BACKLOG](../../BACKLOG.md); спринт снимает с него сливки по критерию «максимум эффекта на минимум кода».
+
+Отобраны восемь айтемов: боевые баги (`combat-move-budget-not-consumed`, `npc-action-errors-leak-to-log`, `faction-hostility-check-cost`, `hide-world-travel-in-combat`, `second-wind-zero-heal`), полировка снаряжения (`catalog-item-prices`, `action-bar-equip-i18n`, `action-bar-unequip-i18n`) и одна средняя фича — панель свойств предметов (`item-properties-ui`).
+
+За границей спринта: `flee-scene-separation` (нужен /grilling), `combat-status-single-source` (зонтик-хардеринг), `combat-pathfinding-avoidance` (умный боевой ИИ — кроме проверки, что ход завершается), `ui-language-mixing` (рассинхрон язык клиента/сервера, не быстрый), мастер-видимость (`master-panel-creature-inventory`, `spawn-role-freetext-enum`). Смежные боевые баги (`rest-in-combat-not-rejected`, `equip-in-combat-free`) завязаны на `combat-status-single-source` и в скоуп не входят.
+
+**Ссылки:** [BACKLOG](../../BACKLOG.md), [VISION](../../VISION.md), [Sprint 023](../023-trigger-table/sprint.md)
+
+## Phase 1: Читаемость и тактика боя
+
+Самый плотный кластер из живой партии с 11 волками — всё про то, как бой ощущается и читается. Движение в бою начинает тратить бюджет (кайтинг работает, монстр не пересекает карту за ход), чужие отказы и faction-спам уходят из лога, боевой UI не показывает меню мира, Second Wind не пугает нулевым лечением. Проверка: боевой прогон (integration/WS) — шаги списывают `movement_remaining` и ход завершается, в логе игрока нет чужих отказов, бэкенд-лог не тонет в `faction_hostility_check`, в бою нет travel-меню.
+
+**Айтемы:** `combat-move-budget-not-consumed`, `npc-action-errors-leak-to-log`, `faction-hostility-check-cost`, `hide-world-travel-in-combat`, `second-wind-zero-heal`
+
+**Tasks:**
+
+_(генерируются отдельно перед началом фазы)_
+
+## Phase 2: Полировка торговли и экипировки
+
+Стартовое снаряжение получает SRD-цены и продаётся торговцу из инвентаря; кнопки надеть/снять локализованы без сырых ID. Проверка: снять и продать стартовый предмет; кнопки equip/unequip показывают RU-метки и описания.
+
+**Айтемы:** `catalog-item-prices`, `action-bar-equip-i18n`, `action-bar-unequip-i18n`
+
+**Tasks:**
+
+_(генерируются отдельно перед началом фазы)_
+
+## Phase 3: Панель свойств предметов
+
+`WeaponDef`/`ArmorDef`/`ShieldDef`/`AccessoryDef` пробрасываются из каталога в player-facing awareness/схему и отрисовываются как tooltip/панель деталей в магазине и инвентаре: урон, свойства оружия (finesse/reach/two-handed/granted conditions), base AC / dex cap брони, эффект зелий, `grant_modifiers` колец. Проверка: в магазине и инвентаре по предмету видно, что он делает, до покупки/надевания (EN+RU).
+
+**Айтемы:** `item-properties-ui`
+
+**Tasks:**
+
+_(генерируются отдельно перед началом фазы)_
+
+---
+
+## Status
+
+**Current:** Planning complete. Ready to generate Phase 1 tasks.
+
+## Decisions
+
+_(заполняется по ходу спринта)_
+
+## Deferred
+
+_(заполняется по ходу спринта)_
+
+## Results
+
+_(заполняется в конце спринта)_
From 4544ebfadfb74e277a78389467af7bd077ed4ab4 Mon Sep 17 00:00:00 2001
From: vladmesh <16962535+vladmesh@users.noreply.github.com>
Date: Thu, 16 Jul 2026 00:22:24 +0300
Subject: [PATCH 02/19] sprint 024 phase 1: task breakdown
---
docs/BACKLOG.md | 2 +-
docs/STATUS.md | 8 ++-
.../sprints/024-playtest-quick-wins/sprint.md | 10 +++-
.../tasks/phase1-task1-combat-move-budget.md | 48 ++++++++++++++++
.../tasks/phase1-task2-combat-log-noise.md | 56 +++++++++++++++++++
.../phase1-task3-second-wind-zero-heal.md | 44 +++++++++++++++
6 files changed, 161 insertions(+), 7 deletions(-)
create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md
create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md
create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 0a40357b..33bdcbf4 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -73,7 +73,7 @@
- [x] `dm-player-restructure` — ~~Разделить главную на Player/DM входы~~ FIXED Sprint 008 phase 4-5: master restructure, stepper, world management
- [ ] **should** `item-properties-ui` — нет интерфейса просмотра свойств предмета до покупки/использования. В магазине и инвентаре продаются кольца, оружие, броня, зелья, но по ним не видно что они делают (урон, свойства оружия finesse/reach/two-handed/granted conditions, `AccessoryDef.grant_modifiers` у колец, base AC/dex cap у брони, эффект зелий) пока не купишь и не наденешь. Добавить tooltip/панель деталей предмета из данных каталога: `WeaponDef`/`ArmorDef`/`ShieldDef`/`AccessoryDef` уже несут все поля, нужно пробросить их в player-facing awareness/схему и отрисовать в shop + inventory
-- [ ] **should** `hide-world-travel-in-combat` — в бою не показывать меню перемещений по миру (`LocationPanel.tsx`, соседние локации/travel). В бою действует боевой интерфейс: есть карта боя (`BattleMap.tsx`) и action bar, а «карта мира»/travel не нужна и только путает, попытка `travel` в бою всё равно отбивается валидатором («'travel' недоступно в бою», видно 2026-07-15). Фикс: гейтить `LocationPanel` (и прочий out-of-combat travel UI) на `!isCombat` — паттерн уже используется в `ActionBar`/`Perception`/`NpcInspectModal`. В идеале одно место истины: пока `mode === "combat"`, весь мировой навигационный UI скрыт
+- [x] `hide-world-travel-in-combat` — SUPERSEDED (разведка Sprint 024 phase 1): буквальный гейт уже на месте — `GameScreen.tsx:112` свапает правую колонку (`isCombat ? : `) с Sprint 009, `LocationPanel` в бою не рендерится (единственный рендер). Playtest-симптом 2026-07-15 («travel виден в бою, отбивается валидатором») — десинк режима после flee: фронту пришло «Бой окончен» (`mode`→не-combat), панель отрисовалась, хотя бэкенд-бой шёл. Остаток — `combat-status-single-source` / `flee-scene-separation`, пока `mode` — единственный боевой сигнал фронта, отдельного фикса тут нет
- [ ] **could** `fighting-style-description-box` — на стартовом экране при выборе боевого стиля воина (`CharacterForm.tsx`, дропдаун fighting style) показывать под выпадашкой краткое описание выбранного стиля в отдельном блоке другим шрифтом: что это и в чём прикол. Стилей три (`FightingStyle`): Defense (+1 AC в броне), Dueling (+2 к урону одноручным без второго оружия), Great Weapon Fighting (переброс 1-2 на кубах урона двуручного). Метки уже есть в i18n (`setup.json`/`game.json`), описаний нет — дописать EN+RU строки (`fighting_style_*_desc`) и отрисовать muted-блок под селектом. Тот же паттерн пригодится для `levelup_fighting_style_*` в LevelUpModal. Смежно с `item-properties-ui` (показ свойств до выбора)
- [ ] **could** `quickbar-drag-drop` — Drag-and-drop из инвентаря на action bar quickbar слоты: игрок сам выбирает какие consumables (зелья, свитки, бомбы) закрепить на панели для быстрого доступа. Сейчас consumables в drawer-popup, хватает.
- [ ] **could** `drag-resize-panels` — Drag-and-drop / resizable панели на dashboard
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 102d9d30..3f6bd4fc 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -12,16 +12,18 @@
**Sprint:** 024-playtest-quick-wins
**Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов
**Started:** 2026-07-16
-**Phase:** Planning (COMPLETE) — 2026-07-16
+**Phase:** 1 — Читаемость и тактика боя (tasks generated) — 2026-07-16
-Ready for Phase 1 task generation.
+Ready to start task 1.
### Phases
-1. Читаемость и тактика боя (movement budget, enemy-error log gate, faction-log spam, hide world travel, second-wind zero heal)
+1. Читаемость и тактика боя (movement budget, enemy-error log gate, faction-log spam, second-wind zero heal)
2. Полировка торговли и экипировки (catalog prices, equip/unequip i18n)
3. Панель свойств предметов (item-properties-ui)
+`hide-world-travel-in-combat` снят с фазы 1: буквальный гейт уже на месте (GameScreen right-column swap), остаток — десинк режима (`combat-status-single-source`, вне скоупа).
+
## Recent activity (non-sprint)
- 2026-07-10 — Sprint 021 save-schema закрыт: unit 2429, integration 160, два E2E-прогона, audit triaged (свежий риск `save-round-concurrency` в бэклоге), PR в main.
diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md
index a0a62ebc..49562f27 100644
--- a/docs/sprints/024-playtest-quick-wins/sprint.md
+++ b/docs/sprints/024-playtest-quick-wins/sprint.md
@@ -16,13 +16,17 @@
## Phase 1: Читаемость и тактика боя
-Самый плотный кластер из живой партии с 11 волками — всё про то, как бой ощущается и читается. Движение в бою начинает тратить бюджет (кайтинг работает, монстр не пересекает карту за ход), чужие отказы и faction-спам уходят из лога, боевой UI не показывает меню мира, Second Wind не пугает нулевым лечением. Проверка: боевой прогон (integration/WS) — шаги списывают `movement_remaining` и ход завершается, в логе игрока нет чужих отказов, бэкенд-лог не тонет в `faction_hostility_check`, в бою нет travel-меню.
+Самый плотный кластер из живой партии с 11 волками — всё про то, как бой ощущается и читается. Движение в бою начинает тратить бюджет (кайтинг работает, монстр не пересекает карту за ход), чужие отказы и faction-спам уходят из лога, Second Wind не пугает нулевым лечением. Проверка: боевой прогон (integration/WS) — шаги списывают `movement_remaining` и ход завершается, в логе игрока нет чужих отказов, бэкенд-лог не тонет в `faction_hostility_check`.
-**Айтемы:** `combat-move-budget-not-consumed`, `npc-action-errors-leak-to-log`, `faction-hostility-check-cost`, `hide-world-travel-in-combat`, `second-wind-zero-heal`
+**Айтемы:** `combat-move-budget-not-consumed`, `npc-action-errors-leak-to-log`, `faction-hostility-check-cost`, `second-wind-zero-heal`
+
+`hide-world-travel-in-combat` при разведке оказался уже закрыт: `GameScreen.tsx:112` свапает правую колонку (`isCombat ? : `) с Sprint 009, travel-меню в бою не рендерится. Playtest-симптом — десинк режима после flee (`combat-status-single-source`/`flee-scene-separation`, вне скоупа). Айтем помечен superseded в бэклоге.
**Tasks:**
-_(генерируются отдельно перед началом фазы)_
+1. [Бюджет движения в бою](tasks/phase1-task1-combat-move-budget.md) — `handle_move` списывает `movement_remaining`
+2. [Чистота боевого лога](tasks/phase1-task2-combat-log-noise.md) — чужие ошибки не текут игроку, faction-спам → DEBUG, relation_fn один раз на ребилд
+3. [Second Wind без «0 ОЗ»](tasks/phase1-task3-second-wind-zero-heal.md) — сообщение о полном здоровье при `healed == 0`
## Phase 2: Полировка торговли и экипировки
diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md
new file mode 100644
index 00000000..24db6cb7
--- /dev/null
+++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md
@@ -0,0 +1,48 @@
+# Task: Бюджет движения в бою
+
+**Date:** 2026-07-16
+**Sprint:** 024-playtest-quick-wins
+**Phase:** 1 — Читаемость и тактика боя
+
+## Description
+
+Компасное перемещение в бою (`handle_move`, боевая ветка) не тратит бюджет движения, поэтому монстр пересекает всю карту за один ход и кайтинг невозможен (`combat-move-budget-not-consumed`).
+
+`handle_move` (`rules/handlers/movement.py:51-80`, ветка «в бою с OA-колбэком») ставит новую позицию через `bm.set_position` и считает `moved_ft = grid_distance(...)`, но не проверяет и не списывает `ctx.turn_budget.movement_remaining`. Бюджет трогают только `handle_move_to` (BFS, `-= spent` на 142/152) и `handle_dash` (`+= speed` на 182).
+
+Последствие в связке с RuleBrain: `_try_advance` (`rules/rule_brain.py:275`) пускает движение по гейту `movement_left >= 5`; раз бюджет не убывает, гейт всегда истинен, `move_toward_target` шагает по 5ft за итерацию хода без ограничения — доходит до цели через всю карту за ход. Принудительное завершение сейчас случается только через `consecutive_failures_end_turn` (`round.py:290`).
+
+Фикс: в боевой ветке `handle_move` после расчёта `moved_ft`, если `ctx.turn_budget` присутствует:
+- если `moved_ft > budget.movement_remaining` — вернуть `ActionResult(success=False, error=_("No movement remaining"))` до `set_position` (не двигаться);
+- иначе после успешного перемещения `budget.movement_remaining -= moved_ft`.
+
+Порядок с OA: списывать бюджет только за реально сделанный шаг (когда `set_position` состоялся и mover жив). Проверку достаточности бюджета делать до OA-триггеров/`set_position`.
+
+## Tests First
+
+Продуктовые сценарии (юнит на хендлере + через боевой ход RuleBrain):
+
+- Существо со `speed = 30` в бою, вне досягаемости цели. Серия компасных `move` (5ft) суммарно списывает `movement_remaining` до нуля; после исчерпания `_try_advance` возвращает `None`, и ход завершается штатно (`end_turn`), а не через `consecutive_failures_end_turn`. Итоговое пройденное расстояние ≤ speed (не через всю карту).
+- Один `move` на 5ft уменьшает `movement_remaining` ровно на пройденные футы (`grid_distance`).
+- `move`, требующий больше футов, чем осталось в бюджете, отбивается `success=False` и НЕ меняет позицию на карте.
+- Регресс: `handle_move_to` (BFS-клик игрока) по-прежнему корректно списывает бюджет и не задет фиксом.
+
+## Implementation
+
+- `rules/handlers/movement.py` — `handle_move`, только боевая ветка (`ctx.combat_state is not None and ctx.on_leave_reach is not None`). Non-combat ветка (emit для CombatManager) не трогается.
+- `budget = ctx.turn_budget`; guard `if budget is not None`. Проверка достаточности — до `set_position`/OA; списание — после успешного шага.
+- `moved_ft` уже считается как `grid_distance(cur_pos, new_pos)`; им же вычитать (одиночный компасный шаг, без diagonal 5/10-чередования `move_to` — приемлемо для quick-fix, отметить комментарием).
+- Убедиться, что при гибели mover в OA (ранний `return ActionResult()` на строке 66) бюджет не списывается за несделанный шаг.
+
+## Acceptance Criteria
+
+- [ ] Tests written and RED (before implementation)
+- [ ] Implementation makes tests GREEN
+- [ ] Existing tests still pass (`make check`)
+- [ ] Компасный `move` в бою проверяет и списывает `movement_remaining`
+- [ ] RuleBrain-существо не пересекает карту за один ход; ход завершается по исчерпании speed, а не только failsafe
+- [ ] `handle_move_to` / `handle_dash` поведение не изменилось
+
+## Status
+
+`pending`
diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md
new file mode 100644
index 00000000..6ba96015
--- /dev/null
+++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md
@@ -0,0 +1,56 @@
+# Task: Чистота боевого лога
+
+**Date:** 2026-07-16
+**Sprint:** 024-playtest-quick-wins
+**Phase:** 1 — Читаемость и тактика боя
+
+## Description
+
+Два бэкенд-фикса про то, что попадает в логи во время боя.
+
+### 1. `npc-action-errors-leak-to-log`
+
+`on_action` (`service/session.py:372-382`) кладёт `msg["error"] = error` после действия любого существа, без фильтра по актору (бюджет там уже гейтится на `creature.id == player.id`, ошибка — нет). Фронт рендерит это в боевом логе игрока — технические отказы чужих ходов («Туда не пройти, путь заблокирован» от волка) текут игроку.
+
+Фикс: проставлять `msg["error"]` только когда `creature.id == player.id` (тот же гейт, что у budget). Отказы чужих актёров в лог игрока не попадают.
+
+### 2. `faction-hostility-check-cost`
+
+`check_faction_hostility` (`layers/entities/awareness_builder.py:425-453`) вызывается на каждую пару существ при пересборке awareness (после каждого действия раунда) → O(N²) на ребилд. Две проблемы:
+
+- **Лог-спам:** `logger.info("faction_hostility_check", ...)` на каждую пару. В живой партии 2026-07-15 с 11 волками — 70.8% всего backend-лога (3610/5102 строк), топит сигнал. Опустить до `logger.debug`.
+- **Аллокация:** `make_relation_fn(query_fn)` конструируется заново на каждую пару (строка 433). Строить один раз на ребилд и переиспользовать.
+
+Фикс: `logger.info` → `logger.debug` в `check_faction_hostility`; вынести построение `get_faction_relation = make_relation_fn(query_fn)` из per-pair пути так, чтобы на один ребилд awareness он создавался один раз, а не N² раз. Мемоизация faction-pair отношений на ребилд опциональна, но `relation_fn` точно строить единожды.
+
+`get_faction_relation` (метод рядом, строка ~412) конструирует то же самое для другой ветки — свести к одному источнику при рефакторе, не меняя семантику `effective_relation`.
+
+## Tests First
+
+Продуктовые сценарии:
+
+- **Error leak:** боевой ход, где NPC совершает действие с отказом (напр. заблокированное перемещение), а затем ход игрока. В событии, доставленном игроку по чужому действию, поля `error` нет. Для действия самого игрока с отказом `error` присутствует (регресс — гейт не ломает игроцкие ошибки).
+- **Relation fn once:** пересборка awareness для наблюдателя среди N существ вызывает построение relation-функции (или нижележащий кросс-слойный faction-запрос) ограниченное число раз на ребилд, а не O(N²). Проверяется через spy/счётчик на `make_relation_fn` (или на `query_fn`): при N существах число построений `relation_fn` не растёт квадратично.
+- Регресс: результат `check_faction_hostility` (hostile/не hostile) не изменился для дружественных/враждебных/нейтральных пар и пар с персональной репутацией.
+
+## Implementation
+
+- `service/session.py` — в `on_action` обернуть `msg["error"] = error` в гейт `creature.id == player.id`. Оставить `msg["actor"]`/`msg["action"]` как есть.
+- `layers/entities/awareness_builder.py`:
+ - `check_faction_hostility`: `logger.info` → `logger.debug`.
+ - Построение `make_relation_fn(query_fn)` вынести на уровень ребилда (там, где идёт цикл по существам — вызовы на строках 243 и 336), передавать готовую `relation_fn` вниз вместо `query_fn`, либо мемоизировать per-rebuild. Не менять сигнатуру публичных методов сверх необходимого; не менять семантику `effective_relation`.
+- Гоча: `make_relation_fn` строится и в `get_faction_relation` (строка 412), и в `check_faction_hostility` (433) — при выносе свести к одному построению на ребилд.
+
+## Acceptance Criteria
+
+- [ ] Tests written and RED (before implementation)
+- [ ] Implementation makes tests GREEN
+- [ ] Existing tests still pass (`make check`)
+- [ ] Отказы чужих ходов (не игрока) не попадают в `error` события игрока
+- [ ] `faction_hostility_check` логируется на DEBUG, не на INFO
+- [ ] `make_relation_fn` строится один раз на ребилд awareness, не на каждую пару
+- [ ] Семантика hostility (дружба/вражда/нейтралитет/репутация) не изменилась
+
+## Status
+
+`pending`
diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md
new file mode 100644
index 00000000..6431a1c6
--- /dev/null
+++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md
@@ -0,0 +1,44 @@
+# Task: Second Wind без «0 ОЗ»
+
+**Date:** 2026-07-16
+**Sprint:** 024-playtest-quick-wins
+**Phase:** 1 — Читаемость и тактика боя
+
+## Description
+
+Second Wind при полном HP показывает «восстанавливаешь 0 ОЗ» — механически корректно (ресурс потрачен, лечение = 0), но выглядит багом (`second-wind-zero-heal`).
+
+`_perceive_second_wind` (`layers/entities/perception.py:349-357`) всегда форматирует «You catch your breath, regaining {hp} HP» / «{entity} catches their breath, regaining {hp} HP» из `healed` в `EntitySecondWindPayload`. При максимальных HP `healed == 0`.
+
+Фикс: при `healed == 0` — отдельная строка вместо «regaining 0 HP»:
+- self: «You catch your breath, but you are already at full health».
+- other: «{entity} catches their breath, already at full health».
+
+Новые строки в EN base + перевод в RU `.po`, компиляция `.mo`. Ненулевой случай не меняется.
+
+## Tests First
+
+Продуктовые сценарии (перцепция от события `ENTITY_SECOND_WIND`):
+
+- Боец при максимальных HP использует Second Wind (`healed == 0`) → сообщение наблюдателю-игроку про полное здоровье, БЕЗ «0 HP»/«0 ОЗ».
+- Боец с неполными HP использует Second Wind (`healed > 0`) → обычная строка «regaining {N} HP» с реальным числом (регресс).
+- Оба варианта проверяются и для self (актор = наблюдатель), и для other (актор ≠ наблюдатель).
+
+## Implementation
+
+- `layers/entities/perception.py` — `_perceive_second_wind`: ветка `if healed == 0` возвращает full-health строки (self/other), иначе существующие «regaining {hp} HP».
+- i18n: обернуть новые строки в `_()`; добавить EN msgid + RU перевод в `locale/*/LC_MESSAGES/*.po`; `make messages` при необходимости для извлечения, `make compile-messages` для `.mo`.
+- Строки короткие, без em-dash; регистр/тон — как у соседних combat-перцепций.
+
+## Acceptance Criteria
+
+- [ ] Tests written and RED (before implementation)
+- [ ] Implementation makes tests GREEN
+- [ ] Existing tests still pass (`make check`)
+- [ ] `healed == 0` → сообщение о полном здоровье без «0 HP»
+- [ ] `healed > 0` → прежняя строка с числом
+- [ ] EN + RU переводы на месте, `.mo` перекомпилированы
+
+## Status
+
+`pending`
From 59b2a4098f2a049eb654132295c944bb278da50f Mon Sep 17 00:00:00 2001
From: vladmesh <16962535+vladmesh@users.noreply.github.com>
Date: Thu, 16 Jul 2026 01:11:47 +0300
Subject: [PATCH 03/19] sprint 024 phase 1 task 1: unify combat movement-budget
accounting
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Reformulated after code recon: the "budget not consumed" premise was
wrong — the dispatcher already charged movement via
action_cost(MOVE)=MOVEMENT, gated by check_budget. The real issue was
split accounting: MOVE charged requested ft in the dispatcher while
MOVE_TO/DASH charged actual distance in their handlers, causing latent
diagonal undercharge and blocked-step overcharge.
- MOVE -> cost_type=FREE; remove CostType.MOVEMENT. handle_move now
checks and decrements the distance actually walked, atomically (no
partial placement on rejection).
- Add check_movement_available so a spent-out creature is still not
offered MOVE (check_budget no longer gates a FREE action).
- handle_move_to distinguishes "no path" from "not enough movement".
- Surface movement_remaining + reachable targets to the LLM prompt
(player and RuleBrain already had it).
Updated dispatcher/multi-action/session-awareness tests to the new
contract; new i18n strings translated to ru.
---
docs/BACKLOG.md | 2 +-
docs/STATUS.md | 4 +-
.../sprints/024-playtest-quick-wins/sprint.md | 6 +-
.../tasks/phase1-task1-combat-move-budget.md | 82 +++++++++++-----
src/dnd_simulator/core/action_defs.py | 5 +-
src/dnd_simulator/llm/brain.py | 6 ++
src/dnd_simulator/llm/prompts.py | 7 +-
.../locale/ru/LC_MESSAGES/dnd_simulator.mo | Bin 28933 -> 29312 bytes
.../locale/ru/LC_MESSAGES/dnd_simulator.po | 12 +++
src/dnd_simulator/rules/actions.py | 3 -
src/dnd_simulator/rules/handlers/movement.py | 18 +++-
src/dnd_simulator/rules/validation.py | 11 +++
tests/unit/test_action_dispatcher.py | 30 ++++--
tests/unit/test_handlers_movement.py | 92 ++++++++++++++++++
tests/unit/test_llm_brain.py | 49 +++++++++-
tests/unit/test_move_to.py | 50 ++++++++++
tests/unit/test_multi_action.py | 7 +-
tests/unit/test_session_awareness.py | 3 +-
18 files changed, 341 insertions(+), 46 deletions(-)
diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md
index 33bdcbf4..f015385f 100644
--- a/docs/BACKLOG.md
+++ b/docs/BACKLOG.md
@@ -113,7 +113,7 @@
- [ ] **should** `load-combat-round-resume` — загрузка сейва посреди боя: раунд-луп продолжает крутить ходы NPC сразу после load, до реконнекта игрока — UI после реконнекта показывает Round 3/4 вместо сохранённого Round 1; в логах `listener_error` в `WsEventListener.on_turn`. Сейв корректен (`turn_order`/`sides`/`round_number` на месте — sprint 021 phase 2 это починил), проблема в lifecycle: после load раунд должен стоять на паузе до подключения player listener (родня grace-period из `session-disconnect-debounce`). Repro: [e2e phase2-report](sprints/021-save-schema/e2e/phase2-report.md), Finding 1
- [ ] **should** `combat-status-single-source` — **хардеринг-зонтик:** «в бою» представлено несколькими рассинхронизирующимися сигналами — `creature.in_combat`, раундовый `ctx.is_combat` (задаётся веткой цикла, `round.py:200` vs `323`), активный `CombatState` в локации, и фронтовое «Бой окончен». Их расхождение — общий корень нескольких багов: `rest-in-combat-not-rejected` (отдых прошёл мирным путём), `npc-action-errors-leak-to-log`, и противоречие «Бой окончен → 'travel' недоступно в бою» после побега (сессия a0f0fed0, 2026-07-15). Фикс: единый авторитет боевого статуса для актора (активный `CombatState` его локации), из которого выводятся и валидация действий, и разрешение отдыха, и фронтовые combat-баннеры; убрать доверие к транзиентному `ctx.is_combat`/`in_combat` там, где решается инвариант. Смежно с `flee-scene-separation`
- [ ] **should** `rest-in-combat-not-rejected` — бэкенд пропустил длинный отдых в бою (видно в живой партии 2026-07-15, всплыло через глюк UI после побега и повторного входа в бой). Отдых помечен `combat_mode=PEACEFUL_ONLY` (`action_defs.py:510` LONG_REST, рядом SHORT_REST), а `check_action_mode` (`validation.py:95`) отклоняет PEACEFUL_ONLY только при `ctx.is_combat`. `is_combat` задаётся тем, по какому циклу идёт ход: боевой (`round.py:200`) → `True`, мирный (`round.py:323`) → `False`. Когда участие в бою рассинхронилось (флаг слетел после flee, см. `flee-scene-separation`), ход прошёл мирным путём с `is_combat=False`, и PEACEFUL_ONLY-гейт пропустил отдых. Сами `handle_long_rest`/`handle_short_rest` (`rules/handlers/rest.py`) не имеют независимой проверки боя. Фикс (defense-in-depth, не зависит от фикса побега): отдых должен отклоняться при наличии активного `CombatState` в локации актора / участия актора в бою, а не только по транзиентному `ctx.is_combat`. Проверить заодно другие PEACEFUL_ONLY-действия на тот же обход. Смежно с `flee-scene-separation`
-- [ ] **should** `combat-move-budget-not-consumed` — компасное перемещение в бою не тратит бюджет движения, монстр пересекает всю карту за один ход, кайтинг невозможен (подтверждено по коду + живой партии 2026-07-15). Механизм: ход стартует с `movement_remaining = speed` (`round.py:193`), но `handle_move` (`rules/handlers/movement.py:42-90`, компасный шаг в бою) ставит новую позицию и не проверяет/не списывает `movement_remaining` — бюджет трогают только `handle_move_to` (BFS, decrement на 142/152) и `handle_dash` (+speed на 182). RuleBrain ходит компасом: `_try_advance` (`rule_brain.py:268`) пускает движение по гейту `movement_left >= 5`, но раз бюджет не убывает, гейт всегда истинен, `move_toward_target` шагает по 5 ft за итерацию цикла хода без ограничения числа итераций → доходит до цели через всю карту за один ход. Фикс: `handle_move` должен проверять `budget.movement_remaining >= moved_ft` и вычитать `moved_ft` (как `handle_move_to`); тогда пошаговое сближение упрётся в speed. Смежно с `combat-pathfinding-avoidance` (тот же боевой ИИ/движение)
+- [ ] **should** `combat-move-budget-not-consumed` — **ПЕРЕФОРМУЛИРОВАН Sprint 024 phase 1 task 1** (разведка кода 2026-07-16). Исходная премиса неверна: бюджет **списывается** — не в `handle_move`, а в диспетчере. `ActionDispatcher.dispatch` (`service/action_dispatcher.py:134-136`) после успешного хендлера зовёт `TurnBudget.consume(action_cost(action))`, а `MOVE` = `cost_type=MOVEMENT` (`core/action_defs.py:212`), то есть диспетчер списывает `movement_ft` = запрошенные ft; `check_budget` (`validation.py:126`) гейтит. Воспроизведено: speed=30 через реальный диспетчер проходит ровно 30ft (6 шагов), дальше reject «Недостаточно ресурсов». Кайтинг уже упирается в speed; «пересечение карты» этим механизмом не воспроизводится (вероятно dash или мелкая карта — отдельно). Реальная проблема — **раздвоенный учёт стоимости движения**: `MOVE`=MOVEMENT (диспетчер, по запросу) против `MOVE_TO`/`DASH`=FREE/ACTION (хендлер, по факту). Латентные баги, замаскированные ортогональным 5ft-ходом RuleBrain: диагональ недосписывается (плоские 5 вместо 5/10, ×1.5), заблокированный/частичный шаг пересписывается (списывается запрос, не пройденное). Фикс (унификация оси движения): `MOVE`→`cost_type=FREE`, `handle_move` списывает фактический `moved_ft` атомарно (reject целиком при нехватке, без частичного перемещения), удалить `CostType.MOVEMENT`; `handle_move_to` различает «нет пути» и «не хватает движения»; остаток движения + достижимые цели в LLM-промпт (игрок/RuleBrain уже видят). Инвариант: диспетчер владеет осью действий, `movement_remaining` — только movement-хендлеры. Смежно с `combat-pathfinding-avoidance` (тот же боевой ИИ/движение)
- [ ] **should** `npc-action-errors-leak-to-log` — ошибки неудачных ходов НПС попадают в боевой лог игрока (видно в живой партии 2026-07-15: «Туда не пройти, путь заблокирован» от волка). Причина: `on_action` в `service/session.py:372` срабатывает после действия любого существа и кладёт `msg["error"] = error` без фильтра по актору (бюджет там уже гейтится на `creature.id == player.id`, а ошибка нет), фронт рендерит это в логе. Игроку не нужны технические отказы чужих ходов. Фикс: не проставлять `error` в `action_result` для не-игрока (гейт `creature.id == player.id`, как у budget) либо не рендерить ошибки чужих актёров на фронте. Смежно с `combat-pathfinding-avoidance` (сам спам-источник) — но это отдельный UX-баг: даже после починки ИИ чужие отказы не должны течь игроку
- [ ] **could** `ui-language-mixing` — в английском UI серверные строки идут по `DND_LANGUAGE=ru`: combat log «Бой начался», «КЗ», «промах», тип NPC «человек» при английском shell UI. Язык клиента и сервера не согласован (сервер берёт env, клиент — свой). Сосед `npc-language`. E2E sprint 021 phase 2
- [x] `dash-actiondef-movement-conflation` — FIXED Sprint 023 phase 4: Dash metadata больше не объявляет `toward`/`away_from`/`direction`; schema и RU-описание требуют отдельный `move`/`move_to` после пополнения movement budget
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 3f6bd4fc..85344614 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -12,9 +12,9 @@
**Sprint:** 024-playtest-quick-wins
**Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов
**Started:** 2026-07-16
-**Phase:** 1 — Читаемость и тактика боя (tasks generated) — 2026-07-16
+**Phase:** 1 — Читаемость и тактика боя (task 1 done, task 2 pending) — 2026-07-16
-Ready to start task 1.
+Task 1 (единый учёт бюджета движения) done: `MOVE`→FREE, `handle_move` списывает `moved_ft` атомарно, `check_movement_available` держит «0 движения → MOVE недоступен», внятная отбивка `move_to`, остаток движения + достижимость целей в LLM-промпт. Премиса `combat-move-budget-not-consumed` оказалась неверной (бюджет уже списывал диспетчер) — таск переформулирован в унификацию раздвоенного учёта. `make check` зелёный (backend 2550, frontend 289).
### Phases
diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md
index 49562f27..a3cc1dc5 100644
--- a/docs/sprints/024-playtest-quick-wins/sprint.md
+++ b/docs/sprints/024-playtest-quick-wins/sprint.md
@@ -16,7 +16,9 @@
## Phase 1: Читаемость и тактика боя
-Самый плотный кластер из живой партии с 11 волками — всё про то, как бой ощущается и читается. Движение в бою начинает тратить бюджет (кайтинг работает, монстр не пересекает карту за ход), чужие отказы и faction-спам уходят из лога, Second Wind не пугает нулевым лечением. Проверка: боевой прогон (integration/WS) — шаги списывают `movement_remaining` и ход завершается, в логе игрока нет чужих отказов, бэкенд-лог не тонет в `faction_hostility_check`.
+Самый плотный кластер из живой партии с 11 волками — всё про то, как бой ощущается и читается. Учёт бюджета движения унифицируется (одно место списания по факту пройденного, корректная диагональ, честная отбивка, видимость остатка мозгу), чужие отказы и faction-спам уходят из лога, Second Wind не пугает нулевым лечением. Проверка: боевой прогон (integration/WS) — шаги списывают `movement_remaining` и ход завершается, в логе игрока нет чужих отказов, бэкенд-лог не тонет в `faction_hostility_check`.
+
+**Правка по ходу разведки (task 1):** премиса `combat-move-budget-not-consumed` («бюджет не тратится») оказалась неверной — бюджет списывает диспетчер через `action_cost` (`MOVE`=`cost_type=MOVEMENT`), кайтинг уже упирается в speed. Реальная проблема — раздвоенный учёт (`MOVE` списывает по запросу в диспетчере против `MOVE_TO`/`DASH` по факту в хендлере) с латентными багами диагонали/частичного шага. Таск переформулирован в унификацию оси движения.
**Айтемы:** `combat-move-budget-not-consumed`, `npc-action-errors-leak-to-log`, `faction-hostility-check-cost`, `second-wind-zero-heal`
@@ -24,7 +26,7 @@
**Tasks:**
-1. [Бюджет движения в бою](tasks/phase1-task1-combat-move-budget.md) — `handle_move` списывает `movement_remaining`
+1. [Единый учёт бюджета движения в бою](tasks/phase1-task1-combat-move-budget.md) — переформулирован: бюджет **уже** списывается диспетчером (премиса «не тратится» неверна), но учёт раздвоен — `MOVE`=MOVEMENT (диспетчер, по запросу) против `MOVE_TO`/`DASH`=хендлер (по факту). Унифицируем: `MOVE`→FREE, `handle_move` списывает фактический `moved_ft` атомарно (чинит диагональ/частичный шаг), внятная отбивка `move_to`, остаток движения в LLM-промпт
2. [Чистота боевого лога](tasks/phase1-task2-combat-log-noise.md) — чужие ошибки не текут игроку, faction-спам → DEBUG, relation_fn один раз на ребилд
3. [Second Wind без «0 ОЗ»](tasks/phase1-task3-second-wind-zero-heal.md) — сообщение о полном здоровье при `healed == 0`
diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md
index 24db6cb7..c1d0a7e9 100644
--- a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md
+++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md
@@ -1,4 +1,4 @@
-# Task: Бюджет движения в бою
+# Task: Единый учёт бюджета движения в бою
**Date:** 2026-07-16
**Sprint:** 024-playtest-quick-wins
@@ -6,43 +6,79 @@
## Description
-Компасное перемещение в бою (`handle_move`, боевая ветка) не тратит бюджет движения, поэтому монстр пересекает всю карту за один ход и кайтинг невозможен (`combat-move-budget-not-consumed`).
+Переформулировано 2026-07-16 после разведки кода. Исходная премиса айтема `combat-move-budget-not-consumed` («компасное движение в бою не тратит бюджет, монстр пересекает карту за ход») **неверна**: бюджет движения списывается — но не в хендлере, а в диспетчере. `ActionDispatcher.dispatch` (`service/action_dispatcher.py:134-136`) после успешного хендлера зовёт `TurnBudget.consume(action_cost(action))`, а `MOVE` объявлен `cost_type=MOVEMENT` (`core/action_defs.py:212`), то есть диспетчер списывает `movement_ft = запрошенные ft`. Воспроизведено: существо speed=30 через реальный диспетчер проходит ровно 30ft (6 шагов по 5ft), затем `check_budget` отбивает `move` с «Недостаточно ресурсов». Кайтинг уже работает; «пересечение всей карты» этим механизмом не воспроизводится (вероятно dash или мелкая карта — вне этого таска).
-`handle_move` (`rules/handlers/movement.py:51-80`, ветка «в бою с OA-колбэком») ставит новую позицию через `bm.set_position` и считает `moved_ft = grid_distance(...)`, но не проверяет и не списывает `ctx.turn_budget.movement_remaining`. Бюджет трогают только `handle_move_to` (BFS, `-= spent` на 142/152) и `handle_dash` (`+= speed` на 182).
+Настоящая проблема — **раздвоенный и неконсистентный учёт стоимости движения**:
-Последствие в связке с RuleBrain: `_try_advance` (`rules/rule_brain.py:275`) пускает движение по гейту `movement_left >= 5`; раз бюджет не убывает, гейт всегда истинен, `move_toward_target` шагает по 5ft за итерацию хода без ограничения — доходит до цели через всю карту за ход. Принудительное завершение сейчас случается только через `consecutive_failures_end_turn` (`round.py:290`).
+- `MOVE` (компас) — `cost_type=MOVEMENT`, бюджет списывает диспетчер по **запрошенным** `ft`.
+- `MOVE_TO` (клик/BFS) — `cost_type=FREE`, бюджет списывает **хендлер** по факту пройденного (`spent`, с диагональю 5/10 через `step_cost`).
+- `DASH` — `cost_type=ACTION`, хендлер **добавляет** `+speed` к движению.
-Фикс: в боевой ветке `handle_move` после расчёта `moved_ft`, если `ctx.turn_budget` присутствует:
-- если `moved_ft > budget.movement_remaining` — вернуть `ActionResult(success=False, error=_("No movement remaining"))` до `set_position` (не двигаться);
-- иначе после успешного перемещения `budget.movement_remaining -= moved_ft`.
+Movement — исполнительная стоимость: сколько реально пройдено, известно только после резолва (стены, обрыв пути на OA-смерти, диагональное чередование). Декларативный `action_cost` этого знать не может — он чистая функция от параметров, без карты. Поэтому `MOVE_TO`/`DASH` правильно отдают учёт движения хендлеру, а `MOVE` — сидит на заборе: списывает **запрошенные** ft, а не фактически пройденные. Латентные баги, замаскированные тем, что RuleBrain ходит ортогонально по 5ft:
-Порядок с OA: списывать бюджет только за реально сделанный шаг (когда `set_position` состоялся и mover жив). Проверку достаточности бюджета делать до OA-триггеров/`set_position`.
+- **Диагональ недосписывается** — диспетчер берёт плоские 5 за диагональный шаг, `grid_distance` даёт ×1.5.
+- **Заблокированный/частичный шаг пересписывается** — запрос `ft=30`, стена на 10ft → двигается 10, списывается 30.
-## Tests First
+Дополнительно: гейт мозга (`movement_remaining` в awareness) и фактическое списание (запрошенные ft в диспетчере) считаются по-разному → расхождение «мозг думал что хватит, а списалось иначе». Совпадают только в ортогональном 5ft-случае.
+
+## Почему так (обоснование)
+
+Инвариант: **диспетчер владеет только осью действий (actions / bonus_actions / reaction); `movement_remaining` мутируется исключительно в movement-хендлерах.** Тогда:
+
+- Учёт движения — в одном месте, по факту пройденного. Диагональ/частичный шаг чинятся сами собой.
+- Awareness пересобирается каждую итерацию цикла хода с живым `turn_budget` (`round.py:269`), поэтому гейт мозга и списание — гарантированно одно и то же число. Отбивка честна по построению.
+- Мозгу нужна видимость остатка, чтобы планировать «подбежал-ударил-отбежал». Игрок (`BudgetDisplay`/`BattleMap.reachable`) и RuleBrain (`rule_brain.py:274`) её уже имеют; LLM-промпт — нет (только `Speed`, дистанции). Единственная реальная дыра видимости.
+
+## Scope
+
+Три части, все в одном таске:
+
+**A. Единый учёт (Option C).**
+- `core/action_defs.py`: `MOVE` `cost_type` MOVEMENT → FREE; удалить член enum `CostType.MOVEMENT` (после снятия у него не остаётся потребителей).
+- `rules/actions.py`: убрать `case CostType.MOVEMENT`.
+- `rules/handlers/movement.py` `handle_move`, боевая ветка: после `moved_ft = grid_distance(...)`, если `budget is not None and moved_ft > budget.movement_remaining` → reject `_("Not enough movement")` **до** OA/`set_position` (не двигаться); иначе после успешного `set_position` — `budget.movement_remaining -= moved_ft`. Ранний `return` при гибели mover в OA бюджет не трогает.
-Продуктовые сценарии (юнит на хендлере + через боевой ход RuleBrain):
+**B. Внятная отбивка в `handle_move_to`.**
+- Различать «стена, пути нет» и «путь есть, но длиннее бюджета». В ветке `if not path`: fallback-пересчёт достижимости без лимита бюджета; если цель достижима в принципе → `_("Not enough movement to reach there")`, иначе `_("No path to target")`.
-- Существо со `speed = 30` в бою, вне досягаемости цели. Серия компасных `move` (5ft) суммарно списывает `movement_remaining` до нуля; после исчерпания `_try_advance` возвращает `None`, и ход завершается штатно (`end_turn`), а не через `consecutive_failures_end_turn`. Итоговое пройденное расстояние ≤ speed (не через всю карту).
-- Один `move` на 5ft уменьшает `movement_remaining` ровно на пройденные футы (`grid_distance`).
-- `move`, требующий больше футов, чем осталось в бюджете, отбивается `success=False` и НЕ меняет позицию на карте.
-- Регресс: `handle_move_to` (BFS-клик игрока) по-прежнему корректно списывает бюджет и не задет фиксом.
+**C. Видимость движения для LLM.**
+- `llm/brain.py` `_combat_awareness_to_dict`: добавить `movement_remaining` (из `aw.turn_budget`, fallback `self_speed`); пометить nearby-цели достижимыми в этот ход (`distance_ft <= movement_remaining`).
+- `llm/prompts.py`: строка статуса «Movement remaining: N ft»; отметка достижимых целей в списке nearby.
-## Implementation
+Новые i18n-строки переводятся в `locale/ru` (проект RU-default).
-- `rules/handlers/movement.py` — `handle_move`, только боевая ветка (`ctx.combat_state is not None and ctx.on_leave_reach is not None`). Non-combat ветка (emit для CombatManager) не трогается.
-- `budget = ctx.turn_budget`; guard `if budget is not None`. Проверка достаточности — до `set_position`/OA; списание — после успешного шага.
-- `moved_ft` уже считается как `grid_distance(cur_pos, new_pos)`; им же вычитать (одиночный компасный шаг, без diagonal 5/10-чередования `move_to` — приемлемо для quick-fix, отметить комментарием).
-- Убедиться, что при гибели mover в OA (ранний `return ActionResult()` на строке 66) бюджет не списывается за несделанный шаг.
+## Tests First
+
+- `handle_move` (боевая ветка): одиночный 5ft `move` уменьшает `movement_remaining` ровно на `grid_distance`; серия шагов упирается в speed (speed=30 → 6 шагов, дальше reject, позиция замерла, суммарно ≤ speed); `move`, требующий больше остатка → `success=False`, позиция и бюджет не меняются, `emit` не вызван; гибель mover в OA не списывает бюджет.
+- Регресс диспетчера: `test_dash_adds_effective_speed_without_moving_then_move_spends_it` остаётся зелёным (dash → 45, компасный move 5ft → 40) — ровно одно место списывает.
+- `handle_move_to`: цель за пределами бюджета (но достижимая) → `Not enough movement to reach there`; цель за стеной → `No path to target`; успешный путь по-прежнему списывает `spent`.
+- `_combat_awareness_to_dict`: содержит `movement_remaining`; nearby в пределах остатка помечены достижимыми.
## Acceptance Criteria
- [ ] Tests written and RED (before implementation)
- [ ] Implementation makes tests GREEN
- [ ] Existing tests still pass (`make check`)
-- [ ] Компасный `move` в бою проверяет и списывает `movement_remaining`
-- [ ] RuleBrain-существо не пересекает карту за один ход; ход завершается по исчерпании speed, а не только failsafe
-- [ ] `handle_move_to` / `handle_dash` поведение не изменилось
+- [ ] `MOVE` FREE, `CostType.MOVEMENT` удалён, `handle_move` списывает фактический `moved_ft` атомарно
+- [ ] Диагональ/частичный компасный шаг списываются по факту, не по запросу
+- [ ] `handle_move_to` различает «нет пути» и «не хватает движения»
+- [ ] LLM-промпт показывает остаток движения и достижимые цели
+- [ ] `handle_move_to` / `handle_dash` числовое поведение бюджета не изменилось
## Status
-`pending`
+`done`
+
+## Developer Notes
+
+Разведка перевернула таск: исходная премиса «бюджет не тратится» неверна. `ActionDispatcher.dispatch` уже списывал движение через `TurnBudget.consume(action_cost(MOVE))` (MOVE был `cost_type=MOVEMENT`), а `check_budget` гейтил. Воспроизведено скриптом: speed=30 через реальный диспетчер = ровно 6 шагов по 5ft, дальше reject. Кайтинг работал. Backlog-анализ читал только тело `handle_move` и не увидел диспетчерский слой.
+
+Реализовано (унификация оси движения):
+- **A.** `MOVE` → `cost_type=FREE`, член enum `CostType.MOVEMENT` и его ветка в `_cost_type_to_cost` удалены. `handle_move` (боевая ветка) теперь сам проверяет `moved_ft <= movement_remaining` до OA/`set_position` (reject целиком, без частичного перемещения) и списывает `moved_ft` по факту. Гибель mover в OA бюджет не трогает.
+- Добавлен валидатор `check_movement_available` (`rules/validation.py`): раз MOVE стал FREE, `check_budget` больше не отсекает MOVE при 0 движения — новый чек возвращает `No movement remaining`, сохраняя «0 движения → MOVE недоступен» (регрессия в `BaseActionProvider`, который гейтит через `validate_action`).
+- **B.** `handle_move_to`: при `not path` — fallback-пересчёт достижимости без лимита бюджета; цель достижима в принципе → `Not enough movement to reach there`, иначе `No path to target`. Extra-Dijkstra только на failure-пути.
+- **C.** `_combat_awareness_to_dict` (`llm/brain.py`) отдаёт `movement_remaining` (fallback `self_speed`) и метит nearby-цели `reachable` при `distance_ft <= movement_remaining`; `prompts.py` рендерит «Movement remaining this turn: N ft» и «(in reach this turn)».
+
+Обновлённые старые тесты (интенциональная смена контракта, не подгон): `test_action_dispatcher` (`test_move_consumes_movement`/`test_move_insufficient_movement_rejected` переписаны на полный боевой ctx — бюджет владеет хендлер; `test_dash_...then_move_spends_it` остался зелёным без правок), `test_multi_action.test_move_costs_movement` → `test_move_is_free_at_dispatcher` (movement_ft==0), `test_session_awareness` (cost_type `movement`→`free`). Новые i18n-строки переведены в `locale/ru`, `.mo` перекомпилирован.
+
+Симптом живой партии «монстр через карту» этим механизмом не воспроизводится — вероятно dash или мелкая карта, вне таска (отмечено в бэклоге/спринте).
diff --git a/src/dnd_simulator/core/action_defs.py b/src/dnd_simulator/core/action_defs.py
index 31f313b0..88972ddb 100644
--- a/src/dnd_simulator/core/action_defs.py
+++ b/src/dnd_simulator/core/action_defs.py
@@ -18,7 +18,6 @@ class CostType(StrEnum):
FREE = "free"
ACTION = "action"
BONUS_ACTION = "bonus_action"
- MOVEMENT = "movement"
REACTION = "reaction"
@@ -209,7 +208,9 @@ def get_action_def(action_type: ActionType) -> ActionDef:
ActionDef(
action_type=ActionType.MOVE,
description=N_("Move up to your speed."),
- cost_type=CostType.MOVEMENT,
+ # FREE at the dispatcher: handle_move owns the movement-budget accounting (like MOVE_TO/DASH),
+ # charging the distance actually walked rather than the distance requested.
+ cost_type=CostType.FREE,
combat_mode=CombatMode.COMBAT_ONLY,
ends_peaceful_turn=True,
params=(
diff --git a/src/dnd_simulator/llm/brain.py b/src/dnd_simulator/llm/brain.py
index adc119f8..d8198608 100644
--- a/src/dnd_simulator/llm/brain.py
+++ b/src/dnd_simulator/llm/brain.py
@@ -157,6 +157,9 @@ def _peaceful_awareness_to_dict(aw: PeacefulAwareness) -> dict[str, object]:
def _combat_awareness_to_dict(aw: CombatAwareness) -> dict[str, object]:
"""Convert CombatAwareness to dict format expected by prompt builders."""
+ # Movement left this turn drives kite planning ("close, hit, back off"). Fall back to full
+ # speed when no live budget is attached (e.g. awareness built outside a turn).
+ movement_remaining = aw.turn_budget.movement_remaining if aw.turn_budget is not None else aw.self_speed
nearby_list: list[dict[str, object]] = []
for e in aw.nearby:
entry: dict[str, object] = {"id": e.id, "description": e.description}
@@ -164,6 +167,8 @@ def _combat_awareness_to_dict(aw: CombatAwareness) -> dict[str, object]:
entry["is_wounded"] = True
if e.distance_ft:
entry["distance_ft"] = e.distance_ft
+ if e.distance_ft <= movement_remaining:
+ entry["reachable"] = True
if e.direction:
entry["direction"] = e.direction
nearby_list.append(entry)
@@ -178,6 +183,7 @@ def _combat_awareness_to_dict(aw: CombatAwareness) -> dict[str, object]:
"self_weapon": aw.self_weapon,
"self_weapon_damage": aw.self_weapon_damage,
"self_conditions": [c.value for c in aw.self_conditions],
+ "movement_remaining": movement_remaining,
"nearby": nearby_list,
"round_number": aw.round_number,
"walls": aw.walls,
diff --git a/src/dnd_simulator/llm/prompts.py b/src/dnd_simulator/llm/prompts.py
index 18296ff5..53265702 100644
--- a/src/dnd_simulator/llm/prompts.py
+++ b/src/dnd_simulator/llm/prompts.py
@@ -116,6 +116,7 @@ def build_npc_combat_prompt(
weapon = combat_awareness["self_weapon"]
weapon_dmg = combat_awareness["self_weapon_damage"]
speed = combat_awareness.get("self_speed", 30)
+ movement_remaining = combat_awareness.get("movement_remaining", speed)
hp_status = _("healthy")
if hp < max_hp // 2:
@@ -130,7 +131,8 @@ def build_npc_combat_prompt(
dist = e.get("distance_ft")
direction = e.get("direction")
if dist is not None and direction is not None:
- entities_lines.append(f"- {e['description']} (id: {e['id']}) — {dist} ft {direction}")
+ reach_tag = " " + _("(in reach this turn)") if e.get("reachable") else ""
+ entities_lines.append(f"- {e['description']} (id: {e['id']}) — {dist} ft {direction}{reach_tag}")
else:
entities_lines.append(f"- {e['description']} (id: {e['id']})")
@@ -202,6 +204,9 @@ def build_npc_combat_prompt(
+ "\n"
+ "- "
+ _("Speed: {speed} ft").format(speed=speed)
+ + "\n"
+ + "- "
+ + _("Movement remaining this turn: {n} ft").format(n=movement_remaining)
+ f"{conditions_ctx}\n"
+ f"{entities_ctx}"
f"{items_ctx}"
diff --git a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.mo b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.mo
index 6d77b76300fa8553c5927476f0f0697f75a8fdb8..a76e91bf1702c78631e99b552a49c2845a414ac0 100644
GIT binary patch
delta 5643
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
IURo0SUyZB*zW@LL
delta 5288
zcmYM%3s9C-9>?*6c;O--R}lnJP`Ri`N=SjAcn!tY3$ke56*W=|Dy`K=S*xU)dn==#?_3u%k{0Vhq8}czh^wJsop|&U;wX%Fv$Y&sP
zGs{pbd<}KqMohxLpzgnbTEG?b#1?e4!dn!)(T7p>U81Y;Y{pL)CU_>(6lod$(850wMUF#xxrKkh~CSski_
zdenp)P!T$h?XeY=WdF78eHlH5`Y_DIIjDZCP~%od6MuE^6%GCH5-Oyf*)R-<&_I3uUgXd5Yd=>TFd$xTas^8f-w(=Hg!tLlp
z14g2fZvbk6h3Gs+VF`s%_=&XzHBcIN>)aM0O{NS-;HNkce@5j*EJsHxPe&GQa#0g5
zvF<>H{4DDJR@6eh`xAei#{`zs8PjnHjiMVvr=td#i@JZI^;y(FFImgbhx+TbUWw}G
zJ@mv6QT^<;?se=9`|S+}ZM_cF(J@>98a42D){EBftv{e9(uR8OSJXh>>?6Bm{81Bm
z3>A@V^hPI-f*vTa4Rh@c&!7f;8P)Mxd;JaTJN9}NDng&wdNpdGL#XGDTI*5cG@$yu
zfNZ&Anki_eEvN^6L3QxJ);-BIwfmwT3`T8LcT~>AV;4-djN%-p+8=?-a$t%2(MK4T=qn*B+b@~kbBHZ)P0|!-e9Lu?}e)vhzds?zac>w
zNA+7?ql#!NY9UdaX-za2HGvWg#j+v9Uq2$7X;5<1qH^IFj=>YC-|YZi0uymOK7m!J
z_W$4nOyDgx99N*WaRbiG;>2N#Lm`sF*lc&;`Pheg
z1+qAE2=(_sE2=$gm>cp;RQ69m?d^0Nh2Kk?
z^2zK&{p>blH@t^Bj-f0g4kJ-}n2k!#2{;|g@H$>XMer1(6J>K7$Ks%o?%xY5Q4!mU
zqjmnjp`eh4GJW#N6eBM$vja85v$nknl|&&tH5D^33ExId>{hsx>=J^
z{fBDRYAec7_Z>lH
z|F5VB_sex7GZr<`7ciFbO@+Nsi#mSI*b{@tabz$R74kKx2REQXxEG(oCM54n#&~|9
z@Fmm¬uNn&+OTRj38kqc>hgCy>H*3JTeMjKC1i6p3n*QP-E?0uNp&=+lm4^`x6j
zKXWE~b3Jg9TTerUJRjq+7&YNaOu+q^h8I!e1y3gaDkM#I_cjLyQGXhJ@O@<4%^p+;
zZ=yo#KgHei3{=QxqqeBRw%4L2b`h0Z9()C30V-0(s7UO|C;lpYNkcFEC$jzKH%!7Y
zQ%M*s#liSFY63qY!7@<=?tR6mm2O5~Jb*fGUmz#YTtYwW`Fr>NWYqoB915!_yod^Y
z8!A+Rg>FP1$8hR%F$({H+M0K92%g727&Ofdc{-+3pM#3rHe_tmjGAx;XEhDiV^?&(
zq|lARMI3;?pdt}RW|2Q;G%A#pxD@MfIVR0;-HG$4N0D)Tu?)51J;=x0;7dt7Zk8L7
z&8Xx)h#5Nn=PBrcu-W`~3JWk3Ph&syp5y+v`cdpoeHChgTakP;M{K)4sjsa|!elJP
zj<^#$NsuFR3{1&+r
zh`{#Ldt*E%qLO%$bt4X^-hkN{wAfARNe%^$u-S@v*oHd4BcFCx>|iAIEf|M?MP>a3
zd=?Xn-RHMiZ(|Pa6Q6N^oZdm5s)LxN`d%a$#DuJrjOZ*2;Bs>VCC
hpHEF;k0~BC|B49 ActionCost:
return ActionCost(actions=1)
case CostType.BONUS_ACTION:
return ActionCost(bonus_actions=1)
- case CostType.MOVEMENT:
- ft = int(str(action.params["ft"])) if action.params and "ft" in action.params else 5
- return ActionCost(movement_ft=ft)
case CostType.REACTION:
return ActionCost(reaction=1)
diff --git a/src/dnd_simulator/rules/handlers/movement.py b/src/dnd_simulator/rules/handlers/movement.py
index 65f8c0cd..2693a77d 100644
--- a/src/dnd_simulator/rules/handlers/movement.py
+++ b/src/dnd_simulator/rules/handlers/movement.py
@@ -58,15 +58,26 @@ def handle_move(actor: Creature, action: Action, emit_fn: EmitFn, ctx: ActionCon
if new_pos == cur_pos:
return ActionResult(success=False, error=_("Cannot move there, blocked"))
+ # Grid distance of this single compass step (no diagonal 5/10 alternation, unlike MOVE_TO — fine per step).
+ moved_ft = grid_distance(cur_pos, new_pos)
+
+ # Movement budget lives here, not in the dispatcher (MOVE is cost_type=FREE). Reject the whole
+ # step if it can't be paid for — no partial placement, so the mover never lands where it can't afford.
+ budget = ctx.turn_budget
+ if budget is not None and moved_ft > budget.movement_remaining:
+ return ActionResult(success=False, error=_("Not enough movement"))
+
# Check OA triggers
triggers = find_oa_triggers([cur_pos, new_pos], actor, _get_combatants(ctx), bm, ctx.combat_state)
for _step_idx, reactors in triggers:
alive = ctx.on_leave_reach(actor, cur_pos, new_pos, reactors)
if not alive:
+ # Mover died mid-step: nothing committed, so no budget spent.
return ActionResult()
bm.set_position(actor.id, new_pos)
- moved_ft = grid_distance(cur_pos, new_pos)
+ if budget is not None:
+ budget.movement_remaining -= moved_ft
emit_fn(
Event(
@@ -119,6 +130,11 @@ def handle_move_to(actor: Creature, action: Action, emit_fn: EmitFn, ctx: Action
reachable = compute_reachable(start_pos, budget.movement_remaining, bm, actor.id)
path = reachable.get(target)
if not path:
+ # Distinguish "sealed off by walls" from "reachable but past this turn's budget" so the
+ # brain/player gets an actionable reason. Only pay the extra Dijkstra on the failure path.
+ unbounded = compute_reachable(start_pos, bm.width * bm.height * 15, bm, actor.id)
+ if target in unbounded:
+ return ActionResult(success=False, error=_("Not enough movement to reach there"))
return ActionResult(success=False, error=_("No path to target"))
# Walk step-by-step, checking OA triggers at each step
diff --git a/src/dnd_simulator/rules/validation.py b/src/dnd_simulator/rules/validation.py
index 6a1dabce..189a9588 100644
--- a/src/dnd_simulator/rules/validation.py
+++ b/src/dnd_simulator/rules/validation.py
@@ -136,6 +136,16 @@ def check_budget(actor: Creature, action: Action, ctx: ActionContext) -> Validat
return None
+def check_movement_available(actor: Creature, action: Action, ctx: ActionContext) -> ValidationError | None:
+ """Compass MOVE needs movement left. MOVE is cost_type=FREE (handler owns the budget), so
+ check_budget can't gate it — this keeps a spent-out creature from being offered MOVE."""
+ if action.name is not ActionType.MOVE:
+ return None
+ if ctx.turn_budget is not None and ctx.turn_budget.movement_remaining <= 0:
+ return ValidationError("NO_MOVEMENT", _("No movement remaining"))
+ return None
+
+
def check_target_valid(actor: Creature, action: Action, ctx: ActionContext) -> ValidationError | None:
"""For targeted actions: target must exist, be a Creature, be alive, be at same location."""
if get_action_def(action.name).target_mode != TargetMode.SINGLE:
@@ -366,6 +376,7 @@ def check_target_scope(actor: Creature, action: Action, ctx: ActionContext) -> V
check_action_mode,
check_cost_mode,
check_budget,
+ check_movement_available,
check_has_item,
check_target_valid,
check_lootable_target,
diff --git a/tests/unit/test_action_dispatcher.py b/tests/unit/test_action_dispatcher.py
index c6c40e5f..3c8be2b5 100644
--- a/tests/unit/test_action_dispatcher.py
+++ b/tests/unit/test_action_dispatcher.py
@@ -248,22 +248,40 @@ def failing_emit(event: Event) -> ActionResult:
assert not result.success
assert budget.actions == 2 # unchanged
+ def _combat_move_ctx(self, budget: TurnBudget, creature: Creature) -> ActionContext:
+ """Full combat context so MOVE resolves in handle_move's combat branch (handler owns budget)."""
+ battle_map = BattleMap(width=60, height=60)
+ battle_map.set_position(creature.id, Position(10, 10))
+ combat = CombatState(location_id="loc", turn_order=[creature.id], battle_map=battle_map)
+ return ActionContext(
+ is_combat=True,
+ current_turn_entity_id=creature.id,
+ turn_budget=budget,
+ combat_state=combat,
+ get_entity=lambda eid: creature if eid == creature.id else None,
+ on_leave_reach=lambda _mover, _start, _end, _reactors: True,
+ )
+
def test_move_consumes_movement(self) -> None:
+ # MOVE is FREE at the dispatcher now; the combat handler charges the distance actually walked.
budget = TurnBudget(actions=1, bonus_actions=0, movement_remaining=30)
- ctx = _combat_ctx(budget)
+ creature = _creature()
+ ctx = self._combat_move_ctx(budget, creature)
d = create_dispatcher(_WORLD)
- action = Action(name=ActionType.MOVE, params={"direction": "N", "ft": 10})
- result = d.dispatch(_creature(), action, ctx, _noop_emit)
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 10})
+ result = d.dispatch(creature, action, ctx, _noop_emit)
assert result.success
assert budget.movement_remaining == 20
def test_move_insufficient_movement_rejected(self) -> None:
budget = TurnBudget(actions=1, bonus_actions=0, movement_remaining=5)
- ctx = _combat_ctx(budget)
+ creature = _creature()
+ ctx = self._combat_move_ctx(budget, creature)
d = create_dispatcher(_WORLD)
- action = Action(name=ActionType.MOVE, params={"direction": "N", "ft": 10})
- result = d.dispatch(_creature(), action, ctx, _noop_emit)
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 10})
+ result = d.dispatch(creature, action, ctx, _noop_emit)
assert not result.success
+ assert budget.movement_remaining == 5
def test_dash_adds_effective_speed_without_moving_then_move_spends_it(self) -> None:
budget = TurnBudget(actions=1, bonus_actions=0, movement_remaining=10)
diff --git a/tests/unit/test_handlers_movement.py b/tests/unit/test_handlers_movement.py
index f5841368..91e2c19f 100644
--- a/tests/unit/test_handlers_movement.py
+++ b/tests/unit/test_handlers_movement.py
@@ -16,6 +16,7 @@
from dnd_simulator.core.turn_budget import TurnBudget
from dnd_simulator.i18n import set_language
from dnd_simulator.rules.handlers.movement import handle_dash, handle_disengage, handle_move, handle_travel, handle_wait
+from dnd_simulator.rules.movement import grid_distance
from dnd_simulator.rules.validation import ActionContext
@@ -133,6 +134,97 @@ def test_move_blocked_at_map_edge_fails(self) -> None:
# Should fail since south would go to negative y which is off-map
assert not result.success
+ def test_move_in_combat_consumes_budget(self) -> None:
+ """A single 5ft combat move decrements movement_remaining by the feet moved."""
+ mover = _creature("mover", speed=30)
+ assert mover.turn_budget is not None
+ bm = _battle_map()
+ bm.set_position("mover", Position(10, 10))
+ cs = _combat_state(bm, ["mover"])
+ on_leave_reach = MagicMock(return_value=True)
+ ctx = _ctx(mover, combat_state=cs, entities={"mover": mover}, on_leave_reach=on_leave_reach)
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 5})
+ emit_fn = MagicMock(return_value=ActionResult())
+ world = MagicMock()
+
+ result = handle_move(mover, action, emit_fn, ctx, world)
+
+ assert result.success
+ assert bm.get_position("mover") == Position(10, 15)
+ assert mover.turn_budget.movement_remaining == 25
+
+ def test_move_series_depletes_budget_and_then_blocks(self) -> None:
+ """Repeated 5ft moves drain speed to zero; the move past the budget fails, position frozen."""
+ mover = _creature("mover", speed=30)
+ assert mover.turn_budget is not None
+ bm = _battle_map()
+ bm.set_position("mover", Position(10, 10))
+ cs = _combat_state(bm, ["mover"])
+ on_leave_reach = MagicMock(return_value=True)
+ ctx = _ctx(mover, combat_state=cs, entities={"mover": mover}, on_leave_reach=on_leave_reach)
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 5})
+ emit_fn = MagicMock(return_value=ActionResult())
+ world = MagicMock()
+
+ # speed 30 → six 5ft steps, then dry
+ for _ in range(6):
+ assert handle_move(mover, action, emit_fn, ctx, world).success
+ assert mover.turn_budget.movement_remaining == 0
+ assert bm.get_position("mover") == Position(10, 40)
+ # Total distance never exceeded speed (10 → 40 == 30ft).
+ assert grid_distance(Position(10, 10), Position(10, 40)) == 30
+
+ # Seventh step has no budget left: rejected, position unchanged.
+ result = handle_move(mover, action, emit_fn, ctx, world)
+ assert not result.success
+ assert bm.get_position("mover") == Position(10, 40)
+ assert mover.turn_budget.movement_remaining == 0
+
+ def test_move_exceeding_remaining_budget_is_rejected_atomically(self) -> None:
+ """A 10ft move with only 5ft left fails and leaves the map position untouched (no partial move)."""
+ mover = _creature("mover", speed=30)
+ assert mover.turn_budget is not None
+ mover.turn_budget.movement_remaining = 5
+ bm = _battle_map()
+ bm.set_position("mover", Position(10, 10))
+ cs = _combat_state(bm, ["mover"])
+ on_leave_reach = MagicMock(return_value=True)
+ ctx = _ctx(mover, combat_state=cs, entities={"mover": mover}, on_leave_reach=on_leave_reach)
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 10})
+ emit_fn = MagicMock(return_value=ActionResult())
+ world = MagicMock()
+
+ result = handle_move(mover, action, emit_fn, ctx, world)
+
+ assert not result.success
+ assert bm.get_position("mover") == Position(10, 10)
+ assert mover.turn_budget.movement_remaining == 5
+ emit_fn.assert_not_called()
+
+ def test_move_does_not_spend_budget_when_mover_dies_in_oa(self) -> None:
+ """If the mover dies to an opportunity attack, no step commits and no budget is spent."""
+ mover = _creature("mover", speed=30)
+ assert mover.turn_budget is not None
+ bm = _battle_map()
+ bm.set_position("mover", Position(10, 10))
+ # An enemy adjacent so leaving its reach triggers an OA.
+ enemy = _creature("enemy", speed=30)
+ bm.set_position("enemy", Position(10, 15))
+ cs = _combat_state(bm, ["mover", "enemy"])
+ # on_leave_reach reports the mover died (alive=False).
+ on_leave_reach = MagicMock(return_value=False)
+ ctx = _ctx(mover, combat_state=cs, entities={"mover": mover, "enemy": enemy}, on_leave_reach=on_leave_reach)
+ action = Action(name=ActionType.MOVE, params={"direction": "south", "ft": 5})
+ emit_fn = MagicMock(return_value=ActionResult())
+ world = MagicMock()
+
+ result = handle_move(mover, action, emit_fn, ctx, world)
+
+ # Move aborted by lethal OA — budget untouched, position not committed.
+ assert result.success # early return with a neutral ActionResult
+ assert mover.turn_budget.movement_remaining == 30
+ assert bm.get_position("mover") == Position(10, 10)
+
class TestMoveErrorI18n:
"""Movement-handler error strings must localize and carry no em-dash."""
diff --git a/tests/unit/test_llm_brain.py b/tests/unit/test_llm_brain.py
index c45e7dd0..2b4ec789 100644
--- a/tests/unit/test_llm_brain.py
+++ b/tests/unit/test_llm_brain.py
@@ -5,9 +5,10 @@
from unittest.mock import MagicMock
from dnd_simulator.core.action import ActionType
-from dnd_simulator.core.awareness import PeacefulAwareness
+from dnd_simulator.core.awareness import CombatAwareness, CombatEntity, PeacefulAwareness
+from dnd_simulator.core.turn_budget import TurnBudget
from dnd_simulator.layers.entities.models import Npc, NpcActivity, ScheduleEntry
-from dnd_simulator.llm.brain import LlmBrain
+from dnd_simulator.llm.brain import LlmBrain, _combat_awareness_to_dict
def _awareness(hour: int) -> PeacefulAwareness:
@@ -40,6 +41,50 @@ def _mock_llm_with_tool_call(tool_name: str, args: dict[str, object]) -> MagicMo
return llm
+def _combat_awareness(movement_remaining: int, *, near_dist: int, far_dist: int) -> CombatAwareness:
+ return CombatAwareness(
+ self_hp=20,
+ self_max_hp=20,
+ self_ac=12,
+ self_speed=30,
+ self_weapon="sword",
+ self_weapon_damage="1d8",
+ turn_budget=TurnBudget(actions=1, bonus_actions=0, movement_remaining=movement_remaining, reaction=1),
+ nearby=[
+ CombatEntity(id="near", description="A wolf", distance_ft=near_dist, direction="north"),
+ CombatEntity(id="far", description="A bear", distance_ft=far_dist, direction="east"),
+ ],
+ )
+
+
+class TestCombatAwarenessDictMovement:
+ """The LLM prompt dict must expose remaining movement and which targets are reachable this turn."""
+
+ def test_dict_carries_movement_remaining(self) -> None:
+ aw = _combat_awareness(15, near_dist=10, far_dist=40)
+ d = _combat_awareness_to_dict(aw)
+ assert d["movement_remaining"] == 15
+
+ def test_dict_falls_back_to_speed_without_budget(self) -> None:
+ aw = _combat_awareness(15, near_dist=10, far_dist=40)
+ aw = replace_turn_budget_none(aw)
+ d = _combat_awareness_to_dict(aw)
+ assert d["movement_remaining"] == aw.self_speed
+
+ def test_reachable_targets_flagged_within_budget(self) -> None:
+ aw = _combat_awareness(15, near_dist=10, far_dist=40)
+ d = _combat_awareness_to_dict(aw)
+ by_id = {e["id"]: e for e in d["nearby"]} # type: ignore[union-attr]
+ assert by_id["near"].get("reachable") is True # 10ft ≤ 15ft budget
+ assert by_id["far"].get("reachable") is not True # 40ft > 15ft budget
+
+
+def replace_turn_budget_none(aw: CombatAwareness) -> CombatAwareness:
+ from dataclasses import replace
+
+ return replace(aw, turn_budget=None)
+
+
class TestLlmBrainScheduledActivity:
"""LlmBrain must read scheduled_activity via the Protocol path, not via isinstance(Npc)."""
diff --git a/tests/unit/test_move_to.py b/tests/unit/test_move_to.py
index 63329e22..aa9073ca 100644
--- a/tests/unit/test_move_to.py
+++ b/tests/unit/test_move_to.py
@@ -205,6 +205,56 @@ def test_move_to_edge_of_range_reaches_target(self) -> None:
assert ctx.turn_budget is not None
assert ctx.turn_budget.movement_remaining == 0
+ def test_move_to_beyond_budget_reports_movement_shortfall(self) -> None:
+ """Target reachable in principle but past the budget → 'not enough movement', not 'no path'."""
+ from dnd_simulator.i18n import set_language
+ from dnd_simulator.rules.handlers.movement import handle_move_to
+
+ set_language("en")
+ try:
+ bm = BattleMap(width=60, height=60)
+ bm.set_position("player", Position(0, 0))
+ ctx = self._make_ctx(bm, movement_remaining=10) # only 2 cells
+ actor = self._make_creature()
+ action = Action(name=ActionType.MOVE_TO, params={"x": 50, "y": 0}) # 50ft away, open map
+
+ result = handle_move_to(actor, action, MagicMock(), ctx, MagicMock())
+
+ assert not result.success
+ assert result.error == "Not enough movement to reach there"
+ assert bm.get_position("player") == Position(0, 0)
+ finally:
+ set_language("en")
+
+ def test_move_to_walled_off_reports_no_path(self) -> None:
+ """Target sealed behind walls → 'no path', distinct from the budget-shortfall message."""
+ from dnd_simulator.i18n import set_language
+ from dnd_simulator.rules.handlers.movement import handle_move_to
+
+ set_language("en")
+ try:
+ bm = BattleMap(
+ width=30,
+ height=30,
+ walls=[
+ Wall(10, 5, 10, 15),
+ Wall(20, 5, 20, 15),
+ Wall(10, 15, 20, 15),
+ Wall(10, 5, 20, 5),
+ ],
+ )
+ bm.set_position("player", Position(0, 0))
+ ctx = self._make_ctx(bm, movement_remaining=30)
+ actor = self._make_creature()
+ action = Action(name=ActionType.MOVE_TO, params={"x": 15, "y": 10})
+
+ result = handle_move_to(actor, action, MagicMock(), ctx, MagicMock())
+
+ assert not result.success
+ assert result.error == "No path to target"
+ finally:
+ set_language("en")
+
def test_existing_move_direction_still_works(self) -> None:
"""Regression: the original direction-based move action still works."""
from dnd_simulator.rules.handlers.movement import handle_move
diff --git a/tests/unit/test_multi_action.py b/tests/unit/test_multi_action.py
index 3b405522..0c4c8655 100644
--- a/tests/unit/test_multi_action.py
+++ b/tests/unit/test_multi_action.py
@@ -109,9 +109,12 @@ def test_dodge_costs_one_action(self) -> None:
cost = action_cost(Action(name=ActionType.DODGE))
assert cost.actions == 1
- def test_move_costs_movement(self) -> None:
+ def test_move_is_free_at_dispatcher(self) -> None:
+ # MOVE is cost_type=FREE: handle_move charges the distance walked directly, so the
+ # dispatcher-level cost carries no movement (avoids double-counting with the handler).
cost = action_cost(Action(name=ActionType.MOVE, params={"toward": "x"}))
- assert cost.movement_ft == 5
+ assert cost.movement_ft == 0
+ assert cost.actions == 0 and cost.bonus_actions == 0 and cost.reaction == 0
# -- Multi-action loop integration tests --
diff --git a/tests/unit/test_session_awareness.py b/tests/unit/test_session_awareness.py
index c4dc56f9..636d2e3a 100644
--- a/tests/unit/test_session_awareness.py
+++ b/tests/unit/test_session_awareness.py
@@ -92,7 +92,8 @@ def test_combat_actions_have_cost_type(self) -> None:
by_name = {a["name"]: a for a in result["available_actions"]}
assert by_name["attack"]["cost_type"] == "action"
assert by_name["dodge"]["cost_type"] == "action"
- assert by_name["move"]["cost_type"] == "movement"
+ # MOVE is FREE at the dispatcher; handle_move owns the movement budget (like MOVE_TO/DASH).
+ assert by_name["move"]["cost_type"] == "free"
assert by_name["dash"]["cost_type"] == "action"
def test_rogue_dash_has_cost_type_and_cost_options(self) -> None:
From dfbdbf188fbd2d719fb40c6017edc198159e2551 Mon Sep 17 00:00:00 2001
From: vladmesh <16962535+vladmesh@users.noreply.github.com>
Date: Thu, 16 Jul 2026 01:30:55 +0300
Subject: [PATCH 04/19] sprint 024 phase 1 task 2: gate other-creature errors
out of player log, build faction relation fn once per awareness rebuild
---
docs/STATUS.md | 4 +-
.../tasks/phase1-task2-combat-log-noise.md | 14 +++-
.../layers/entities/awareness_builder.py | 39 ++++++-----
src/dnd_simulator/service/session.py | 12 ++--
.../service/transport_payloads.py | 28 ++++++++
tests/unit/test_awareness_relation_fn_once.py | 60 +++++++++++++++++
tests/unit/test_session_round_state.py | 66 ++++++++++++++++++-
7 files changed, 195 insertions(+), 28 deletions(-)
create mode 100644 tests/unit/test_awareness_relation_fn_once.py
diff --git a/docs/STATUS.md b/docs/STATUS.md
index 85344614..fcc08da5 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -12,10 +12,12 @@
**Sprint:** 024-playtest-quick-wins
**Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов
**Started:** 2026-07-16
-**Phase:** 1 — Читаемость и тактика боя (task 1 done, task 2 pending) — 2026-07-16
+**Phase:** 1 — Читаемость и тактика боя (task 2 done, task 3 pending) — 2026-07-16
Task 1 (единый учёт бюджета движения) done: `MOVE`→FREE, `handle_move` списывает `moved_ft` атомарно, `check_movement_available` держит «0 движения → MOVE недоступен», внятная отбивка `move_to`, остаток движения + достижимость целей в LLM-промпт. Премиса `combat-move-budget-not-consumed` оказалась неверной (бюджет уже списывал диспетчер) — таск переформулирован в унификацию раздвоенного учёта. `make check` зелёный (backend 2550, frontend 289).
+Task 2 (чистота боевого лога) done: on_action вынесен в `build_action_result` (transport_payloads), `error`/`budget` гейтятся на игрока — чужие технические отказы (заблокированный ход волка) больше не текут в лог игрока. В awareness `make_relation_fn` строится один раз на ребилд (было ~2N на пару): hostility вынесен в приватный `_hostility_from_relation(relation_fn)`, публичная сигнатура `check_faction_hostility` сохранена. `faction_hostility_check` INFO→DEBUG. `make check` зелёный (backend 2554, frontend 289).
+
### Phases
1. Читаемость и тактика боя (movement budget, enemy-error log gate, faction-log spam, second-wind zero heal)
diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md
index 6ba96015..a12bcda7 100644
--- a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md
+++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md
@@ -53,4 +53,16 @@
## Status
-`pending`
+`done`
+
+## Developer Notes
+
+Done 2026-07-16. `make check` green (backend 2554, frontend 289).
+
+**Error leak.** Extracted the `on_action` closure body into `build_action_result` (transport_payloads.py), matching the existing `build_round_state`/`build_turn_state` seam, so the gate is unit-testable without a live round thread. `actor`/`action` broadcast for any creature; `error` and `budget` gated on `creature.id == player.id`. Previously `error` had no gate (only budget did), so a wolf's blocked-path refusal leaked into the player's log. Dropped the now-unused `_budget_to_dict` import from session.py.
+
+**Relation fn once.** `check_faction_hostility` and `_resolve_relation` each built `make_relation_fn(query_fn)` per pair → ~2N closures per awareness rebuild. Split the hostility logic into a private `_hostility_from_relation(observer, other, relation_fn)` that takes a prebuilt callback; `build_combat_awareness` and `build_nearby_entities` now build the relation fn once and pass it down. `check_faction_hostility(observer, other, query_fn)` kept its public signature (builds once, delegates) so the existing `test_reputation_awareness.py` regression tests pass unchanged. `_resolve_relation` is private, so its signature changed to take `relation_fn`.
+
+**Log level.** `faction_hostility_check` moved from `logger.info` to `logger.debug` inside `_hostility_from_relation`. (`awareness_nearby` info-line is one-per-rebuild, not per-pair — left as is, out of scope.)
+
+Test discriminates the allocation: 8 nearby creatures went from 16 relation-fn builds to 1.
diff --git a/src/dnd_simulator/layers/entities/awareness_builder.py b/src/dnd_simulator/layers/entities/awareness_builder.py
index b51fa428..3b3b3f6f 100644
--- a/src/dnd_simulator/layers/entities/awareness_builder.py
+++ b/src/dnd_simulator/layers/entities/awareness_builder.py
@@ -41,6 +41,7 @@
from dnd_simulator.core.models import GameDateTime, QueryFn
from dnd_simulator.core.turn_budget import TurnBudget
from dnd_simulator.layers.entities.combat_manager import CombatManager
+ from dnd_simulator.rules.combat_sides import FactionRelationFn
logger = structlog.get_logger(domain="entity")
@@ -212,6 +213,9 @@ def build_combat_awareness(self, creature: Creature, query_fn: QueryFn | None =
battle_map_positions: dict[str, Position] = dict(combat.battle_map.positions) if combat else {}
my_pos = battle_map_positions.get(creature.id)
+ # Build the faction relation callback once per rebuild, not once per nearby entity.
+ relation_fn = make_relation_fn(query_fn) if query_fn is not None else None
+
# Build nearby list (exclude dead creatures and containers — containers are loot, not combatants)
from dnd_simulator.core.container import Container
@@ -240,7 +244,7 @@ def build_combat_awareness(self, creature: Creature, query_fn: QueryFn | None =
if combat and combat.entity_to_side and creature.id in combat.entity_to_side:
is_hostile = not are_allies(combat, creature.id, e.id)
else:
- is_hostile = self.check_faction_hostility(creature, e, query_fn)
+ is_hostile = self._hostility_from_relation(creature, e, relation_fn)
nearby.append(
CombatEntity(
id=e.id,
@@ -313,6 +317,8 @@ def build_nearby_entities(
creature_location = creature.location_id
if isinstance(creature, Npc):
creature_location = creature.current_location(hour)
+ # Build the faction relation callback once per rebuild, not once per nearby entity.
+ relation_fn = make_relation_fn(query_fn) if query_fn is not None else None
for e in self._entities.values():
if e.id == creature.id:
continue
@@ -333,8 +339,8 @@ def build_nearby_entities(
desc = creature.perceive(e) if isinstance(creature, Character) and isinstance(e, Entity) else e.name
is_wounded = isinstance(e, Creature) and e.is_alive and e.current_hp < e.max_hp // 2
is_dead = isinstance(e, Creature) and not e.is_alive
- is_hostile = (not lootable) and self.check_faction_hostility(creature, e, query_fn)
- relation = self._resolve_relation(creature, e, query_fn)
+ is_hostile = (not lootable) and self._hostility_from_relation(creature, e, relation_fn)
+ relation = self._resolve_relation(creature, e, relation_fn)
loot_items: list[ItemInfo] = []
loot_gold = 0
if lootable and isinstance(e, InventoryHolder):
@@ -401,22 +407,20 @@ def _resolve_faction_name(self, faction_id: str, query_fn: QueryFn | None) -> st
except (KeyError, ValueError, LayerError):
return ""
- def _resolve_relation(self, observer: Entity, other: Entity, query_fn: QueryFn | None) -> str:
- """Resolve the relation string between observer and other entity."""
+ def _resolve_relation(self, observer: Entity, other: Entity, relation_fn: FactionRelationFn | None) -> str:
+ """Resolve the relation string between observer and other entity against a prebuilt callback."""
if not observer.faction_id or not other.faction_id:
return FactionRelation.NEUTRAL.value
- if query_fn is None:
+ if relation_fn is None:
return FactionRelation.NEUTRAL.value
try:
- get_faction_relation = make_relation_fn(query_fn)
-
if isinstance(observer, Creature) and isinstance(other, Creature):
- relation = effective_relation(observer, other, get_faction_relation)
+ relation = effective_relation(observer, other, relation_fn)
else:
if observer.faction_id == other.faction_id:
return FactionRelation.FRIENDLY.value
- relation = get_faction_relation(observer.faction_id, other.faction_id)
+ relation = relation_fn(observer.faction_id, other.faction_id)
return relation.value
except (KeyError, ValueError, LayerError):
@@ -424,24 +428,27 @@ def _resolve_relation(self, observer: Entity, other: Entity, query_fn: QueryFn |
def check_faction_hostility(self, observer: Entity, other: Entity, query_fn: QueryFn | None) -> bool:
"""Check if two entities are hostile based on effective relation (reputation + faction)."""
+ relation_fn = make_relation_fn(query_fn) if query_fn is not None else None
+ return self._hostility_from_relation(observer, other, relation_fn)
+
+ def _hostility_from_relation(self, observer: Entity, other: Entity, relation_fn: FactionRelationFn | None) -> bool:
+ """Hostility check against a prebuilt relation callback (built once per awareness rebuild)."""
if not observer.faction_id or not other.faction_id:
return False
- if query_fn is None:
+ if relation_fn is None:
return False
try:
- get_faction_relation = make_relation_fn(query_fn)
-
if isinstance(observer, Creature) and isinstance(other, Creature):
- relation = effective_relation(observer, other, get_faction_relation)
+ relation = effective_relation(observer, other, relation_fn)
else:
# Plain Entity — no reputation, use faction-only logic
if observer.faction_id == other.faction_id:
return False
- relation = get_faction_relation(observer.faction_id, other.faction_id)
+ relation = relation_fn(observer.faction_id, other.faction_id)
is_hostile = relation == FactionRelation.HOSTILE
- logger.info(
+ logger.debug(
"faction_hostility_check",
observer=observer.id,
other=other.id,
diff --git a/src/dnd_simulator/service/session.py b/src/dnd_simulator/service/session.py
index bd02ca45..17adf983 100644
--- a/src/dnd_simulator/service/session.py
+++ b/src/dnd_simulator/service/session.py
@@ -28,8 +28,8 @@
from dnd_simulator.round import Round
from dnd_simulator.service.action_dispatcher import create_dispatcher
from dnd_simulator.service.transport_payloads import (
- _budget_to_dict,
_reaction_to_dict,
+ build_action_result,
build_round_state,
build_turn_state,
)
@@ -372,13 +372,9 @@ def on_reaction(
def on_action(creature: Creature, action: Action, budget: TurnBudget | None, error: str) -> None:
self._last_turn_msg = None # turn is being processed
with language_context(self.lang):
- msg = build_round_state("action_result", player, game_round, creature_host, self.world)
- msg["actor"] = creature.id
- msg["action"] = action.name
- if error:
- msg["error"] = error
- if budget is not None and creature.id == player.id:
- msg["budget"] = _budget_to_dict(budget)
+ msg = build_action_result(
+ player, game_round, creature_host, self.world, creature, action, budget, error
+ )
self._fire("on_action_result", msg)
game_round.set_on_action(on_action)
diff --git a/src/dnd_simulator/service/transport_payloads.py b/src/dnd_simulator/service/transport_payloads.py
index 1958d9e2..2fc0124c 100644
--- a/src/dnd_simulator/service/transport_payloads.py
+++ b/src/dnd_simulator/service/transport_payloads.py
@@ -19,6 +19,7 @@
from dnd_simulator.service.dto import JourneyView, PlayerStatusData, ResourcePoolView
if TYPE_CHECKING:
+ from dnd_simulator.core.action import Action
from dnd_simulator.core.creature_host import CreatureHost
from dnd_simulator.core.location import LocationGraph
from dnd_simulator.core.world import World
@@ -232,6 +233,33 @@ def build_round_state(
}
+def build_action_result(
+ player: PlayerCharacter,
+ game_round: Round,
+ creature_host: CreatureHost,
+ world: World,
+ creature: Creature,
+ action: Action,
+ budget: TurnBudget | None,
+ error: str,
+) -> dict[str, Any]:
+ """Build the per-action broadcast message fired after every creature's action.
+
+ ``actor`` and ``action`` are broadcast for any creature. ``error`` and ``budget`` are
+ player-only: another creature's technical refusal (a blocked wolf move) and its turn
+ budget must never leak into the player's combat log.
+ """
+ msg = build_round_state("action_result", player, game_round, creature_host, world)
+ msg["actor"] = creature.id
+ msg["action"] = action.name
+ is_player = creature.id == player.id
+ if error and is_player:
+ msg["error"] = error
+ if budget is not None and is_player:
+ msg["budget"] = _budget_to_dict(budget)
+ return msg
+
+
def build_turn_state(
player: PlayerCharacter,
awareness: PeacefulAwareness | CombatAwareness,
diff --git a/tests/unit/test_awareness_relation_fn_once.py b/tests/unit/test_awareness_relation_fn_once.py
new file mode 100644
index 00000000..3792c155
--- /dev/null
+++ b/tests/unit/test_awareness_relation_fn_once.py
@@ -0,0 +1,60 @@
+"""Awareness rebuild builds the faction relation callback once, not once-per-pair.
+
+Guards the O(N²) allocation regression from sprint 024 task 2: check_faction_hostility
+and _resolve_relation used to each construct make_relation_fn(query_fn) for every nearby
+entity, so a scene of N creatures rebuilt the closure ~2N times per awareness rebuild.
+"""
+
+from __future__ import annotations
+
+import dnd_simulator.layers.entities.awareness_builder as ab
+from dnd_simulator.core.character import Character
+from dnd_simulator.core.models import Answer, FactionRelation, Query, QueryType
+from dnd_simulator.layers.entities.layer import EntitiesLayer
+
+
+def _hostile_query_fn(target: str, query: Query) -> Answer:
+ if target == "politics" and query.question == QueryType.FACTION_RELATION:
+ return Answer(value=FactionRelation.HOSTILE)
+ if target == "politics" and query.question == QueryType.FACTION_NAME:
+ return Answer(value="Some Faction")
+ return Answer(value=None)
+
+
+def test_relation_fn_built_once_per_rebuild(monkeypatch) -> None: # type: ignore[no-untyped-def]
+ """One awareness rebuild over N nearby creatures builds the relation fn a constant number
+ of times, not O(N)."""
+ observer = Character(id="obs", name="Obs", location_id="road", faction_id="humans")
+ others = [Character(id=f"g{i}", name=f"G{i}", location_id="road", faction_id="goblins") for i in range(8)]
+ layer = EntitiesLayer([observer, *others])
+
+ calls = {"n": 0}
+ real_make = ab.make_relation_fn
+
+ def counting_make(query_fn): # type: ignore[no-untyped-def]
+ calls["n"] += 1
+ return real_make(query_fn)
+
+ monkeypatch.setattr(ab, "make_relation_fn", counting_make)
+
+ result = layer._awareness.build_nearby_entities(observer, hour=10, query_fn=_hostile_query_fn)
+
+ # Sanity: every other creature was surfaced (so the per-pair path really ran N times).
+ assert len(result) == 8
+ # Built once for the whole rebuild, not once-per-pair (that would be ~16 for hostility+relation).
+ assert calls["n"] == 1
+
+
+def test_hostility_semantics_unchanged_after_rebuild_refactor() -> None:
+ """Hostile factions still read as hostile through build_nearby_entities."""
+ observer = Character(id="knight", name="Knight", location_id="road", faction_id="kingdom")
+ enemy = Character(id="orc", name="Orc", location_id="road", faction_id="horde")
+ friend = Character(id="squire", name="Squire", location_id="road", faction_id="kingdom")
+
+ layer = EntitiesLayer([observer, enemy, friend])
+
+ result = layer._awareness.build_nearby_entities(observer, hour=10, query_fn=_hostile_query_fn)
+ by_id = {n.id: n for n in result}
+
+ assert by_id["orc"].is_hostile is True
+ assert by_id["squire"].is_hostile is False
diff --git a/tests/unit/test_session_round_state.py b/tests/unit/test_session_round_state.py
index f07ccbba..028db2d0 100644
--- a/tests/unit/test_session_round_state.py
+++ b/tests/unit/test_session_round_state.py
@@ -6,13 +6,15 @@
from typing import Any
from unittest.mock import MagicMock
-from dnd_simulator.core.character import Ability, AbilityScores, Attack, DamageComponent, DamageType
+from dnd_simulator.core.action import Action, ActionType
+from dnd_simulator.core.character import Ability, AbilityScores, Attack, Character, DamageComponent, DamageType
from dnd_simulator.core.models import Answer, GameDateTime, Query
from dnd_simulator.core.player import PlayerCharacter
+from dnd_simulator.core.turn_budget import TurnBudget
from dnd_simulator.core.world import World
from dnd_simulator.layers.entities.layer import EntitiesLayer
from dnd_simulator.service.dto import PlayerStatusData
-from dnd_simulator.service.transport_payloads import build_round_state
+from dnd_simulator.service.transport_payloads import build_action_result, build_round_state
_SWORD = Attack(
name="longsword",
@@ -190,3 +192,63 @@ def query_fn(target: str, query: Query) -> Answer:
result = build_round_state("round_result", player, game_round, entities_layer, world)
assert _COMMON_FIELDS.issubset(result.keys()), f"Missing fields: {_COMMON_FIELDS - result.keys()}"
+
+
+class TestActionResultErrorGate:
+ """build_action_result: actor/action broadcast for any creature; error and budget are player-only.
+
+ Guards npc-action-errors-leak-to-log — a wolf's blocked-path refusal must never reach the
+ player's combat log.
+ """
+
+ def _fixture(self) -> tuple[PlayerCharacter, Character, MagicMock, MagicMock, EntitiesLayer]:
+ player = PlayerCharacter(
+ id="p1",
+ name="Hero",
+ location_id="arena",
+ ability_scores=_scores(STR=16),
+ attacks=(_SWORD,),
+ )
+ wolf = Character(id="w1", name="Wolf", location_id="arena")
+ entities_layer = EntitiesLayer([player, wolf])
+
+ def query_fn(target: str, query: Query) -> Answer:
+ return Answer(value=None)
+
+ world = MagicMock(spec=World)
+ world.time = GameDateTime(year=1490, month=6, day=15, hour=14)
+ world.make_query_fn.return_value = query_fn
+ world.location_graph = MagicMock()
+ world.location_graph.has.return_value = False
+
+ game_round = MagicMock()
+ game_round.get_perceived_events.return_value = []
+
+ return player, wolf, world, game_round, entities_layer
+
+ def test_other_creature_error_is_not_leaked(self) -> None:
+ """An NPC action with an error yields no `error` field in the player's message."""
+ player, wolf, world, game_round, entities_layer = self._fixture()
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 5})
+
+ msg = build_action_result(
+ player, game_round, entities_layer, world, wolf, action, TurnBudget(), "Туда не пройти"
+ )
+
+ assert msg["actor"] == "w1"
+ assert msg["action"] == action.name
+ assert "error" not in msg
+ assert "budget" not in msg
+
+ def test_player_error_is_kept(self) -> None:
+ """The player's own action error is preserved (the gate does not swallow player errors)."""
+ player, _wolf, world, game_round, entities_layer = self._fixture()
+ action = Action(name=ActionType.MOVE, params={"direction": "north", "ft": 5})
+
+ msg = build_action_result(
+ player, game_round, entities_layer, world, player, action, TurnBudget(), "Недостаточно ресурсов"
+ )
+
+ assert msg["actor"] == "p1"
+ assert msg["error"] == "Недостаточно ресурсов"
+ assert "budget" in msg
From ad6a7384162eecdf04bb41ff5e36b39e18cd480d Mon Sep 17 00:00:00 2001
From: vladmesh <16962535+vladmesh@users.noreply.github.com>
Date: Thu, 16 Jul 2026 02:13:54 +0300
Subject: [PATCH 05/19] sprint 024 phase 1 task 3: second wind at full HP reads
'already at full health', not 'regaining 0 HP'
---
docs/STATUS.md | 4 +-
.../phase1-task3-second-wind-zero-heal.md | 6 ++-
.../layers/entities/perception.py | 8 +++-
src/dnd_simulator/locale/messages.pot | 12 ++++-
.../locale/ru/LC_MESSAGES/dnd_simulator.mo | Bin 29312 -> 29621 bytes
.../locale/ru/LC_MESSAGES/dnd_simulator.po | 6 +++
tests/unit/test_perception.py | 41 ++++++++++++++++++
7 files changed, 72 insertions(+), 5 deletions(-)
diff --git a/docs/STATUS.md b/docs/STATUS.md
index fcc08da5..376c9449 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -12,12 +12,14 @@
**Sprint:** 024-playtest-quick-wins
**Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов
**Started:** 2026-07-16
-**Phase:** 1 — Читаемость и тактика боя (task 2 done, task 3 pending) — 2026-07-16
+**Phase:** 1 — Читаемость и тактика боя (task 3 done, все задачи фазы 1 готовы) — 2026-07-16
Task 1 (единый учёт бюджета движения) done: `MOVE`→FREE, `handle_move` списывает `moved_ft` атомарно, `check_movement_available` держит «0 движения → MOVE недоступен», внятная отбивка `move_to`, остаток движения + достижимость целей в LLM-промпт. Премиса `combat-move-budget-not-consumed` оказалась неверной (бюджет уже списывал диспетчер) — таск переформулирован в унификацию раздвоенного учёта. `make check` зелёный (backend 2550, frontend 289).
Task 2 (чистота боевого лога) done: on_action вынесен в `build_action_result` (transport_payloads), `error`/`budget` гейтятся на игрока — чужие технические отказы (заблокированный ход волка) больше не текут в лог игрока. В awareness `make_relation_fn` строится один раз на ребилд (было ~2N на пару): hostility вынесен в приватный `_hostility_from_relation(relation_fn)`, публичная сигнатура `check_faction_hostility` сохранена. `faction_hostility_check` INFO→DEBUG. `make check` зелёный (backend 2554, frontend 289).
+Task 3 (Second Wind без «0 ОЗ») done: `_perceive_second_wind` получил ветку `healed == 0` (self/other) с сообщением о полном здоровье вместо «regaining 0 HP»; ненулевой путь не тронут. Новые EN-строки + RU-перевод, `.pot`/`.mo` перекомпилированы. `make check` зелёный (backend 2558, frontend 289). Фаза 1 полностью закрыта по задачам.
+
### Phases
1. Читаемость и тактика боя (movement budget, enemy-error log gate, faction-log spam, second-wind zero heal)
diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md
index 6431a1c6..50e58cc8 100644
--- a/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md
+++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md
@@ -41,4 +41,8 @@ Second Wind при полном HP показывает «восстанавли
## Status
-`pending`
+`done`
+
+## Developer Notes
+
+`_perceive_second_wind` gained a `healed == 0` branch (self/other) before the existing `regaining {hp} HP` lines; non-zero path untouched. New EN msgids ("You catch your breath, but you are already at full health" / "{entity} catches their breath, already at full health") wrapped in `_()`, RU translations added to `dnd_simulator.po`, `.pot` re-extracted via `make messages`, `.mo` recompiled via `make compile-messages`. Four product tests in `TestPerceiveSecondWind` cover zero/positive × self/other, asserting no "0" leaks and "full health" wording on the zero case. `make check` green (backend 2558, frontend 289).
diff --git a/src/dnd_simulator/layers/entities/perception.py b/src/dnd_simulator/layers/entities/perception.py
index 95428cdd..c6e67079 100644
--- a/src/dnd_simulator/layers/entities/perception.py
+++ b/src/dnd_simulator/layers/entities/perception.py
@@ -351,7 +351,13 @@ def _perceive_second_wind(event: Event, observer: Character, get_entity: GetEnti
assert isinstance(payload, EntitySecondWindPayload)
entity_id = payload.entity_id
healed = payload.healed
- if entity_id == observer.id:
+ self_acting = entity_id == observer.id
+ if healed == 0:
+ if self_acting:
+ return _("You catch your breath, but you are already at full health")
+ desc = _describe(observer, entity_id, get_entity)
+ return _("{entity} catches their breath, already at full health").format(entity=desc)
+ if self_acting:
return _("You catch your breath, regaining {hp} HP").format(hp=healed)
desc = _describe(observer, entity_id, get_entity)
return _("{entity} catches their breath, regaining {hp} HP").format(entity=desc, hp=healed)
diff --git a/src/dnd_simulator/locale/messages.pot b/src/dnd_simulator/locale/messages.pot
index 255c1a84..f26cc3bb 100644
--- a/src/dnd_simulator/locale/messages.pot
+++ b/src/dnd_simulator/locale/messages.pot
@@ -807,11 +807,19 @@ msgstr ""
msgid "{entity} invokes a blessing (+d4 to attack rolls for {n} rounds)"
msgstr ""
-#: src/dnd_simulator/layers/entities/perception.py:356
+#: src/dnd_simulator/layers/entities/perception.py:357
+msgid "You catch your breath, but you are already at full health"
+msgstr ""
+
+#: src/dnd_simulator/layers/entities/perception.py:359
+msgid "{entity} catches their breath, already at full health"
+msgstr ""
+
+#: src/dnd_simulator/layers/entities/perception.py:361
msgid "You catch your breath, regaining {hp} HP"
msgstr ""
-#: src/dnd_simulator/layers/entities/perception.py:358
+#: src/dnd_simulator/layers/entities/perception.py:363
msgid "{entity} catches their breath, regaining {hp} HP"
msgstr ""
diff --git a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.mo b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.mo
index a76e91bf1702c78631e99b552a49c2845a414ac0..121293fc32d67480fd0d3f001d7cd667390c25ed 100644
GIT binary patch
delta 5520
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