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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 diff --git a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po index 75dd4f8a..a2c54ba5 100644 --- a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po +++ b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po @@ -399,6 +399,12 @@ msgstr "Ты переводишь дух и восстанавливаешь {hp msgid "{entity} catches their breath, regaining {hp} HP" msgstr "{entity} переводит дух и восстанавливает {hp} ОЗ" +msgid "You catch your breath, but you are already at full health" +msgstr "Ты переводишь дух, но твоё здоровье уже полное" + +msgid "{entity} catches their breath, already at full health" +msgstr "{entity} переводит дух; его здоровье уже полное" + #: Action Surge msgid "You surge with energy, gaining an extra action" msgstr "Ты наполняешься энергией и получаешь дополнительное действие" diff --git a/tests/unit/test_perception.py b/tests/unit/test_perception.py index 14b79ea0..232e6b22 100644 --- a/tests/unit/test_perception.py +++ b/tests/unit/test_perception.py @@ -17,6 +17,7 @@ EntityDiedPayload, EntityLayOnHandsPayload, EntitySayPayload, + EntitySecondWindPayload, EntityUseItemPayload, EquipmentPayload, InspectPayload, @@ -513,6 +514,46 @@ def test_weapon_name_translated_on_equip(self, monkeypatch: pytest.MonkeyPatch) assert "[T]Dagger" in result +class TestPerceiveSecondWind: + """Second Wind perception: full-health case must not read as 'regaining 0 HP'.""" + + @staticmethod + def _event(entity_id: str, healed: int) -> Event: + return Event( + event_type=EventType.ENTITY_SECOND_WIND, + source_layer="entities", + data=EntitySecondWindPayload(entity_id=entity_id, healed=healed), + ) + + def test_zero_heal_self_reports_full_health(self) -> None: + observer = Character(id="fighter", name="Fighter", location_id="r1") + result = perceive_event(self._event("fighter", 0), observer, _get_entity_fn(observer)) + assert "0" not in result + assert "you" in result.lower() + assert "full health" in result.lower() + + def test_zero_heal_other_reports_full_health(self) -> None: + observer = Character(id="guard", name="Guard", location_id="r1") + actor = Character(id="fighter", name="Fighter", location_id="r1", race=Race.ELF) + result = perceive_event(self._event("fighter", 0), observer, _get_entity_fn(observer, actor)) + assert "0" not in result + assert "elf" in result.lower() + assert "full health" in result.lower() + + def test_positive_heal_self_reports_amount(self) -> None: + observer = Character(id="fighter", name="Fighter", location_id="r1") + result = perceive_event(self._event("fighter", 7), observer, _get_entity_fn(observer)) + assert "7" in result + assert "you" in result.lower() + + def test_positive_heal_other_reports_amount(self) -> None: + observer = Character(id="guard", name="Guard", location_id="r1") + actor = Character(id="fighter", name="Fighter", location_id="r1", race=Race.ELF) + result = perceive_event(self._event("fighter", 7), observer, _get_entity_fn(observer, actor)) + assert "7" in result + assert "elf" in result.lower() + + class TestPerceptionDispatchAndFailFast: """Dispatch dict routing and fail-fast on missing event data.""" From 49bb24efdbe8ce8add3c1b0026e135b76b102ac3 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:35:16 +0300 Subject: [PATCH 06/19] =?UTF-8?q?sprint=20024=20phase=201:=20close=20?= =?UTF-8?q?=E2=80=94=20integration=20second-wind-full-hp=20test,=20E2E=209?= =?UTF-8?q?/9,=20mark=20phase=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/STATUS.md | 6 +- .../e2e/phase1-report.md | 62 +++++++++++++++++++ .../sprints/024-playtest-quick-wins/sprint.md | 4 +- tests/integration/test_combat_turns.py | 28 +++++++++ 4 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 docs/sprints/024-playtest-quick-wins/e2e/phase1-report.md diff --git a/docs/STATUS.md b/docs/STATUS.md index 376c9449..db1610f6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,8 +3,8 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-16 -**Position:** Sprint 024 (playtest-quick-wins) в планировании: быстрые UX-фиксы из живой партии 2026-07-15, план готов. -**Next:** Генерация задач Phase 1. +**Position:** Sprint 024 (playtest-quick-wins): Phase 1 (Читаемость и тактика боя) COMPLETE — integration 164 green, E2E 9/9. +**Next:** Генерация задач Phase 2 (полировка торговли и экипировки). **Blockers:** Нет. ## Current Sprint @@ -12,7 +12,7 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 1 — Читаемость и тактика боя (task 3 done, все задачи фазы 1 готовы) — 2026-07-16 +**Phase:** 1 — Читаемость и тактика боя (COMPLETE) — 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). diff --git a/docs/sprints/024-playtest-quick-wins/e2e/phase1-report.md b/docs/sprints/024-playtest-quick-wins/e2e/phase1-report.md new file mode 100644 index 00000000..12f7b74d --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/e2e/phase1-report.md @@ -0,0 +1,62 @@ +# E2E Report: sprint024-phase1 + +**Date:** 2026-07-16 +**Flags:** --no-llm +**Sections tested:** combat (playbook §3) + phase-relevant §10.4/10.5/10.6, §11.1/11.4; auto-discovered from phase 1 changes +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs, world Sword Vale + +## Summary + +- Scenarios: 9 tested, 9 passed, 0 failed +- Quick fixes: 0 +- Blockers: 0 + +Phase 1 unified combat movement-budget accounting (task 1), gated other-creature errors + faction spam out of the player log (task 2), and reworded Second Wind at full HP (task 3). All three verified — task 1 and task 2 through the live UI, task 3 through integration + unit tests (its full-HP path needs an HP reset that the combat run didn't hit). + +## Results + +### Section 3: Combat + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 3.1 | Initiate combat | pass | Attack marta → `Combat started! Initiative order: Marta, Adventurer`, sidebar swaps to Combat panel, round number shown | +| 3.2 | Attack and damage | pass | `You attack Brute (longsword slash) [d20(5)+4=9 vs AC 10], miss` and later a clean crit line — full roll + AC + damage-type breakdown, no placeholder leaks | +| 3.3 | End turn and NPC response | pass | Brute took its turn: `Brute moved (10 ft)` then `Brute attacks you (fists) [d20(20)+0=20 vs AC 19], CRIT! 2 damage (1 bludgeoning + 1 weapon_crit)`; new turn arrived, budget reset to full | +| 3.4 | Combat ends | pass | Verified in the marta one-shot: `human dies` → `Combat ended.` → sidebar returns to peaceful/loot | + +### Phase-relevant dashboard/reaction scenarios + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 10.5 | Combat layout switch | pass | Right column swaps to interactive Battle Map on combat start, back to Location after | +| 10.6 | Action bar budget display | pass | `Actions / Bonus / Movement / Reaction` row with live numbers; resets to `Movement: 30ft` at each turn start | +| 10.4 | Click-to-move + movement budget | pass | **task 1**: 15ft move (`You move to the west (15 ft)`) dropped budget exactly `Movement: 30ft → 15ft`; reachable-cell highlight shrank to the new 15ft radius | +| 11.1 / 11.4 | OA on leaving reach | pass | Leaving the adjacent Brute's reach drew an OA: `Brute attacks you (fists) (opportunity attack) [d20(12)+0=12 vs AC 19], miss` + `Brute seizes the opening against you!` | + +### Auto-discovered scenarios (phase 1 changes) + +| Scenario | Reason | Status | Notes | +|----------|--------|--------|-------| +| Movement budget accounting in combat | task 1 unified `MOVE`→FREE, `handle_move` charges actual `moved_ft` | pass | 3-cell straight move = 15ft charged exactly; budget resets per turn; reachable set recomputes from remaining movement | +| Player log cleanliness across NPC turns | task 2 gated other-creature errors + faction spam | pass | Two full rounds (my turn, OA, Brute move + attack + crit) — no leaked technical errors, no faction-spam lines in the player log | +| `faction_hostility_check` log level | task 2 moved it INFO→DEBUG | pass | 5 occurrences in backend log, all `"level": "debug"` | +| Second Wind at full HP wording | task 3 `healed == 0` message | n/a (test-covered) | Not repro'd in the E2E combat (player was damaged, so heal was non-zero). Covered by new integration test `test_fighter_second_wind_at_full_hp_reports_full_health` + unit tests | + +## Quick Fixes + +None. + +## Findings + +### Blockers +None. + +### Minor +- NPC race renders in Russian (`человек`) while the UI chrome is English — the session was created without an explicit lang and the server default is RU. Pre-existing, out-of-scope `ui-language-mixing` (backlog), not a phase-1 regression. +- Perceived NPC identity shows race (`You attack human`) for an unfamiliar NPC (marta) but the given name for the spawned monster (`You attack Brute`). Existing `perceive()` behavior, unrelated to this phase. + +## Log Analysis + +- No errors, exceptions, or tracebacks in `/tmp/dnd-e2e-backend.log`. +- `faction_hostility_check` present only at DEBUG (task 2 gating confirmed). +- No `action_failed` events fired this run (the Brute was never blocked), so task 2's specific "blocked NPC action stays backend-only" path wasn't exercised live; it is unit-tested and the player log stayed clean throughout. diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md index a3cc1dc5..20a346c7 100644 --- a/docs/sprints/024-playtest-quick-wins/sprint.md +++ b/docs/sprints/024-playtest-quick-wins/sprint.md @@ -14,7 +14,7 @@ **Ссылки:** [BACKLOG](../../BACKLOG.md), [VISION](../../VISION.md), [Sprint 023](../023-trigger-table/sprint.md) -## Phase 1: Читаемость и тактика боя +## Phase 1: Читаемость и тактика боя ✓ Самый плотный кластер из живой партии с 11 волками — всё про то, как бой ощущается и читается. Учёт бюджета движения унифицируется (одно место списания по факту пройденного, корректная диагональ, честная отбивка, видимость остатка мозгу), чужие отказы и faction-спам уходят из лога, Second Wind не пугает нулевым лечением. Проверка: боевой прогон (integration/WS) — шаги списывают `movement_remaining` и ход завершается, в логе игрока нет чужих отказов, бэкенд-лог не тонет в `faction_hostility_check`. @@ -54,7 +54,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Planning complete. Ready to generate Phase 1 tasks. +**Current:** Phase 1 complete (all 3 tasks done, integration 164 green, E2E 9/9). Ready for Phase 2 task generation. ## Decisions diff --git a/tests/integration/test_combat_turns.py b/tests/integration/test_combat_turns.py index 495afce7..b8d9313f 100644 --- a/tests/integration/test_combat_turns.py +++ b/tests/integration/test_combat_turns.py @@ -228,6 +228,34 @@ def test_fighter_second_wind_heals_and_exhausts(self, api_url: str, player_api_u finally: requests.delete(f"{api_url}/sessions/{sid}", timeout=5) + def test_fighter_second_wind_at_full_hp_reports_full_health( + self, api_url: str, player_api_url: str, ws_base_url: str + ) -> None: + """Second Wind at full HP: healed == 0, message reports full health (not 'regaining 0 HP').""" + sid, pid = _create_session(api_url, player_api_url, "fighter") + try: + sock = ws_connect(ws_base_url, sid, pid) + try: + turn = _get_turn(sock) + turn = _ensure_combat(sock, turn, "target_dummy") + + # No damage applied — fighter is at full HP. + ws_send_action(sock, "second_wind") + events, _turn2 = _collect_events_until_turn(sock) + + sw_event = _find_event(events, "entity_second_wind") + assert sw_event is not None, ( + f"Expected entity_second_wind event, got: {[e.get('event_type') for e in events]}" + ) + assert sw_event["data"]["healed"] == 0, "Second Wind at full HP should heal 0" + desc = sw_event.get("description", "") + assert "full health" in desc, f"Expected full-health message, got: {desc!r}" + assert "regaining 0" not in desc, f"Should not say 'regaining 0 HP', got: {desc!r}" + finally: + sock.close() + finally: + requests.delete(f"{api_url}/sessions/{sid}", timeout=5) + # ── Test: Rogue Full Turn ──────────────────────────────────────────────── From ba94b00bea32df37dc9fb0e56e7f5f33f4b51fee Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:50:40 +0300 Subject: [PATCH 07/19] sprint 024 phase 2: task breakdown --- docs/STATUS.md | 6 +-- .../sprints/024-playtest-quick-wins/sprint.md | 5 +- .../tasks/phase2-task1-catalog-item-prices.md | 45 ++++++++++++++++++ .../tasks/phase2-task2-equip-unequip-i18n.md | 46 +++++++++++++++++++ 4 files changed, 97 insertions(+), 5 deletions(-) create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md diff --git a/docs/STATUS.md b/docs/STATUS.md index db1610f6..8593cc98 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,8 +3,8 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-16 -**Position:** Sprint 024 (playtest-quick-wins): Phase 1 (Читаемость и тактика боя) COMPLETE — integration 164 green, E2E 9/9. -**Next:** Генерация задач Phase 2 (полировка торговли и экипировки). +**Position:** Sprint 024 (playtest-quick-wins): Phase 1 COMPLETE; Phase 2 (полировка торговли и экипировки) — задачи сгенерированы (2 таска). +**Next:** Phase 2 task 1 — SRD-цены для каталога предметов. **Blockers:** Нет. ## Current Sprint @@ -12,7 +12,7 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 1 — Читаемость и тактика боя (COMPLETE) — 2026-07-16 +**Phase:** 2 — Полировка торговли и экипировки (tasks generated) — 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). diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md index 20a346c7..d929014b 100644 --- a/docs/sprints/024-playtest-quick-wins/sprint.md +++ b/docs/sprints/024-playtest-quick-wins/sprint.md @@ -38,7 +38,8 @@ **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [SRD-цены для каталога предметов](tasks/phase2-task1-catalog-item-prices.md) — проставить `price` в 31 каталожный YAML (плумбинг цены уже на месте, не хватает данных); снятый стартовый предмет продаётся торговцу из инвентаря +2. [i18n кнопок надеть/снять](tasks/phase2-task2-equip-unequip-i18n.md) — 10 slot-меток в `game.json` (EN+RU), хардкод `USE`/`EQUIP` в `InventoryPanel` через `t()`, 12 описаний equip/unequip в RU `.po`; `equip-action-collapse` вне скоупа ## Phase 3: Панель свойств предметов @@ -54,7 +55,7 @@ _(генерируются отдельно перед началом фазы)_ ## Status -**Current:** Phase 1 complete (all 3 tasks done, integration 164 green, E2E 9/9). Ready for Phase 2 task generation. +**Current:** Phase 1 complete (all 3 tasks done, integration 164 green, E2E 9/9). Phase 2 tasks generated (2 tasks). Ready to start Phase 2 task 1. ## Decisions diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md new file mode 100644 index 00000000..26a72a6f --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md @@ -0,0 +1,45 @@ +# Task: SRD-цены для каталога предметов + +**Date:** 2026-07-16 +**Sprint:** 024-playtest-quick-wins +**Phase:** 2 — Полировка торговли и экипировки + +## Description + +Стартовое снаряжение нельзя продать торговцу (`catalog-item-prices`, живая партия 2026-07-15). `validate_sell` (`rules/trade.py:46-47`) и `validate_buy` (`rules/trade.py:30-31`) отбивают сделку при `item.price is None` строкой «Item has no price and cannot be traded». Ни одна запись в `content/catalogs/items/` не задаёт `price`, а схема дефолтит его в `None` (`content_loader/schemas.py:129`), поэтому всё, что резолвится из каталога (стартовое, лутовое, купленное), приходит с `price=None` и не торгуется. + +Плумбинг цены полностью на месте: `resolve_item_ref` (`content_loader/items.py:164-186`) мёржит поля каталога, `_to_item` ставит `Item.price=model.price` (`items.py:155`), сериализация round-trip'ит цену. Не хватает только данных — значений `price` в YAML. + +Фикс: проставить SRD-цены (PHB) в 31 каталожный YAML. Для мандановых предметов — таблица PHB (в золотых). Для магических (`flaming_longsword`, `frost_dagger`, `ring_of_protection`, `boots_of_speed`, `circlet_of_aim`) цены в SRD нет — задать разумные значения по редкости (источник — DMG/домашние; uncommon ≈ 200–500, rare ≈ 2000–5000 gp). + +Границы: не трогаем механику скидки при продаже — сейчас buy и sell идут по одной `item.price` (наценки нет), это существующее поведение и вне скоупа. Экипированный предмет продать нельзя (лежит в `Creature.equipped`, а не в `inventory`; `_find_item` смотрит только `inventory`) — это тоже существующее поведение, отдельной ошибки не добавляем; сценарий продажи снятого предмета работает и его надо покрыть тестом. + +Ориентир по мандановым (PHB, gp): dagger 2, longsword 15, rapier 25, greataxe 30, greatsword 50, mace 5, quarterstaff 1, shortsword 10, warhammer 15, shortbow 25, longbow 50, hand_crossbow 75; padded 5, leather 10, hide 10, studded_leather 45, ring_mail 30, chain_shirt 50, scale_mail 50, chain_mail 75, splint 200, breastplate 400, half_plate 750, plate 1500, shield 10; health_potion 50. + +## Tests First + +Продуктовые сценарии (реальная цепочка каталог → резолв → правила торговли): + +- Игрок со снятым стартовым предметом, резолвнутым из каталога через `ref:` (напр. `chain_mail`), лежащим в инвентаре, на локации торговца → действие `sell` → золото игрока выросло на `price`, предмет ушёл из инвентаря, ошибки нет. Это прогоняет `resolve_item_ref` → `Item.price` → `validate_sell` → `execute_sell` → `transfer_items`. +- Регресс-гварда каталога: каждая запись `content/catalogs/items/*.yaml` после парсинга через `parse_items`/каталог-лоадер несёт `price is not None` (параметризованный тест по всем файлам), чтобы будущая запись без цены падала в тесте, а не в живой партии. +- `validate_sell`/`validate_buy` для предмета с `price=None` по-прежнему отбивают со старой ошибкой (гварду не ломаем — существующий `test_rejects_unpriceable_item` расширить на sell). + +## Implementation + +- `content/catalogs/items/*.yaml` — добавить `price: ` в каждую из 31 записи по таблице выше; магические — по редкости. +- Проверить, что новых полей схема не отвергает: `ItemContent` — `extra="forbid"`, но `price` уже объявлен, так что YAML принимается без правок схемы. +- Тест продажи собрать на реальном резолве каталога (не `Item(price=...)` вручную), чтобы поймать разрыв данные↔правила. +- Правок Python-кода торговли не требуется; если тест продажи проще собрать через хендлер `handle_sell`/WS — переиспользовать существующие фикстуры из `test_trade_handlers.py`/`test_trade_ws.py`. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] Все 31 записи каталога несут `price` +- [ ] Снятый стартовый предмет продаётся торговцу из инвентаря (золото растёт, предмет уходит) +- [ ] Гварда `price is None` не сломана (priceless по-прежнему отбивается) + +## Status + +`pending` diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md new file mode 100644 index 00000000..3b9fce9c --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md @@ -0,0 +1,46 @@ +# Task: i18n кнопок надеть/снять + +**Date:** 2026-07-16 +**Sprint:** 024-playtest-quick-wins +**Phase:** 2 — Полировка торговли и экипировки + +## Description + +Кнопки экипировки в боевом action bar и в инвентаре показывают сырые ID и английские тексты под RU (`action-bar-equip-i18n`, `action-bar-unequip-i18n`, живая партия 2026-07-15 + E2E sprint 020). Оба айтема — одна семья по 12 экипировочным `ActionType`; бэклог требует чинить их одним PR. Делается сейчас вместе, коллапс 12 ActionType в один (`equip-action-collapse`) отложен и этот таск его не трогает. + +Два независимых слоя текста: + +**Метки (frontend).** `getActionLabel` (`frontend/src/components/game/action-bar/utils.ts:9-13`) ищет ключ `game:` в i18next, иначе возвращает сырой snake_case ID. В `en/game.json`/`ru/game.json` есть только `equip`/`unequip` (строки 89-90); нет ключей `equip_armor`, `equip_shield`, `equip_head`, `equip_feet`, `equip_ring` и их `unequip_*` — они рендерятся как «equip_armor». Плюс в `InventoryPanel.tsx` кнопки в сумке хардкодят английские литералы `"USE"` (строка 102) и `"EQUIP"` (строка 111) мимо i18n. + +**Описания (backend).** Описания `ActionDef` приходят по проводу уже переведёнными: `transport_payloads.py:52` делает `_(definition.description)`, фронт рендерит строку как есть (`InventoryDrawer.tsx:52`, `ActionButton` `title`). Msgid'ы equip/unequip описаний помечены `N_()` в `_EQUIP_ACTION_SPECS` (`core/action_defs.py:336-413`) и извлечены в `.pot`, но в RU-каталоге `locale/ru/LC_MESSAGES/dnd_simulator.po` их переводов нет — под RU описания текут по-английски («Put away your equipped weapon. You will fight with fists.», «Remove your equipped armor.» и т.д.). Решение по бэклогу: переводить в `.po` (не переключать описания на фронтовые ключи). + +Фикс: добавить 10 недостающих slot-меток в `en`/`ru` `game.json`, увести хардкод `USE`/`EQUIP` из `InventoryPanel.tsx` в `t()`, и добавить RU-переводы 12 описаний equip/unequip (6 equip + 6 unequip) в `.po` с перекомпиляцией `.mo`. + +## Tests First + +Продуктовые сценарии (русский игрок видит русские кнопки и подсказки, не сырые ID и не английский): + +- **Backend:** транспортный payload доступных действий, собранный под `DND_LANGUAGE=ru`, отдаёт `description` для `equip_armor`, `unequip_armor`, `unequip_shield` и `unequip` (оружие) на русском, не английскую строку. Прогоняет `_(definition.description)` + `.po`. Собрать через существующий билдер payload'а действий (`transport_payloads`) на реальном `action_defs`. +- **Frontend:** под i18n `lng=ru` метки всех 10 slot-действий (`equip_armor`/`unequip_armor`/`equip_shield`/`unequip_shield`/`equip_head`/`unequip_head`/`equip_feet`/`unequip_feet`/`equip_ring`/`unequip_ring`) резолвятся в непустой перевод, а не в сырой ID (`getActionLabel` не возвращает входное имя). Рендер `InventoryDrawer` с этими действиями не показывает snake_case строк. +- **Frontend:** в `InventoryPanel` кнопки экипировки/использования из сумки рендерят переведённый текст (ключ `game:*`), а не литералы `EQUIP`/`USE`. + +## Implementation + +- `frontend/src/i18n/locales/en/game.json` и `ru/game.json` — добавить ключи `equip_armor`, `equip_shield`, `equip_head`, `equip_feet`, `equip_ring`, `unequip_armor`, `unequip_shield`, `unequip_head`, `unequip_feet`, `unequip_ring` (EN — человекочитаемые «Equip Armor»/«Remove Armor», RU — «Надеть броню»/«Снять броню» и т.д. в тоне соседних `equip`/`unequip`/`slot_*`). +- `frontend/src/components/game/InventoryPanel.tsx` — заменить литералы `"USE"` (строка 102) и `"EQUIP"` (строка 111) на `t("game:use_item")`/`t("game:equip")` (или новые короткие ключи), добавить недостающие ключи в оба `game.json`. +- Backend `.po`: `make messages` для актуализации `.pot`, влить новые msgid'ы в `locale/ru/LC_MESSAGES/dnd_simulator.po` (msgmerge при необходимости), перевести 12 описаний из `_EQUIP_ACTION_SPECS` (weapon equip/unequip, armor, shield, head, feet, ring), `make compile-messages` для `.mo`. EN base не трогаем. +- Строки короткие, без em-dash; тон — как у существующих боевых меток и `equip`/`unequip`. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] 10 slot-меток есть в `en` и `ru` `game.json`; slot-действия не показывают сырой ID +- [ ] `USE`/`EQUIP` в `InventoryPanel` идут через `t()`, не хардкод +- [ ] 12 описаний equip/unequip переведены в RU `.po`, `.mo` перекомпилированы; под RU описания русские +- [ ] Веб-действия (wire-контракт 12 `ActionType`) не изменены — коллапс вне скоупа + +## Status + +`pending` From 497ba2c85afb4c6269d4b4b891cd74e917d267ae Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 02:57:41 +0300 Subject: [PATCH 08/19] sprint 024 phase 2 task 1: SRD prices for item catalog, sell starting gear from inventory --- content/catalogs/items/boots_of_speed.yaml | 1 + content/catalogs/items/breastplate.yaml | 1 + content/catalogs/items/chain_mail.yaml | 1 + content/catalogs/items/chain_shirt.yaml | 1 + content/catalogs/items/circlet_of_aim.yaml | 1 + content/catalogs/items/dagger.yaml | 1 + content/catalogs/items/flaming_longsword.yaml | 1 + content/catalogs/items/frost_dagger.yaml | 1 + content/catalogs/items/greataxe.yaml | 1 + content/catalogs/items/greatsword.yaml | 1 + content/catalogs/items/half_plate.yaml | 1 + content/catalogs/items/hand_crossbow.yaml | 1 + content/catalogs/items/health_potion.yaml | 1 + content/catalogs/items/hide.yaml | 1 + content/catalogs/items/leather.yaml | 1 + content/catalogs/items/longbow.yaml | 1 + content/catalogs/items/longsword.yaml | 1 + content/catalogs/items/mace.yaml | 1 + content/catalogs/items/padded.yaml | 1 + content/catalogs/items/plate.yaml | 1 + content/catalogs/items/quarterstaff.yaml | 1 + content/catalogs/items/rapier.yaml | 1 + content/catalogs/items/ring_mail.yaml | 1 + .../catalogs/items/ring_of_protection.yaml | 1 + content/catalogs/items/scale_mail.yaml | 1 + content/catalogs/items/shield.yaml | 1 + content/catalogs/items/shortbow.yaml | 1 + content/catalogs/items/shortsword.yaml | 1 + content/catalogs/items/splint.yaml | 1 + content/catalogs/items/studded_leather.yaml | 1 + content/catalogs/items/warhammer.yaml | 1 + docs/STATUS.md | 4 +- .../tasks/phase2-task1-catalog-item-prices.md | 12 +++++- tests/unit/test_srd_catalogs.py | 25 +++++++++++ tests/unit/test_trade.py | 41 +++++++++++++++++++ 35 files changed, 111 insertions(+), 2 deletions(-) diff --git a/content/catalogs/items/boots_of_speed.yaml b/content/catalogs/items/boots_of_speed.yaml index f06962d2..ca6abd61 100644 --- a/content/catalogs/items/boots_of_speed.yaml +++ b/content/catalogs/items/boots_of_speed.yaml @@ -8,3 +8,4 @@ modifiers: op: add value: 10 source: boots_of_speed +price: 4000 diff --git a/content/catalogs/items/breastplate.yaml b/content/catalogs/items/breastplate.yaml index 8b6d699f..57a48299 100644 --- a/content/catalogs/items/breastplate.yaml +++ b/content/catalogs/items/breastplate.yaml @@ -4,3 +4,4 @@ armor_id: breastplate category: medium base_ac: 14 max_dex_bonus: 2 +price: 400 diff --git a/content/catalogs/items/chain_mail.yaml b/content/catalogs/items/chain_mail.yaml index 8509af5b..9a86efd3 100644 --- a/content/catalogs/items/chain_mail.yaml +++ b/content/catalogs/items/chain_mail.yaml @@ -4,3 +4,4 @@ armor_id: chain_mail category: heavy base_ac: 16 max_dex_bonus: 0 +price: 75 diff --git a/content/catalogs/items/chain_shirt.yaml b/content/catalogs/items/chain_shirt.yaml index 09b4deb9..73e344e5 100644 --- a/content/catalogs/items/chain_shirt.yaml +++ b/content/catalogs/items/chain_shirt.yaml @@ -4,3 +4,4 @@ armor_id: chain_shirt category: medium base_ac: 13 max_dex_bonus: 2 +price: 50 diff --git a/content/catalogs/items/circlet_of_aim.yaml b/content/catalogs/items/circlet_of_aim.yaml index 93d7f3ac..a9ba5919 100644 --- a/content/catalogs/items/circlet_of_aim.yaml +++ b/content/catalogs/items/circlet_of_aim.yaml @@ -8,3 +8,4 @@ modifiers: op: add value: 1 source: circlet_of_aim +price: 2500 diff --git a/content/catalogs/items/dagger.yaml b/content/catalogs/items/dagger.yaml index 2f241ddb..0bedd71a 100644 --- a/content/catalogs/items/dagger.yaml +++ b/content/catalogs/items/dagger.yaml @@ -9,3 +9,4 @@ damage: ability: dex is_finesse: true is_light: true +price: 2 diff --git a/content/catalogs/items/flaming_longsword.yaml b/content/catalogs/items/flaming_longsword.yaml index 3769bed7..8866cb3a 100644 --- a/content/catalogs/items/flaming_longsword.yaml +++ b/content/catalogs/items/flaming_longsword.yaml @@ -10,3 +10,4 @@ damage: type: fire modifier: 1 is_magic: true +price: 2500 diff --git a/content/catalogs/items/frost_dagger.yaml b/content/catalogs/items/frost_dagger.yaml index 640d318b..4f39e38e 100644 --- a/content/catalogs/items/frost_dagger.yaml +++ b/content/catalogs/items/frost_dagger.yaml @@ -13,3 +13,4 @@ modifier: 1 is_magic: true is_finesse: true is_light: true +price: 2500 diff --git a/content/catalogs/items/greataxe.yaml b/content/catalogs/items/greataxe.yaml index 712a1e66..f2f76b82 100644 --- a/content/catalogs/items/greataxe.yaml +++ b/content/catalogs/items/greataxe.yaml @@ -8,3 +8,4 @@ damage: type: slashing is_two_handed: true is_heavy: true +price: 30 diff --git a/content/catalogs/items/greatsword.yaml b/content/catalogs/items/greatsword.yaml index 22c73766..578b9399 100644 --- a/content/catalogs/items/greatsword.yaml +++ b/content/catalogs/items/greatsword.yaml @@ -8,3 +8,4 @@ damage: type: slashing is_two_handed: true is_heavy: true +price: 50 diff --git a/content/catalogs/items/half_plate.yaml b/content/catalogs/items/half_plate.yaml index a5b60d67..72aa55f6 100644 --- a/content/catalogs/items/half_plate.yaml +++ b/content/catalogs/items/half_plate.yaml @@ -4,3 +4,4 @@ armor_id: half_plate category: medium base_ac: 15 max_dex_bonus: 2 +price: 750 diff --git a/content/catalogs/items/hand_crossbow.yaml b/content/catalogs/items/hand_crossbow.yaml index fea09039..26f7cdba 100644 --- a/content/catalogs/items/hand_crossbow.yaml +++ b/content/catalogs/items/hand_crossbow.yaml @@ -8,3 +8,4 @@ damage: type: piercing ability: dex is_light: true +price: 75 diff --git a/content/catalogs/items/health_potion.yaml b/content/catalogs/items/health_potion.yaml index 2f700321..1e60c81d 100644 --- a/content/catalogs/items/health_potion.yaml +++ b/content/catalogs/items/health_potion.yaml @@ -1,3 +1,4 @@ name: Health Potion type: potion heal_dice: "2d4+2" +price: 50 diff --git a/content/catalogs/items/hide.yaml b/content/catalogs/items/hide.yaml index 1ca3cae7..d5e66324 100644 --- a/content/catalogs/items/hide.yaml +++ b/content/catalogs/items/hide.yaml @@ -4,3 +4,4 @@ armor_id: hide category: medium base_ac: 12 max_dex_bonus: 2 +price: 10 diff --git a/content/catalogs/items/leather.yaml b/content/catalogs/items/leather.yaml index d2270a80..a07a90c5 100644 --- a/content/catalogs/items/leather.yaml +++ b/content/catalogs/items/leather.yaml @@ -3,3 +3,4 @@ type: armor armor_id: leather category: light base_ac: 11 +price: 10 diff --git a/content/catalogs/items/longbow.yaml b/content/catalogs/items/longbow.yaml index e27f0ca2..390dd0a9 100644 --- a/content/catalogs/items/longbow.yaml +++ b/content/catalogs/items/longbow.yaml @@ -9,3 +9,4 @@ damage: ability: dex is_two_handed: true is_heavy: true +price: 50 diff --git a/content/catalogs/items/longsword.yaml b/content/catalogs/items/longsword.yaml index 022a74f6..36b61846 100644 --- a/content/catalogs/items/longsword.yaml +++ b/content/catalogs/items/longsword.yaml @@ -6,3 +6,4 @@ attack_name: longsword slash damage: - dice: "1d8" type: slashing +price: 15 diff --git a/content/catalogs/items/mace.yaml b/content/catalogs/items/mace.yaml index 9afa119e..7051fcc0 100644 --- a/content/catalogs/items/mace.yaml +++ b/content/catalogs/items/mace.yaml @@ -6,3 +6,4 @@ attack_name: mace strike damage: - dice: "1d6" type: bludgeoning +price: 5 diff --git a/content/catalogs/items/padded.yaml b/content/catalogs/items/padded.yaml index 0362f47b..2ea09046 100644 --- a/content/catalogs/items/padded.yaml +++ b/content/catalogs/items/padded.yaml @@ -3,3 +3,4 @@ type: armor armor_id: padded category: light base_ac: 11 +price: 5 diff --git a/content/catalogs/items/plate.yaml b/content/catalogs/items/plate.yaml index 8c632fdf..a916c6a1 100644 --- a/content/catalogs/items/plate.yaml +++ b/content/catalogs/items/plate.yaml @@ -4,3 +4,4 @@ armor_id: plate category: heavy base_ac: 18 max_dex_bonus: 0 +price: 1500 diff --git a/content/catalogs/items/quarterstaff.yaml b/content/catalogs/items/quarterstaff.yaml index 2307a0ed..9212b050 100644 --- a/content/catalogs/items/quarterstaff.yaml +++ b/content/catalogs/items/quarterstaff.yaml @@ -6,3 +6,4 @@ attack_name: quarterstaff strike damage: - dice: "1d6" type: bludgeoning +price: 1 diff --git a/content/catalogs/items/rapier.yaml b/content/catalogs/items/rapier.yaml index 288969c7..8ec7d466 100644 --- a/content/catalogs/items/rapier.yaml +++ b/content/catalogs/items/rapier.yaml @@ -7,3 +7,4 @@ damage: - dice: "1d8" type: piercing is_finesse: true +price: 25 diff --git a/content/catalogs/items/ring_mail.yaml b/content/catalogs/items/ring_mail.yaml index 3081192b..303c368b 100644 --- a/content/catalogs/items/ring_mail.yaml +++ b/content/catalogs/items/ring_mail.yaml @@ -4,3 +4,4 @@ armor_id: ring_mail category: heavy base_ac: 14 max_dex_bonus: 0 +price: 30 diff --git a/content/catalogs/items/ring_of_protection.yaml b/content/catalogs/items/ring_of_protection.yaml index e30deb05..ed7ba782 100644 --- a/content/catalogs/items/ring_of_protection.yaml +++ b/content/catalogs/items/ring_of_protection.yaml @@ -8,3 +8,4 @@ modifiers: op: add value: 1 source: ring_of_protection +price: 3500 diff --git a/content/catalogs/items/scale_mail.yaml b/content/catalogs/items/scale_mail.yaml index 4df1ed87..f3d3f606 100644 --- a/content/catalogs/items/scale_mail.yaml +++ b/content/catalogs/items/scale_mail.yaml @@ -4,3 +4,4 @@ armor_id: scale_mail category: medium base_ac: 14 max_dex_bonus: 2 +price: 50 diff --git a/content/catalogs/items/shield.yaml b/content/catalogs/items/shield.yaml index ca71e0b0..bffe067a 100644 --- a/content/catalogs/items/shield.yaml +++ b/content/catalogs/items/shield.yaml @@ -2,3 +2,4 @@ name: Shield type: shield shield_id: shield ac_bonus: 2 +price: 10 diff --git a/content/catalogs/items/shortbow.yaml b/content/catalogs/items/shortbow.yaml index d261e4e5..0e7dbe96 100644 --- a/content/catalogs/items/shortbow.yaml +++ b/content/catalogs/items/shortbow.yaml @@ -8,3 +8,4 @@ damage: type: piercing ability: dex is_two_handed: true +price: 25 diff --git a/content/catalogs/items/shortsword.yaml b/content/catalogs/items/shortsword.yaml index 98002d83..9a369e55 100644 --- a/content/catalogs/items/shortsword.yaml +++ b/content/catalogs/items/shortsword.yaml @@ -8,3 +8,4 @@ damage: type: piercing is_finesse: true is_light: true +price: 10 diff --git a/content/catalogs/items/splint.yaml b/content/catalogs/items/splint.yaml index 3b5529c5..528bcd7c 100644 --- a/content/catalogs/items/splint.yaml +++ b/content/catalogs/items/splint.yaml @@ -4,3 +4,4 @@ armor_id: splint category: heavy base_ac: 17 max_dex_bonus: 0 +price: 200 diff --git a/content/catalogs/items/studded_leather.yaml b/content/catalogs/items/studded_leather.yaml index 982d5a8c..2d539927 100644 --- a/content/catalogs/items/studded_leather.yaml +++ b/content/catalogs/items/studded_leather.yaml @@ -3,3 +3,4 @@ type: armor armor_id: studded_leather category: light base_ac: 12 +price: 45 diff --git a/content/catalogs/items/warhammer.yaml b/content/catalogs/items/warhammer.yaml index 9c2f51eb..7d92b927 100644 --- a/content/catalogs/items/warhammer.yaml +++ b/content/catalogs/items/warhammer.yaml @@ -6,3 +6,4 @@ attack_name: warhammer smash damage: - dice: "1d8" type: bludgeoning +price: 15 diff --git a/docs/STATUS.md b/docs/STATUS.md index 8593cc98..0150f4c6 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,7 +12,9 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 2 — Полировка торговли и экипировки (tasks generated) — 2026-07-16 +**Phase:** 2 — Полировка торговли и экипировки (task 1 done, task 2 pending) — 2026-07-16 + +Task 1 (SRD-цены каталога) done: `price` проставлен в 31 каталожный YAML (PHB для манданого, редкость DMG для магии); плумбинг цены уже нёс её от каталога до `Item.price`, правок кода нет. Регресс-гварда `TestCatalogPrices` + продажа снятого `chain_mail` через реальный резолв каталога. `make check` зелёный (backend 2563, frontend 289). 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). diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md index 26a72a6f..10cf7bec 100644 --- a/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md @@ -42,4 +42,14 @@ ## Status -`pending` +`done` + +## Developer Notes + +Плумбинг цены уже был на месте — правок Python не потребовалось. Добавлен `price:` в 31 каталожный YAML: манданое — по таблице PHB (dagger 2 … plate 1500, shield 10, health_potion 50; quarterstaff округлён 2sp→1gp), магическое (`flaming_longsword`/`frost_dagger`/`circlet_of_aim` 2500, `ring_of_protection` 3500, `boots_of_speed` 4000) — по редкости DMG, домашние значения. + +Тесты: `TestCatalogPrices` в `test_srd_catalogs.py` (every entry has price / prices positive / price survives ref-resolve в runtime Item) как регресс-гварда против забытого `price:`; `TestSellStartingEquipmentFromCatalog` в `test_trade.py` продаёт `chain_mail`, резолвнутый через `parse_items([{"ref": "chain_mail"}], catalog)` (не hand-built `Item`), проверяя цепочку каталог→resolve→validate_sell→execute_sell→transfer_items и рост золота на `price`; `test_sell_rejects_unpriceable_item` расширил гварду `price is None` на sell. + +Проверка `execute_sell` во время разведки: `gold=-price` корректен (src/dst меняются местами между buy и sell, `transfer_items` делает `src.gold -= gold; dst.gold += gold`), продавец получает золото — флаг ревью-агента был ложным. + +`make check` зелёный (backend 2563 = 2558 + 5 новых, frontend 289). Наценки при продаже нет (buy и sell по одной `item.price`) — существующее поведение, вне скоупа. Экипированный предмет по-прежнему не продаётся (лежит в `equipped`, не в `inventory`) — тоже вне скоупа, покрыт сценарий продажи снятого. diff --git a/tests/unit/test_srd_catalogs.py b/tests/unit/test_srd_catalogs.py index 9c5d6a7d..e5aa1c67 100644 --- a/tests/unit/test_srd_catalogs.py +++ b/tests/unit/test_srd_catalogs.py @@ -206,3 +206,28 @@ def test_shield_def(self, catalog: dict[str, ItemContent]) -> None: sd = items[0].shield_def assert sd is not None assert sd.ac_bonus == 2 + + +# --------------------------------------------------------------------------- +# Prices — every catalog item must carry a sell/buy price +# --------------------------------------------------------------------------- + + +class TestCatalogPrices: + def test_every_catalog_entry_has_price(self, catalog: dict[str, ItemContent]) -> None: + """A catalog item with no price resolves to price=None and can't be traded. + + Guards against a new catalog entry forgetting `price:` — it should fail here, + not in a live game when the player tries to sell it. + """ + missing = [stem for stem, content in catalog.items() if content.price is None] + assert not missing, f"catalog entries missing a price: {missing}" + + def test_prices_are_positive(self, catalog: dict[str, ItemContent]) -> None: + for stem, content in catalog.items(): + assert content.price is not None and content.price > 0, f"non-positive price on '{stem}'" + + def test_resolved_item_carries_price(self, catalog: dict[str, ItemContent]) -> None: + """Price survives ref-resolution into a runtime Item (the sell path reads Item.price).""" + items = parse_items([{"ref": "chain_mail"}], item_catalog=catalog) + assert items[0].price is not None and items[0].price > 0 diff --git a/tests/unit/test_trade.py b/tests/unit/test_trade.py index 8fc7d7e4..cc4ed0fa 100644 --- a/tests/unit/test_trade.py +++ b/tests/unit/test_trade.py @@ -2,6 +2,11 @@ from __future__ import annotations +from pathlib import Path + +from dnd_simulator.content_loader.catalogs import load_catalog +from dnd_simulator.content_loader.items import parse_items +from dnd_simulator.content_loader.schemas import ItemContent from dnd_simulator.core.character import NpcRole from dnd_simulator.core.items import Item, ItemType from dnd_simulator.core.player import PlayerCharacter @@ -10,6 +15,8 @@ MARKET = "silverport_city_market" +CATALOG_DIR = Path(__file__).resolve().parents[2] / "content" / "catalogs" / "items" + def _merchant(*, gold: int = 100, items: list[Item] | None = None) -> Npc: npc = Npc(id="merchant_1", name="Gretta", role=NpcRole.MERCHANT, location_id=MARKET, gold=gold) @@ -87,6 +94,40 @@ def test_rejects_unpriceable_item(self) -> None: assert error is not None assert "price" in error.lower() + def test_sell_rejects_unpriceable_item(self) -> None: + gem = _unpriceable() + merchant = _merchant(gold=100) + player = _player(items=[gem]) + + error = validate_sell(seller=player, buyer=merchant, item_id="quest_gem_0") + assert error is not None + assert "price" in error.lower() + + +class TestSellStartingEquipmentFromCatalog: + """A starting item resolved from the catalog can be sold once it's in inventory. + + This is the live-playtest bug (`catalog-item-prices`): before prices were added to + the catalog YAML, chain_mail resolved with price=None and the merchant rejected the sale. + """ + + def test_sell_catalog_chain_mail(self) -> None: + catalog = load_catalog(CATALOG_DIR, ItemContent) + chain_mail = parse_items([{"ref": "chain_mail"}], item_catalog=catalog)[0] + assert chain_mail.price is not None # resolved from catalog, not hand-built + + merchant = _merchant(gold=1000) + player = _player(gold=0, items=[chain_mail]) + + error = validate_sell(seller=player, buyer=merchant, item_id=chain_mail.id) + assert error is None + + execute_sell(seller=player, buyer=merchant, item=chain_mail) + + assert chain_mail in merchant.inventory + assert chain_mail not in player.inventory + assert player.gold == chain_mail.price + # --- Sell validation & execution --- From 2d1613ae736d4d702cdcbeb6d631b0bfc0a81177 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:00:02 +0300 Subject: [PATCH 09/19] sprint 024 phase 2 task 2: i18n for equip/unequip buttons and descriptions Add 10 slot labels (equip_armor..unequip_ring) plus a short 'use' key to en/ru game.json; route InventoryPanel USE/EQUIP literals through t(). Add RU translations for the 12 equip/unequip ActionDef descriptions to the .po and recompile the .mo, so a Russian player sees Russian labels and tooltips instead of raw ids and English base strings. Web contract of the 12 ActionType is untouched. --- docs/STATUS.md | 8 +- .../tasks/phase2-task2-equip-unequip-i18n.md | 14 ++- .../src/components/game/InventoryPanel.tsx | 5 +- .../game/__tests__/equipI18n.test.tsx | 81 ++++++++++++++++++ frontend/src/i18n/locales/en/game.json | 11 +++ frontend/src/i18n/locales/ru/game.json | 11 +++ .../locale/ru/LC_MESSAGES/dnd_simulator.mo | Bin 29621 -> 30775 bytes .../locale/ru/LC_MESSAGES/dnd_simulator.po | 36 ++++++++ tests/unit/test_equip_desc_i18n.py | 69 +++++++++++++++ 9 files changed, 229 insertions(+), 6 deletions(-) create mode 100644 frontend/src/components/game/__tests__/equipI18n.test.tsx create mode 100644 tests/unit/test_equip_desc_i18n.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 0150f4c6..be06db78 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,8 +3,8 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-16 -**Position:** Sprint 024 (playtest-quick-wins): Phase 1 COMPLETE; Phase 2 (полировка торговли и экипировки) — задачи сгенерированы (2 таска). -**Next:** Phase 2 task 1 — SRD-цены для каталога предметов. +**Position:** Sprint 024 (playtest-quick-wins): Phase 1 COMPLETE; Phase 2 (полировка торговли и экипировки) — обе задачи done, фаза готова к закрытию. +**Next:** Закрыть Phase 2 (`/close-phase`). **Blockers:** Нет. ## Current Sprint @@ -12,7 +12,9 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 2 — Полировка торговли и экипировки (task 1 done, task 2 pending) — 2026-07-16 +**Phase:** 2 — Полировка торговли и экипировки (task 1 done, task 2 done) — 2026-07-16 + +Task 2 (i18n надеть/снять) done: 10 slot-меток (`equip_armor`…`unequip_ring`) + короткий `use` в `en`/`ru` `game.json`; `USE`/`EQUIP` в `InventoryPanel` уведены в `t()` (EQUIP→`equip`, USE→новый `use`); 12 RU-описаний equip/unequip добавлены в `.po` руками (`make messages` не годится — гонит `pygettext --keyword=_` без `N_` и падает на f-строках), `.mo` перекомпилирован. Аддитивно, коллапс 12 ActionType не тронут. `make check` зелёный (backend 2565, frontend 291). Task 1 (SRD-цены каталога) done: `price` проставлен в 31 каталожный YAML (PHB для манданого, редкость DMG для магии); плумбинг цены уже нёс её от каталога до `Item.price`, правок кода нет. Регресс-гварда `TestCatalogPrices` + продажа снятого `chain_mail` через реальный резолв каталога. `make check` зелёный (backend 2563, frontend 289). diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md index 3b9fce9c..560cae13 100644 --- a/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md @@ -43,4 +43,16 @@ ## Status -`pending` +`done` + +## Developer Notes + +Три слоя правок, все аддитивные — коллапс 12 ActionType не тронут: + +- **Frontend метки.** 10 slot-ключей (`equip_armor`…`unequip_ring`) добавлены в `en`/`ru` `game.json` рядом с существующими `equip`/`unequip`. `getActionLabel` уже резолвит `game:` — данных не хватало, кода не трогал. +- **InventoryPanel.** Литералы `USE`/`EQUIP` уведены в `t()`. Для EQUIP переиспользован короткий `equip` («Надеть»). Для USE добавлен новый короткий ключ `use` («Использовать») — `use_item` («Use Item»/«Использовать предмет») длинный для 10px-пилюли. `BagItem` получил `useTranslation`. +- **Backend `.po`.** `make messages` не годится: цель гонит `pygettext3 --keyword=_` (без `N_`) в `messages.pot` и падает на f-строках. `.po` компилится напрямую pybabel, поэтому 12 msgid/msgstr добавлены руками в RU-каталог, `make compile-messages` перекомпилил `.mo`. EN base не трогал. + +Тесты: backend `test_equip_desc_i18n.py` строит payload через `_awareness_to_dict` под `language_context("ru")`, проверяет кириллицу в описаниях 11 equip/unequip действий; frontend `equipI18n.test.tsx` — `getActionLabel` не возвращает сырой ID под ru + InventoryPanel рендерит `t()`-метки, не `USE`/`EQUIP`. + +`make check`: backend 2565 passed, frontend 291 passed. (Первый прогон споткнулся о флейки-таймаут неродственного `CreatureForm` теста под нагрузкой — изолированно и на чистом фронт-прогоне зелёный.) diff --git a/frontend/src/components/game/InventoryPanel.tsx b/frontend/src/components/game/InventoryPanel.tsx index c598c6a2..00f86ec5 100644 --- a/frontend/src/components/game/InventoryPanel.tsx +++ b/frontend/src/components/game/InventoryPanel.tsx @@ -73,6 +73,7 @@ function EquipmentSlot({ slot, item }: { slot: string; item?: EquippedInfo }) { } function BagItem({ item }: { item: ItemInfo }) { + const { t } = useTranslation(["game"]) const waitingForAction = useGameStore((s) => s.waitingForAction) const equipSlot = getEquipSlot(item) @@ -99,7 +100,7 @@ function BagItem({ item }: { item: ItemInfo }) { disabled={waitingForAction} onClick={() => sendAction("use_item", { item_id: item.id })} > - USE + {t("game:use")} )} {equipSlot && ( @@ -108,7 +109,7 @@ function BagItem({ item }: { item: ItemInfo }) { disabled={waitingForAction} onClick={handleEquip} > - EQUIP + {t("game:equip")} )} diff --git a/frontend/src/components/game/__tests__/equipI18n.test.tsx b/frontend/src/components/game/__tests__/equipI18n.test.tsx new file mode 100644 index 00000000..19090e31 --- /dev/null +++ b/frontend/src/components/game/__tests__/equipI18n.test.tsx @@ -0,0 +1,81 @@ +import { render, screen } from "@testing-library/react" +import { describe, it, expect, beforeAll, afterAll } from "vitest" +import i18n from "@/i18n" +import { getActionLabel } from "../action-bar/utils" + +// Mock wsClient BEFORE store imports it +import { vi } from "vitest" +vi.mock("@/transport/wsClient", () => ({ + wsClient: { + send: vi.fn(), + onStatus: vi.fn(() => vi.fn()), + onMessage: vi.fn(() => vi.fn()), + getStatus: vi.fn(() => "disconnected"), + connect: vi.fn(), + disconnect: vi.fn(), + }, +})) + +import { useGameStore } from "@/store/gameStore" +import { InventoryPanel } from "../InventoryPanel" + +const SLOT_ACTIONS = [ + "equip_armor", + "unequip_armor", + "equip_shield", + "unequip_shield", + "equip_head", + "unequip_head", + "equip_feet", + "unequip_feet", + "equip_ring", + "unequip_ring", +] + +describe("equip/unequip slot labels — i18n", () => { + beforeAll(async () => { + await i18n.changeLanguage("ru") + }) + afterAll(async () => { + await i18n.changeLanguage("en") + }) + + it("resolves every slot action to a real translation, not the raw id", () => { + const t = i18n.getFixedT("ru", "game") + for (const name of SLOT_ACTIONS) { + const label = getActionLabel((k) => t(k.replace("game:", "")), name) + expect(label).not.toBe(name) + expect(label.length).toBeGreaterThan(0) + } + }) +}) + +describe("InventoryPanel — bag buttons use i18n, not hardcoded literals", () => { + beforeAll(async () => { + await i18n.changeLanguage("ru") + }) + afterAll(async () => { + await i18n.changeLanguage("en") + }) + + it("renders translated Use/Equip labels, not EQUIP/USE literals", () => { + useGameStore.setState({ + player: { + id: "p1", + name: "Hero", + equipped: [], + inventory: [ + { id: "pot1", name: "Зелье", type: "potion", description: "heals" }, + { id: "sw1", name: "Меч", type: "weapon", description: "a sword" }, + ], + }, + } as never) + + render() + + expect(screen.queryByText("USE")).toBeNull() + expect(screen.queryByText("EQUIP")).toBeNull() + expect(screen.getByText(i18n.getFixedT("ru", "game")("use"))).toBeTruthy() + expect(screen.getByText(i18n.getFixedT("ru", "game")("equip"))).toBeTruthy() + }) +}) diff --git a/frontend/src/i18n/locales/en/game.json b/frontend/src/i18n/locales/en/game.json index 1c61efbb..1585621c 100644 --- a/frontend/src/i18n/locales/en/game.json +++ b/frontend/src/i18n/locales/en/game.json @@ -88,6 +88,17 @@ "click_to_unequip": "Click to unequip", "equip": "Equip", "unequip": "Unequip", + "use": "Use", + "equip_armor": "Equip Armor", + "unequip_armor": "Remove Armor", + "equip_shield": "Equip Shield", + "unequip_shield": "Remove Shield", + "equip_head": "Equip Headgear", + "unequip_head": "Remove Headgear", + "equip_feet": "Equip Footwear", + "unequip_feet": "Remove Footwear", + "equip_ring": "Equip Ring", + "unequip_ring": "Remove Ring", "trade": "Trade", "buy": "Buy", "sell": "Sell", diff --git a/frontend/src/i18n/locales/ru/game.json b/frontend/src/i18n/locales/ru/game.json index 16895cf6..91e19ca9 100644 --- a/frontend/src/i18n/locales/ru/game.json +++ b/frontend/src/i18n/locales/ru/game.json @@ -88,6 +88,17 @@ "click_to_unequip": "Нажмите, чтобы снять", "equip": "Надеть", "unequip": "Снять", + "use": "Использовать", + "equip_armor": "Надеть броню", + "unequip_armor": "Снять броню", + "equip_shield": "Взять щит", + "unequip_shield": "Убрать щит", + "equip_head": "Надеть на голову", + "unequip_head": "Снять с головы", + "equip_feet": "Надеть обувь", + "unequip_feet": "Снять обувь", + "equip_ring": "Надеть кольцо", + "unequip_ring": "Снять кольцо", "trade": "Торговля", "buy": "Купить", "sell": "Продать", 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 121293fc32d67480fd0d3f001d7cd667390c25ed..b2f3bbe804678a9f870938432d0b95f8d7880348 100644 GIT binary patch delta 6527 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 HM)VA? delta 5371 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 NJF&6j{-oil{{yi?W61yj diff --git a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po index a2c54ba5..aa3589d8 100644 --- a/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po +++ b/src/dnd_simulator/locale/ru/LC_MESSAGES/dnd_simulator.po @@ -1176,3 +1176,39 @@ msgstr "Активирующий триггер '{id}' снят со взвед msgid "Activation trigger '{id}' is not active" msgstr "Активирующий триггер '{id}' не активен" + +msgid "Equip a weapon from your inventory." +msgstr "Взять оружие из инвентаря." + +msgid "Put away your equipped weapon. You will fight with fists." +msgstr "Убрать оружие. Вы будете драться кулаками." + +msgid "Equip armor from your inventory." +msgstr "Надеть броню из инвентаря." + +msgid "Remove your equipped armor." +msgstr "Снять надетую броню." + +msgid "Equip a shield from your inventory. +2 AC." +msgstr "Взять щит из инвентаря. +2 к КЗ." + +msgid "Remove your equipped shield." +msgstr "Убрать щит." + +msgid "Equip headgear from your inventory." +msgstr "Надеть головной убор из инвентаря." + +msgid "Remove your equipped headgear." +msgstr "Снять головной убор." + +msgid "Equip footwear from your inventory." +msgstr "Надеть обувь из инвентаря." + +msgid "Remove your equipped footwear." +msgstr "Снять обувь." + +msgid "Equip a ring from your inventory." +msgstr "Надеть кольцо из инвентаря." + +msgid "Remove your equipped ring." +msgstr "Снять кольцо." diff --git a/tests/unit/test_equip_desc_i18n.py b/tests/unit/test_equip_desc_i18n.py new file mode 100644 index 00000000..8adcbb72 --- /dev/null +++ b/tests/unit/test_equip_desc_i18n.py @@ -0,0 +1,69 @@ +"""Equip/unequip action descriptions must localize under DND_LANGUAGE=ru. + +Sprint 024, Phase 2, Task 2. The transport payload builder runs +``_(definition.description)`` per action; the RU ``.po`` must carry the +equip/unequip description translations so a Russian player sees Russian +tooltips, not the English base string. +""" + +from __future__ import annotations + +from dnd_simulator.core.action import ActionType +from dnd_simulator.core.awareness import PeacefulAwareness +from dnd_simulator.i18n import language_context +from dnd_simulator.service.transport_payloads import _awareness_to_dict + +_EQUIP_ACTIONS = [ + ActionType.UNEQUIP, # weapon + ActionType.EQUIP_ARMOR, + ActionType.UNEQUIP_ARMOR, + ActionType.EQUIP_SHIELD, + ActionType.UNEQUIP_SHIELD, + ActionType.EQUIP_HEAD, + ActionType.UNEQUIP_HEAD, + ActionType.EQUIP_FEET, + ActionType.UNEQUIP_FEET, + ActionType.EQUIP_RING, + ActionType.UNEQUIP_RING, +] + + +def _has_cyrillic(text: str) -> bool: + return any("Ѐ" <= ch <= "ӿ" for ch in text) + + +def _peaceful(actions: list[ActionType]) -> PeacefulAwareness: + return PeacefulAwareness( + hour=10, + day=1, + month=6, + year=1490, + weather={}, + location_name="Town Square", + region_name="Valley", + settlements=None, + territory_owner=None, + nation_info=None, + available_actions=actions, + ) + + +def _descriptions_ru() -> dict[str, str]: + with language_context("ru"): + result = _awareness_to_dict(_peaceful(_EQUIP_ACTIONS)) + return {a["name"]: a["description"] for a in result["available_actions"]} + + +def test_equip_descriptions_localize_to_russian() -> None: + by_name = _descriptions_ru() + for action in _EQUIP_ACTIONS: + description = by_name[str(action)] + assert _has_cyrillic(description), f"{action} description not translated: {description!r}" + assert "—" not in description + + +def test_equip_descriptions_english_base_has_no_em_dash() -> None: + with language_context("en"): + result = _awareness_to_dict(_peaceful(_EQUIP_ACTIONS)) + for action_info in result["available_actions"]: + assert "—" not in action_info["description"] From 43c671b57c7e7868413ecf3f8063a5aba35f7f2f Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 04:12:52 +0300 Subject: [PATCH 10/19] =?UTF-8?q?sprint=20024=20phase=202:=20close=20?= =?UTF-8?q?=E2=80=94=20trading=20+=20equip/unequip=20i18n=20E2E=208/8,=20m?= =?UTF-8?q?ark=20phase=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E2E surfaced pre-existing ac-stale-on-unequip bug (unequip armor raises AC), logged to backlog. Not a phase-2 regression. --- docs/BACKLOG.md | 2 + docs/STATUS.md | 8 +-- .../e2e/phase2-report.md | 51 +++++++++++++++++++ .../sprints/024-playtest-quick-wins/sprint.md | 4 +- 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 docs/sprints/024-playtest-quick-wins/e2e/phase2-report.md diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f015385f..022154d9 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -42,6 +42,8 @@ - [x] `conversation-costs-time` — ~~Каждая реплика разговора тратит 6 секунд игрового времени (частично)~~ ПОГЛОЩЁН `intents`: «каждое действие несёт длительность» — реплика 6с, часть модели времени - [ ] **should** `loot-drops-monsters` — Общемонстровый дроп: loot-таблицы на шаблонах монстров, корпс-лут с обычных мобов поверх action `take` (Sprint 018 закладывает примитив `Lootable`/`transfer_items`) - [ ] **should** `catalog-item-prices` — стартовые предметы нельзя продать торговцу (видно в живой партии 2026-07-15). Корень: `validate_sell` (`rules/trade.py`) отбивает продажу при `item.price is None`, а ни у одного предмета в каталоге `content/catalogs/items/` поле `price` не задано (схема дефолтит в `None`). Стартовое снаряжение (`chain_mail`, `longsword`, `shield`, `rapier`, …) резолвится из каталога → `price=None` → «Item has no price and cannot be traded», даже когда предмет снят и лежит в инвентаре. Фикс: проставить SRD-цены (PHB) в каталожных YAML, тогда всё, что резолвится из каталога (стартовое, лутовое, купленное), несёт цену продажи. Проверить, что sell не требует предмет экипированным (продавать из инвентаря после снятия). Смежно с `item-properties-ui` (цена — одно из свойств для отображения) +- [ ] **should** `ac-stale-on-unequip` — снятие брони ПОВЫШАЕТ КЗ (найдено E2E Sprint 024 phase 2). Fighter Chain Mail+Shield+Defense = AC 19; снять Chain Mail → AC 21 (подтверждено бэкендом через `player_status`, не артефакт отрисовки). Корень: безбронная ветка `effective_ac` (`rules/modifiers.py:224`) `base = max(creature.ac, 10 + dex_mod)`, а `creature.ac` при создании пишется как бронированный эффективный AC (`commands_player.py:117`) и не переписывается при смене экипировки — `max()` держит устаревшее бронированное значение, щит стакается сверху. Монстров/NPC не задевает (безбронный commoner-трактирщик корректно показал КЗ 10). Фикс трогает «backwards compat» `max()`, который также обслуживает stat-block AC существ — нужен аккуратно + тесты. Смежно с equip/unequip механикой +- [ ] **could** `movement-log-i18n` — строка лога перемещения врага частично не переведена (найдено E2E Sprint 024 phase 2): сосуществуют локализованная «Гоблин перемещается на юго-запад (5 ft)» и английская «Гоблин moved (25 ft)». Две разных отрисовки события движения, одна не через `gettext`. Мелочь, докрутить перевод - [ ] **should** `theft` — Воровство как отдельный режим доступа к инвентарю: take у живого несогласного владельца, contested Sleight of Hand против Perception, crime/репутация; отдельная `validate_steal` поверх общего `transfer_items` - [x] `spawn-event-trigger` — ~~Event-триггер спавна (спавн по мировому событию), в связке со спринтом квестов~~ ПОГЛОЩЁН `trigger-table`: спавн — одно из действий сработавшего триггера - [ ] **could** `spawn-api-xp-value` — master spawn API не принимает `xp_value` для generic-монстров: XP-смоук возможен только на фикстурном мире. E2E sprint 021 close diff --git a/docs/STATUS.md b/docs/STATUS.md index be06db78..a91d84d3 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,8 +3,8 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-16 -**Position:** Sprint 024 (playtest-quick-wins): Phase 1 COMPLETE; Phase 2 (полировка торговли и экипировки) — обе задачи done, фаза готова к закрытию. -**Next:** Закрыть Phase 2 (`/close-phase`). +**Position:** Sprint 024 (playtest-quick-wins): Phase 1 COMPLETE; Phase 2 COMPLETE (integration 164, E2E 8/8). Готова к планированию Phase 3 (панель свойств предметов). +**Next:** Спланировать Phase 3 (`/plan-phase`). **Blockers:** Нет. ## Current Sprint @@ -12,7 +12,9 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 2 — Полировка торговли и экипировки (task 1 done, task 2 done) — 2026-07-16 +**Phase:** 2 — Полировка торговли и экипировки (COMPLETE) — 2026-07-16. Ready for Phase 3 task generation. + +Phase 2 закрыта (2026-07-16): integration 164 green (данные+i18n, новых интеграционных тестов не требуется — плумбинг цены и i18n покрыты бэкенд-юнитами `TestCatalogPrices`/`test_equip_desc_i18n` + фронт `equipI18n.test.tsx`). E2E RU (`test_vale`): купля/продажа стартового `chain_mail` (75g SRD, gold 950→1025), equip/unequip i18n (Надеть/Убрать/Экипировать/Использовать, slot-метки Броня/Голова/Ноги/Кольцо/Сумка), combat-регресс — 8/8. Вскрыт предсуществующий баг `ac-stale-on-unequip` (снятие брони → КЗ растёт, `effective_ac` держит устаревший `creature.ac`) — вне скоупа, в бэклоге. Отчёт: `docs/sprints/024-playtest-quick-wins/e2e/phase2-report.md`. Task 2 (i18n надеть/снять) done: 10 slot-меток (`equip_armor`…`unequip_ring`) + короткий `use` в `en`/`ru` `game.json`; `USE`/`EQUIP` в `InventoryPanel` уведены в `t()` (EQUIP→`equip`, USE→новый `use`); 12 RU-описаний equip/unequip добавлены в `.po` руками (`make messages` не годится — гонит `pygettext --keyword=_` без `N_` и падает на f-строках), `.mo` перекомпилирован. Аддитивно, коллапс 12 ActionType не тронут. `make check` зелёный (backend 2565, frontend 291). diff --git a/docs/sprints/024-playtest-quick-wins/e2e/phase2-report.md b/docs/sprints/024-playtest-quick-wins/e2e/phase2-report.md new file mode 100644 index 00000000..c0d63663 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/e2e/phase2-report.md @@ -0,0 +1,51 @@ +# E2E Report: sprint024-phase2 + +**Date:** 2026-07-16 +**Flags:** --no-llm +**Sections tested:** 5 (Equipment), 8 (equip/unequip i18n), 9 (Trading) + combat regression +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs, world `test_vale`, UI language RU + +## Summary + +- Scenarios: 8 tested, 8 passed +- Quick fixes: 0 +- Blockers: 0 (phase deliverables all green) +- Findings: 1 pre-existing correctness bug (AC), 1 minor i18n gap — both out of Phase 2 scope + +## Results + +### Phase 2 functionality (primary) + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| T1.1 | Catalog item shows SRD price | pass | Chain Mail shows `75g` in bag; Health Potion `50g` | +| T1.2 | Buy from merchant | pass | Health Potion 50g: gold 1000→950, merchant 200→250, potion lands in bag | +| T1.3 | Sell starting gear from inventory | pass | Unequip Chain Mail → bag (75g) → Sell: gold 950→1025 (+75 full SRD), item transfers to merchant, gone from bag | +| T2.1 | Equip button i18n (RU) | pass | Bag item shows "Надеть"; potion shows "Использовать" | +| T2.2 | Equip/unequip log descriptions (RU) | pass | "Ты экипируешь Chain Mail" / "Ты убираешь Chain Mail" | +| T2.3 | Slot labels i18n (RU) | pass | Броня / Голова / Ноги / Кольцо / Сумка all localized | + +### Regression + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 3.1 | Combat initiate + attack | pass | RU initiative order, attack roll "Ты атакуешь Гоблин (longsword slash) [d20(3)+2=5 vs КЗ 15], промах", combat panel + battle map switch in, RU budget bar (Действия/Бонус/Движение/Реакция) | +| 2.4 | Travel between locations | pass | Tavern ↔ market travel via graph path, Location Panel updates | + +## Findings + +### Blockers +- None for Phase 2. Prices, sell-from-inventory, and equip/unequip i18n all work end to end. + +### Pre-existing (out of scope, not introduced by Phase 2) + +- **AC increases when armor is unequipped.** Fighter with Chain Mail + Shield + Defense shows AC 19. Unequipping Chain Mail raises AC to **21** (backend confirmed via `player_status`, not a display glitch). Root cause: `rules/modifiers.py:224` unarmored branch `base = max(creature.ac, 10 + dex_mod)`. At character creation `player.ac` is stored as the *armored* effective AC (`commands_player.py:117`) and never rewritten on equip changes, so `max()` keeps the stale armored value and the shield bonus stacks on top. Monsters/NPCs are unaffected (a plain commoner barkeep correctly showed КЗ 10). Fix touches the "backwards compat" `max()` that also serves stat-block AC creatures — needs care + tests. → BACKLOG. + +### Minor +- **Movement log line partially untranslated.** Two enemy-movement renderings coexist: localized "Гоблин перемещается на юго-запад (5 ft)" and English "Гоблин moved (25 ft)". Pre-existing, unrelated to Phase 2 (equip/unequip i18n). → BACKLOG. +- Starting-equipment names remain English in the RU creation preview ("Chain Mail, Longsword, Shield") and item names stay English in-game. Known separate item-name-localization gap, out of scope. + +## Log Analysis + +- Backend log: 0 tracebacks, 0 exceptions. Clean. +- Enemy technical refusals did not leak to the player log (Phase 1 task 2 holding). diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md index d929014b..3a34378b 100644 --- a/docs/sprints/024-playtest-quick-wins/sprint.md +++ b/docs/sprints/024-playtest-quick-wins/sprint.md @@ -30,10 +30,12 @@ 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: Полировка торговли и экипировки +## Phase 2: Полировка торговли и экипировки ✓ Стартовое снаряжение получает SRD-цены и продаётся торговцу из инвентаря; кнопки надеть/снять локализованы без сырых ID. Проверка: снять и продать стартовый предмет; кнопки equip/unequip показывают RU-метки и описания. +Закрыта 2026-07-16: integration 164 green, E2E (trading + equip/unequip i18n + combat regress) 8/8. E2E вскрыл предсуществующий баг `ac-stale-on-unequip` (снятие брони повышает КЗ) — вне скоупа фазы, в бэклоге. + **Айтемы:** `catalog-item-prices`, `action-bar-equip-i18n`, `action-bar-unequip-i18n` **Tasks:** From 265ddf961f1096db953966875527dd068a5abda5 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:08:46 +0300 Subject: [PATCH 11/19] sprint 024 phase 3: task breakdown --- docs/STATUS.md | 8 ++- .../sprints/024-playtest-quick-wins/sprint.md | 7 ++- .../tasks/phase3-task1-item-props-payload.md | 62 +++++++++++++++++++ .../phase3-task2-item-details-tooltip.md | 53 ++++++++++++++++ 4 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md create mode 100644 docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md diff --git a/docs/STATUS.md b/docs/STATUS.md index a91d84d3..9a802961 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,8 +3,8 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-16 -**Position:** Sprint 024 (playtest-quick-wins): Phase 1 COMPLETE; Phase 2 COMPLETE (integration 164, E2E 8/8). Готова к планированию Phase 3 (панель свойств предметов). -**Next:** Спланировать Phase 3 (`/plan-phase`). +**Position:** Sprint 024 (playtest-quick-wins): Phases 1-2 COMPLETE; Phase 3 (панель свойств предметов) — tasks generated (2 tasks). +**Next:** Начать Phase 3 task 1 (`/implement`). **Blockers:** Нет. ## Current Sprint @@ -12,7 +12,9 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 2 — Полировка торговли и экипировки (COMPLETE) — 2026-07-16. Ready for Phase 3 task generation. +**Phase:** 3 — Панель свойств предметов (tasks generated) — 2026-07-16. Ready to start task 1. + +Phase 3 план: свойства сейчас доезжают до UI только английской строкой `describe_item()` в native `title` (броня/щит — вообще ничего). Task 1 — машиночитаемый JSON-safe `props` из типизированных дефов на `ItemInfo`/`EquippedInfo` по всем четырём каналам (инвентарь, экипировка, торговец, лут); task 2 — компонент `ItemDetails` (CSS-hover карточка, контент в DOM — тестируемо), рендер с i18n-метками EN+RU в `InventoryPanel`/`TradePanel`, fallback на `description`. Серверный язык не трогаем (`ui-language-mixing` вне скоупа), LLM-промпты `props` не раздувает. Phase 2 закрыта (2026-07-16): integration 164 green (данные+i18n, новых интеграционных тестов не требуется — плумбинг цены и i18n покрыты бэкенд-юнитами `TestCatalogPrices`/`test_equip_desc_i18n` + фронт `equipI18n.test.tsx`). E2E RU (`test_vale`): купля/продажа стартового `chain_mail` (75g SRD, gold 950→1025), equip/unequip i18n (Надеть/Убрать/Экипировать/Использовать, slot-метки Броня/Голова/Ноги/Кольцо/Сумка), combat-регресс — 8/8. Вскрыт предсуществующий баг `ac-stale-on-unequip` (снятие брони → КЗ растёт, `effective_ac` держит устаревший `creature.ac`) — вне скоупа, в бэклоге. Отчёт: `docs/sprints/024-playtest-quick-wins/e2e/phase2-report.md`. diff --git a/docs/sprints/024-playtest-quick-wins/sprint.md b/docs/sprints/024-playtest-quick-wins/sprint.md index 3a34378b..1c4e5507 100644 --- a/docs/sprints/024-playtest-quick-wins/sprint.md +++ b/docs/sprints/024-playtest-quick-wins/sprint.md @@ -49,15 +49,18 @@ **Айтемы:** `item-properties-ui` +**Разведка (2026-07-16):** свойства предметов сейчас доезжают до UI только строкой `describe_item()` (`core/awareness.py:65`) — захардкоженный английский в native `title`; броня и щит не показывают ничего, кроме имени. Типизированные дефы до фронта не доходят. Решение — аддитивный машиночитаемый `props` в payload + клиентский рендер с i18n-метками, чтобы не завязываться на серверный язык (`ui-language-mixing` не трогаем). LLM-промпты берут из `ItemInfo` только `id`/`name`/`description`, `props` их не раздувает. + **Tasks:** -_(генерируются отдельно перед началом фазы)_ +1. [Структурные свойства предметов в player-facing payload](tasks/phase3-task1-item-props-payload.md) — `item_props()` из типизированных дефов, JSON-safe `props` на `ItemInfo`/`EquippedInfo`, прошивка в инвентарь/экипировку/товары торговца/лут +2. [Панель деталей предмета в магазине и инвентаре](tasks/phase3-task2-item-details-tooltip.md) — компонент `ItemDetails` (CSS-hover карточка), рендер `props` с i18n-метками EN+RU в `InventoryPanel` и `TradePanel`, fallback на `description` --- ## Status -**Current:** Phase 1 complete (all 3 tasks done, integration 164 green, E2E 9/9). Phase 2 tasks generated (2 tasks). Ready to start Phase 2 task 1. +**Current:** Phases 1-2 complete. Phase 3 tasks generated (2 tasks). Ready to start Phase 3 task 1. ## Decisions diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md new file mode 100644 index 00000000..7e2c6da9 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md @@ -0,0 +1,62 @@ +# Task: Структурные свойства предметов в player-facing payload + +**Date:** 2026-07-16 +**Sprint:** 024-playtest-quick-wins +**Phase:** 3 — Панель свойств предметов + +## Description + +Игрок не видит, что делает предмет, до покупки/надевания (`item-properties-ui`, живая партия 2026-07-15). Сейчас единственный канал свойств — строка `describe_item()` (`core/awareness.py:65`): захардкоженный английский, без брони и щитов вовсе (для них возвращается голое `item.name`), уходит в native `title` tooltip. Типизированные дефы (`WeaponDef`/`ArmorDef`/`ShieldDef`/`AccessoryDef`, `core/items.py`) до фронта не доезжают. + +Добавляем аддитивное поле `props` — JSON-safe словарь машиночитаемых значений, который фронт рендерит локализованными метками сам (клиентский i18n; серверный `ui-language-mixing` не трогаем): + +``` +weapon: {kind: "weapon", damage: [{dice: "1d8", type: "slashing"}, ...], reach: 5, + category: "simple"|"martial", ability: "dex"|null, modifier: 1, is_magic: bool, + is_finesse/is_two_handed/is_light/is_heavy: bool, conditions: ["blessed", ...]} +armor: {kind: "armor", category: "light"|"medium"|"heavy", base_ac: 18, max_dex_bonus: 0} +shield: {kind: "shield", ac_bonus: 2} +accessory: {kind: "accessory", slot: "ring", modifiers: [{stat: "ac", op: "add", value: 1}]} +potion: {kind: "potion", heal_dice: "2d4+2"} +``` + +Предмет без дефов и `heal_dice` → `props` отсутствует/`None`. Все значения — примитивы (enum'ы конвертируются в `.value` при сборке): payload'ы merchant-веток идут через `dataclasses.asdict` без сквозного `_json_safe`, Enum внутри dict-поля утёк бы в WS-сериализацию. + +Точки прошивки: + +- `core/awareness.py` — билдер `item_props(item: Item) -> dict[str, object] | None` рядом с `describe_item`; поля `props` на `ItemInfo` и `EquippedInfo` (default `None`). +- `layers/entities/awareness_builder.py` — `build_available_items` (:83), `build_merchants` (:126), `build_equipped` (:96), лутовая ветка (:312-351) заполняют `props`. +- `service/transport_payloads.py` — `build_inventory_payload` (:126) и `build_equipped_payload` (:117) кладут `props` в dict (они собирают словари руками, asdict их не спасает). + +Границы: `describe_item` остаётся как есть — его едят LLM-промпты (`llm/brain.py:145,176` берут только `id`/`name`/`description`, так что `props` промпты не раздувает). Рендер на фронте — task 2. + +## Tests First + +Продуктовые сценарии на реальном резолве каталога (`parse_items([{"ref": ...}], catalog)`, паттерн из `TestSellStartingEquipmentFromCatalog`), не на hand-built `Item`: + +- Игрок с `plate` в инвентаре → `build_inventory_payload` → запись несёт `props` с `kind="armor"`, `base_ac=18`, `max_dex_bonus=0`, `category="heavy"`. +- Торговец с `flaming_longsword` в продаже → peaceful awareness → `merchants[0].items[0].props`: две компоненты урона (`1d8 slashing` + `1d6 fire`), `modifier=1`, `is_magic=True`, `category="martial"`. +- `frost_dagger` → `props`: `is_finesse=True`, `is_light=True`, `ability="dex"`. +- Надетый `ring_of_protection` → `build_equipped_payload` → `props`: `kind="accessory"`, `slot="ring"`, `modifiers=[{stat:"ac", op:"add", value:1}]`. +- `health_potion` → `props`: `kind="potion"`, `heal_dice="2d4+2"`; `shield` → `ac_bonus=2`. +- JSON-safety: `json.dumps(props)` проходит для каждой записи каталога (параметризованный тест по всем 31 YAML — гарда от Enum-утечки и новых полей). + +## Implementation + +- `item_props()` — чистая функция-вьюха по образцу `describe_item`: ветки по `weapon_def`/`armor_def`/`shield_def`/`accessory_def`/`params["heal_dice"]`, enum'ы → `.value` на месте. +- `ItemInfo.props: dict[str, object] | None = None`, `EquippedInfo` аналогично — аддитивно, существующие конструкторы не ломаются. +- Merchant/available_items/loot ветки awareness едут через `dataclasses.asdict` — новое поле доезжает до WS само; руками прошивать только `build_inventory_payload`/`build_equipped_payload`. +- Тесты класть рядом с существующими: `test_inventory_awareness.py`, `test_srd_catalogs.py` (JSON-гарда), payload-тесты по образцу соседей `transport_payloads`. + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] `props` доезжает по всем четырём каналам: инвентарь, экипировка, товары торговца, лут +- [ ] Все 31 записи каталога дают JSON-сериализуемый `props` (или его отсутствие для простых предметов) +- [ ] `describe_item`/LLM-промпты не тронуты + +## Status + +`pending` diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md new file mode 100644 index 00000000..c2676623 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md @@ -0,0 +1,53 @@ +# Task: Панель деталей предмета в магазине и инвентаре + +**Date:** 2026-07-16 +**Sprint:** 024-playtest-quick-wins +**Phase:** 3 — Панель свойств предметов + +## Description + +Фронтовая половина `item-properties-ui`: структурный `props` из task 1 рендерится локализованной карточкой деталей вместо сырого английского `title={item.description}` в `InventoryPanel.tsx` (bag-строки :91, слоты экипировки :60) и `TradePanel.tsx` (buy :39, sell :65). + +Новый компонент `ItemDetails` (`components/game/`): + +- CSS-hover tooltip (паттерн `group`/`group-hover`, абсолютное позиционирование) — контент всегда в DOM, скрыт стилем; это делает его тестируемым testing-library без симуляции hover и не требует новых зависимостей. +- Рендерит по `props.kind`: оружие — компоненты урона («1d8 рубящий + 1d6 огонь»), досягаемость, категория (простое/воинское), свойства-флаги (фехтовальное, двуручное, лёгкое, тяжёлое, магическое, +N), рабочая характеристика; броня — базовый КЗ, кап ЛОВ, категория (лёгкая/средняя/тяжёлая); щит — бонус КЗ; аксессуар — слот и модификаторы («+1 КЗ», «+10 скорость»); зелье — «лечит 2d4+2 ОЗ». Цена показывается в карточке, если есть. +- Fallback: `props` нет → показывать `description` как раньше (простые предметы, старые сейвы). + +i18n (`frontend/src/i18n/locales/{en,ru}/game.json`): метки полей (урон, досягаемость, базовый КЗ, кап ЛОВ, бонус КЗ, лечит, слот), категории оружия/брони, свойства-флаги, имена статов модификаторов (`ac`, `speed`, `attack_roll`, `initiative`), все 13 типов урона из `DamageType` (`core/character.py:102`). Имена grant_conditions переводить через `t()` с `defaultValue` — каталог их сейчас не использует, ключи не плодим. + +Типы: `ItemProps` в `types/game.ts` (discriminated union по `kind`), опциональный `props?` на `ItemInfo` и `EquippedInfo`. + +Границы: только магазин и инвентарь (включая слоты экипировки) — по скоупу фазы. `LootPanel` получает `props` бэкендом бесплатно, но его рендер не трогаем. Клик-поведение строк (unequip по клику на слот, кнопки buy/sell/equip/use) не меняется. + +## Tests First + +Компонентные тесты по паттерну `equipI18n.test.tsx` (реальный i18n, `changeLanguage("ru")`/`"en"`, store с mock wsClient): + +- Игрок с латами (`props`: armor, base_ac 18, max_dex_bonus 0, heavy) в сумке → `InventoryPanel` рендерит в карточке «КЗ 18» и русскую метку капа ЛОВ; на `en` — английские метки. +- Торговец с огненным мечом (две компоненты урона, modifier 1, is_magic, martial) → `TradePanel` показывает обе компоненты с русскими типами урона («рубящий», «огонь»), «+1» и метку «магическое». +- Надетое кольцо защиты (`equipped`, props accessory `+1 ac`) → карточка слота показывает «+1 КЗ». +- Зелье лечения → «лечит 2d4+2». +- Предмет без `props`, но с `description` → карточка показывает description (fallback не сломан). +- Регресс: существующие клики (unequip по слоту, кнопка buy) продолжают слать те же WS-действия. + +## Implementation + +- `types/game.ts` — `ItemProps` union + `props?` на `ItemInfo`/`EquippedInfo`. +- `components/game/ItemDetails.tsx` — чистый презентационный компонент от `props`/`description`/`price`; обёртка-триггер вокруг имени предмета. +- Вшить в четыре места рендера (`InventoryPanel` bag + слоты, `TradePanel` buy + sell), убрав `title=`-атрибуты. +- `game.json` en/ru — ключи метки/категории/флаги/статы/типы урона; следить за конфликтами с существующими ключами (`use`, `equip` уже заняты). +- Позиционирование tooltip'а в узких сайдбар-колонках: не выпадать за край экрана (max-width, выравнивание по правому краю для правой колонки). + +## Acceptance Criteria + +- [ ] Tests written and RED (before implementation) +- [ ] Implementation makes tests GREEN +- [ ] Existing tests still pass (`make check`) +- [ ] В магазине и инвентаре по предмету видно урон/КЗ/эффект до покупки/надевания, EN и RU без сырых английских строк +- [ ] Броня и щит впервые показывают свои свойства (раньше — только имя) +- [ ] Fallback на `description` для предметов без `props` + +## Status + +`pending` From 47b5e15a24fb3df4cc7723c954d866cc866c354a Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:20:40 +0300 Subject: [PATCH 12/19] sprint 024 phase 3 task 1: structured item props in player-facing payloads --- docs/STATUS.md | 4 +- .../tasks/phase3-task1-item-props-payload.md | 10 +- src/dnd_simulator/adapters/api/schemas.py | 2 +- src/dnd_simulator/core/awareness.py | 62 ++++++ .../layers/entities/awareness_builder.py | 41 +--- src/dnd_simulator/service/dto.py | 2 +- .../service/transport_payloads.py | 13 +- tests/unit/test_item_props.py | 181 ++++++++++++++++++ 8 files changed, 275 insertions(+), 40 deletions(-) create mode 100644 tests/unit/test_item_props.py diff --git a/docs/STATUS.md b/docs/STATUS.md index 9a802961..6dc70c55 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -12,7 +12,9 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 3 — Панель свойств предметов (tasks generated) — 2026-07-16. Ready to start task 1. +**Phase:** 3 — Панель свойств предметов (task 1 done, task 2 pending) — 2026-07-16. + +Task 1 (структурный `props` в payload) done: `item_props()` + `props` на `ItemInfo`/`EquippedInfo`, хелпер `item_info()` схлопнул три дублированные сборки в `awareness_builder`; все четыре канала (инвентарь, экипировка, торговец, лут) несут машиночитаемые свойства из типизированных дефов. `equipped` расширен до `list[dict[str, object]]` в `PlayerStatusData` и `PlayerStatusResponse` (props — dict, старая str-типизация отбивала 422). 8 новых тестов на реальном резолве каталога + JSON-гарда по всем 31 записям. `make check` зелёный (backend 2573, frontend 291). Phase 3 план: свойства сейчас доезжают до UI только английской строкой `describe_item()` в native `title` (броня/щит — вообще ничего). Task 1 — машиночитаемый JSON-safe `props` из типизированных дефов на `ItemInfo`/`EquippedInfo` по всем четырём каналам (инвентарь, экипировка, торговец, лут); task 2 — компонент `ItemDetails` (CSS-hover карточка, контент в DOM — тестируемо), рендер с i18n-метками EN+RU в `InventoryPanel`/`TradePanel`, fallback на `description`. Серверный язык не трогаем (`ui-language-mixing` вне скоупа), LLM-промпты `props` не раздувает. diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md index 7e2c6da9..9e12b5db 100644 --- a/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md @@ -59,4 +59,12 @@ potion: {kind: "potion", heal_dice: "2d4+2"} ## Status -`pending` +`done` + +## Developer Notes + +Реализовано по плану: `item_props()` и `props` на `ItemInfo`/`EquippedInfo` в `core/awareness.py`; заодно введён хелпер `item_info(item)`, который схлопнул три идентичные ручные сборки `ItemInfo` в `awareness_builder.py` (available_items, merchant stock, loot). `build_inventory_payload`/`build_equipped_payload` прошиты руками (они собирают dict сами). + +Интенциональное изменение контракта: `props` — dict, поэтому `equipped` расширен с `list[dict[str, str]]` до `list[dict[str, object]]` в двух местах — `PlayerStatusData` (`service/dto.py:55`) и REST-схема `PlayerStatusResponse` (`adapters/api/schemas.py:228`). Без второго 29 старых тестов падали 422 на создании персонажа — Pydantic отбивал dict в str-поле. Сами старые тесты не правились. + +Тесты: `test_item_props.py`, 8 шт — все четыре канала на реальном резолве каталога (plate/фрост-кинжал/зелье+щит в инвентаре, огненный меч у торговца, кольцо защиты в экипировке, длинный меч в луте трупа), `props is None` для предмета без дефов, JSON-гарда по всем 31 записям каталога (ловит Enum-утечку: merchant/loot едут через `asdict` без сквозного `_json_safe`). `make check` зелёный (backend 2573, frontend 291). diff --git a/src/dnd_simulator/adapters/api/schemas.py b/src/dnd_simulator/adapters/api/schemas.py index cf149e20..dc468c77 100644 --- a/src/dnd_simulator/adapters/api/schemas.py +++ b/src/dnd_simulator/adapters/api/schemas.py @@ -225,7 +225,7 @@ class PlayerStatusResponse(BaseModel): ability_scores: dict[str, int] journey: JourneyResponse | None = None resource_pools: list[dict[str, object]] = Field(default_factory=list) - equipped: list[dict[str, str]] = Field(default_factory=list) + equipped: list[dict[str, object]] = Field(default_factory=list) inventory: list[dict[str, object]] = Field(default_factory=list) diff --git a/src/dnd_simulator/core/awareness.py b/src/dnd_simulator/core/awareness.py index 0e8ece57..432dbc4f 100644 --- a/src/dnd_simulator/core/awareness.py +++ b/src/dnd_simulator/core/awareness.py @@ -50,6 +50,7 @@ class ItemInfo: description: str # e.g. "Healing Potion (heals 2d4+2 HP)" item_type: str = "" # "potion", "weapon", "armor", etc. price: int | None = None + props: dict[str, object] | None = None # structured properties, see item_props() @dataclass(frozen=True) @@ -60,6 +61,7 @@ class EquippedInfo: item_id: str name: str description: str + props: dict[str, object] | None = None def describe_item(item: Item) -> str: @@ -92,6 +94,66 @@ def describe_item(item: Item) -> str: return item.name +def item_props(item: Item) -> dict[str, object] | None: + """Build machine-readable item properties for the UI. + + Values are pure JSON primitives (enums converted to ``.value`` here): + merchant and loot payloads go through ``dataclasses.asdict`` without a + blanket JSON pass, so nothing non-primitive may leak in. The frontend + renders localized labels from these values; ``None`` for items with no + typed def and no potion effect. + """ + if item.weapon_def is not None: + wd = item.weapon_def + return { + "kind": "weapon", + "damage": [{"dice": d.dice, "type": d.type.value} for d in wd.damage], + "reach": wd.reach, + "category": wd.category.value, + "ability": wd.ability.value if wd.ability is not None else None, + "modifier": wd.modifier, + "is_magic": wd.is_magic, + "is_finesse": wd.is_finesse, + "is_two_handed": wd.is_two_handed, + "is_light": wd.is_light, + "is_heavy": wd.is_heavy, + "conditions": [c.value for c in wd.grant_conditions], + } + if item.armor_def is not None: + ad = item.armor_def + return { + "kind": "armor", + "category": ad.category.value, + "base_ac": ad.base_ac, + "max_dex_bonus": ad.max_dex_bonus, + } + if item.shield_def is not None: + return {"kind": "shield", "ac_bonus": item.shield_def.ac_bonus} + if item.accessory_def is not None: + acc = item.accessory_def + return { + "kind": "accessory", + "slot": acc.slot.value, + "modifiers": [{"stat": m.stat.value, "op": m.op.value, "value": m.value} for m in acc.grant_modifiers], + } + heal_dice = item.params.get("heal_dice") + if heal_dice: + return {"kind": "potion", "heal_dice": heal_dice} + return None + + +def item_info(item: Item) -> ItemInfo: + """Build the standard ItemInfo view of an inventory item.""" + return ItemInfo( + id=item.id, + name=item.name, + description=describe_item(item), + item_type=str(item.item_type), + price=item.price, + props=item_props(item), + ) + + @dataclass(frozen=True) class ResourcePoolInfo: """A resource pool as seen in awareness — lightweight view for brains/UI.""" diff --git a/src/dnd_simulator/layers/entities/awareness_builder.py b/src/dnd_simulator/layers/entities/awareness_builder.py index 3b3b3f6f..cf5152ef 100644 --- a/src/dnd_simulator/layers/entities/awareness_builder.py +++ b/src/dnd_simulator/layers/entities/awareness_builder.py @@ -17,6 +17,8 @@ PeacefulAwareness, ResourcePoolInfo, describe_item, + item_info, + item_props, ) from dnd_simulator.core.character import Character, Creature, Entity from dnd_simulator.core.combat import CombatState @@ -82,16 +84,7 @@ def build_awareness( @staticmethod def build_available_items(creature: Creature) -> list[ItemInfo]: """Build item info list for awareness — full inventory.""" - return [ - ItemInfo( - id=item.id, - name=item.name, - description=describe_item(item), - item_type=str(item.item_type), - price=item.price, - ) - for item in creature.inventory - ] + return [item_info(item) for item in creature.inventory] @staticmethod def build_equipped(creature: Creature) -> list[EquippedInfo]: @@ -105,7 +98,9 @@ def build_equipped(creature: Creature) -> list[EquippedInfo]: (EquipmentSlot.RING, creature.equipped_ring), ] return [ - EquippedInfo(slot=slot, item_id=item.id, name=item.name, description=describe_item(item)) + EquippedInfo( + slot=slot, item_id=item.id, name=item.name, description=describe_item(item), props=item_props(item) + ) for slot, item in slots if item is not None ] @@ -127,17 +122,7 @@ def build_merchants(self, creature: Creature, hour: int) -> list[MerchantInfo]: """Build merchant info for merchant NPCs at the creature's location.""" result: list[MerchantInfo] = [] for npc in active_merchants_at(self._entities, creature.location_id, hour): - items = [ - ItemInfo( - id=item.id, - name=item.name, - description=describe_item(item), - item_type=str(item.item_type), - price=item.price, - ) - for item in npc.inventory - if item.price is not None - ] + items = [item_info(item) for item in npc.inventory if item.price is not None] result.append(MerchantInfo(id=npc.id, name=npc.name, gold=npc.gold, items=items)) return result @@ -309,7 +294,6 @@ def build_nearby_entities( self, creature: Creature, hour: int, query_fn: QueryFn | None = None ) -> list[NearbyEntity]: """Build list of nearby entities for peaceful awareness.""" - from dnd_simulator.core.awareness import ItemInfo, describe_item from dnd_simulator.core.loot import InventoryHolder from dnd_simulator.rules.loot import is_lootable @@ -344,16 +328,7 @@ def build_nearby_entities( loot_items: list[ItemInfo] = [] loot_gold = 0 if lootable and isinstance(e, InventoryHolder): - loot_items = [ - ItemInfo( - id=i.id, - name=i.name, - description=describe_item(i), - item_type=str(i.item_type), - price=i.price, - ) - for i in e.inventory - ] + loot_items = [item_info(i) for i in e.inventory] loot_gold = e.gold # Structured fields for inspect card — all populated from AwarenessBuilder name = e.name diff --git a/src/dnd_simulator/service/dto.py b/src/dnd_simulator/service/dto.py index 3fdc9fd6..16232cbf 100644 --- a/src/dnd_simulator/service/dto.py +++ b/src/dnd_simulator/service/dto.py @@ -52,5 +52,5 @@ class PlayerStatusData: ability_scores: dict[str, int] journey: JourneyView | None = None resource_pools: list[ResourcePoolView] = field(default_factory=list) - equipped: list[dict[str, str]] = field(default_factory=list) + equipped: list[dict[str, object]] = field(default_factory=list) inventory: list[dict[str, object]] = field(default_factory=list) diff --git a/src/dnd_simulator/service/transport_payloads.py b/src/dnd_simulator/service/transport_payloads.py index 2fc0124c..28855be3 100644 --- a/src/dnd_simulator/service/transport_payloads.py +++ b/src/dnd_simulator/service/transport_payloads.py @@ -114,17 +114,23 @@ def _reaction_to_dict(trigger: ReactionTrigger, options: list[ReactionOption]) - } -def build_equipped_payload(player: PlayerCharacter) -> list[dict[str, str]]: +def build_equipped_payload(player: PlayerCharacter) -> list[dict[str, object]]: from dnd_simulator.layers.entities.awareness_builder import AwarenessBuilder return [ - {"slot": entry.slot.value, "item_id": entry.item_id, "name": entry.name, "description": entry.description} + { + "slot": entry.slot.value, + "item_id": entry.item_id, + "name": entry.name, + "description": entry.description, + "props": entry.props, + } for entry in AwarenessBuilder.build_equipped(player) ] def build_inventory_payload(player: PlayerCharacter) -> list[dict[str, object]]: - from dnd_simulator.core.awareness import describe_item + from dnd_simulator.core.awareness import describe_item, item_props inventory: list[dict[str, object]] = [] for item in player.inventory: @@ -134,6 +140,7 @@ def build_inventory_payload(player: PlayerCharacter) -> list[dict[str, object]]: "type": item.item_type.value, "description": describe_item(item), "price": item.price, + "props": item_props(item), } if item.accessory_def is not None: entry["slot"] = item.accessory_def.slot.value diff --git a/tests/unit/test_item_props.py b/tests/unit/test_item_props.py new file mode 100644 index 00000000..97e5d429 --- /dev/null +++ b/tests/unit/test_item_props.py @@ -0,0 +1,181 @@ +"""Structured item props in player-facing payloads (Sprint 024, Phase 3, Task 1). + +Items resolved from the real catalog must carry a JSON-safe ``props`` dict +(built from the typed defs) through every player-facing channel: inventory, +equipped slots, merchant stock, and loot. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from dnd_simulator.content_loader import parse_items +from dnd_simulator.content_loader.catalogs import load_catalog +from dnd_simulator.content_loader.schemas import ItemContent +from dnd_simulator.core.awareness import item_props +from dnd_simulator.core.character import AbilityScores, Alignment, CharClass, Creature, NpcRole, Race +from dnd_simulator.core.items import EquipmentSlot, Item +from dnd_simulator.core.player import PlayerCharacter +from dnd_simulator.layers.entities.awareness_builder import AwarenessBuilder +from dnd_simulator.layers.entities.combat_manager import CombatManager +from dnd_simulator.layers.entities.models import Npc +from dnd_simulator.service.transport_payloads import build_equipped_payload, build_inventory_payload + +CATALOG_DIR = Path(__file__).resolve().parents[2] / "content" / "catalogs" / "items" + + +@pytest.fixture(scope="module") +def catalog() -> dict[str, ItemContent]: + return load_catalog(CATALOG_DIR, ItemContent) + + +def _resolve(ref: str, catalog: dict[str, ItemContent]) -> Item: + items = parse_items([{"ref": ref}], item_catalog=catalog) + assert len(items) == 1 + return items[0] + + +def _creature(inventory: list[Item] | None = None) -> Creature: + return Creature( + id="c", + name="C", + location_id="loc", + ability_scores=AbilityScores(), + max_hp=10, + current_hp=10, + ac=10, + inventory=inventory or [], + ) + + +def _player(**kwargs: object) -> PlayerCharacter: + return PlayerCharacter( + id="player_1", + name="Hero", + location_id="loc", + race=Race.HUMAN, + char_class=CharClass.FIGHTER, + alignment=Alignment.TRUE_NEUTRAL, + ability_scores=AbilityScores(), + max_hp=20, + current_hp=20, + ac=16, + **kwargs, # type: ignore[arg-type] + ) + + +class TestInventoryChannel: + def test_plate_props_in_inventory_payload(self, catalog: dict[str, ItemContent]) -> None: + player = _player(inventory=[_resolve("plate", catalog)]) + entry = build_inventory_payload(player)[0] + props = entry["props"] + assert isinstance(props, dict) + assert props["kind"] == "armor" + assert props["base_ac"] == 18 + assert props["max_dex_bonus"] == 0 + assert props["category"] == "heavy" + + def test_frost_dagger_flags_in_available_items(self, catalog: dict[str, ItemContent]) -> None: + creature = _creature(inventory=[_resolve("frost_dagger", catalog)]) + info = AwarenessBuilder.build_available_items(creature)[0] + assert info.props is not None + assert info.props["kind"] == "weapon" + assert info.props["is_finesse"] is True + assert info.props["is_light"] is True + assert info.props["ability"] == "dex" + + def test_potion_and_shield_props(self, catalog: dict[str, ItemContent]) -> None: + player = _player(inventory=[_resolve("health_potion", catalog), _resolve("shield", catalog)]) + by_name = {entry["name"]: entry["props"] for entry in build_inventory_payload(player)} + potion = by_name["Health Potion"] + assert isinstance(potion, dict) + assert potion["kind"] == "potion" + assert potion["heal_dice"] == "2d4+2" + shield = by_name["Shield"] + assert isinstance(shield, dict) + assert shield["kind"] == "shield" + assert shield["ac_bonus"] == 2 + + def test_plain_item_has_no_props(self) -> None: + from dnd_simulator.core.items import ItemType + + plain = Item(id="rock_0", name="Rock", item_type=ItemType.WEAPON) + player = _player(inventory=[plain]) + assert build_inventory_payload(player)[0]["props"] is None + + +class TestMerchantChannel: + def test_flaming_longsword_props_via_merchant_awareness(self, catalog: dict[str, ItemContent]) -> None: + merchant = Npc( + id="merchant_1", + name="Gretta", + location_id="loc", + ability_scores=AbilityScores(), + max_hp=10, + current_hp=10, + ac=10, + role=NpcRole.MERCHANT, + gold=500, + inventory=[_resolve("flaming_longsword", catalog)], + ) + player = _creature() + entities: dict[str, object] = {merchant.id: merchant, player.id: player} + builder = AwarenessBuilder(entities, {}, CombatManager(entities, {})) # type: ignore[arg-type] + merchants = builder.build_merchants(player, hour=12) + assert len(merchants) == 1 + props = merchants[0].items[0].props + assert props is not None + assert props["kind"] == "weapon" + assert props["category"] == "martial" + assert props["modifier"] == 1 + assert props["is_magic"] is True + assert props["damage"] == [ + {"dice": "1d8", "type": "slashing"}, + {"dice": "1d6", "type": "fire"}, + ] + + +class TestEquippedChannel: + def test_ring_of_protection_props_in_equipped_payload(self, catalog: dict[str, ItemContent]) -> None: + ring = _resolve("ring_of_protection", catalog) + player = _player(equipped={EquipmentSlot.RING: ring}) + entry = next(e for e in build_equipped_payload(player) if e["slot"] == "ring") + props = entry["props"] + assert isinstance(props, dict) + assert props["kind"] == "accessory" + assert props["slot"] == "ring" + assert props["modifiers"] == [{"stat": "ac", "op": "add", "value": 1}] + + +class TestLootChannel: + def test_dead_creature_loot_items_carry_props(self, catalog: dict[str, ItemContent]) -> None: + corpse = _creature(inventory=[_resolve("longsword", catalog)]) + corpse.id = "corpse_1" + corpse.current_hp = 0 + observer = _creature() + entities: dict[str, object] = {corpse.id: corpse, observer.id: observer} + builder = AwarenessBuilder(entities, {}, CombatManager(entities, {})) # type: ignore[arg-type] + nearby = builder.build_nearby_entities(observer, hour=12) + loot = next(e for e in nearby if e.id == "corpse_1") + assert loot.loot_items, "corpse inventory should surface as loot" + props = loot.loot_items[0].props + assert props is not None + assert props["kind"] == "weapon" + assert props["damage"] == [{"dice": "1d8", "type": "slashing"}] + assert props["reach"] == 5 + + +class TestJsonSafety: + def test_every_catalog_entry_props_is_json_safe(self, catalog: dict[str, ItemContent]) -> None: + """Guards against enum leakage or new non-primitive fields in props. + + Merchant/loot payloads go through dataclasses.asdict without a blanket + _json_safe pass, so props must be pure primitives at build time. + """ + for stem in catalog: + item = _resolve(stem, catalog) + props = item_props(item) + json.dumps(props) # raises TypeError if anything non-primitive leaks in From 5892de4e45929c5dc8c401cde144510dfe363260 Mon Sep 17 00:00:00 2001 From: vladmesh <16962535+vladmesh@users.noreply.github.com> Date: Thu, 16 Jul 2026 12:37:52 +0300 Subject: [PATCH 13/19] sprint 024 phase 3 task 2: localized item details card in inventory and trade --- docs/STATUS.md | 8 +- .../phase3-task2-item-details-tooltip.md | 23 +- .../src/components/game/InventoryPanel.tsx | 38 ++-- frontend/src/components/game/ItemDetails.tsx | 118 ++++++++++ frontend/src/components/game/TradePanel.tsx | 25 ++- .../game/__tests__/ItemDetails.test.tsx | 211 ++++++++++++++++++ frontend/src/i18n/locales/en/game.json | 21 ++ frontend/src/i18n/locales/ru/game.json | 21 ++ frontend/src/types/game.ts | 55 +++++ 9 files changed, 483 insertions(+), 37 deletions(-) create mode 100644 frontend/src/components/game/ItemDetails.tsx create mode 100644 frontend/src/components/game/__tests__/ItemDetails.test.tsx diff --git a/docs/STATUS.md b/docs/STATUS.md index 6dc70c55..3ded48be 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -3,8 +3,8 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". **Last updated:** 2026-07-16 -**Position:** Sprint 024 (playtest-quick-wins): Phases 1-2 COMPLETE; Phase 3 (панель свойств предметов) — tasks generated (2 tasks). -**Next:** Начать Phase 3 task 1 (`/implement`). +**Position:** Sprint 024 (playtest-quick-wins): Phases 1-2 COMPLETE; Phase 3 (панель свойств предметов) — обе задачи done. +**Next:** Закрыть Phase 3 (`/close-phase`). **Blockers:** Нет. ## Current Sprint @@ -12,7 +12,9 @@ **Sprint:** 024-playtest-quick-wins **Goal:** Быстрые UX-победы из живой партии 2026-07-15 — боевое движение и чистота лога/боевого UI, полировка торговли и i18n снаряжения, панель свойств предметов **Started:** 2026-07-16 -**Phase:** 3 — Панель свойств предметов (task 1 done, task 2 pending) — 2026-07-16. +**Phase:** 3 — Панель свойств предметов (tasks 1-2 done) — 2026-07-16. + +Task 2 (карточка деталей предмета) done: компонент `ItemDetails` (CSS-hover, контент всегда в DOM) рендерит `props` локализованной карточкой во всех четырёх местах (инвентарь bag + слоты, торговец buy + sell), `title=`-атрибуты убраны, fallback на `description`. `ItemProps` discriminated union в `types/game.ts`, 21 новый i18n-ключ EN+RU (переиспользованы `dmg_*`/`slot_*`/`source_*`). Отклонение: карточка на `fixed` c auto-смещениями вместо `absolute` — все точки рендера внутри `overflow-y-auto`, absolute клипался бы. 8 новых компонентных тестов, включая регресс кликов unequip/buy. `make check` зелёный (backend 2573, frontend 299). Task 1 (структурный `props` в payload) done: `item_props()` + `props` на `ItemInfo`/`EquippedInfo`, хелпер `item_info()` схлопнул три дублированные сборки в `awareness_builder`; все четыре канала (инвентарь, экипировка, торговец, лут) несут машиночитаемые свойства из типизированных дефов. `equipped` расширен до `list[dict[str, object]]` в `PlayerStatusData` и `PlayerStatusResponse` (props — dict, старая str-типизация отбивала 422). 8 новых тестов на реальном резолве каталога + JSON-гарда по всем 31 записям. `make check` зелёный (backend 2573, frontend 291). diff --git a/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md index c2676623..226db0d2 100644 --- a/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md @@ -41,13 +41,22 @@ i18n (`frontend/src/i18n/locales/{en,ru}/game.json`): метки полей (у ## Acceptance Criteria -- [ ] Tests written and RED (before implementation) -- [ ] Implementation makes tests GREEN -- [ ] Existing tests still pass (`make check`) -- [ ] В магазине и инвентаре по предмету видно урон/КЗ/эффект до покупки/надевания, EN и RU без сырых английских строк -- [ ] Броня и щит впервые показывают свои свойства (раньше — только имя) -- [ ] Fallback на `description` для предметов без `props` +- [x] Tests written and RED (before implementation) +- [x] Implementation makes tests GREEN +- [x] Existing tests still pass (`make check`) +- [x] В магазине и инвентаре по предмету видно урон/КЗ/эффект до покупки/надевания, EN и RU без сырых английских строк +- [x] Броня и щит впервые показывают свои свойства (раньше — только имя) +- [x] Fallback на `description` для предметов без `props` ## Status -`pending` +`done` + +## Developer Notes + +- Все четыре точки рендера (`InventoryPanel` bag + слоты экипировки, `TradePanel` buy + sell) переведены с `title=` на `ItemDetails`; `title`-атрибуты убраны, подсказка «Нажмите, чтобы снять» переехала внутрь карточки (`hint`-проп). +- Отклонение от плана в позиционировании: план предполагал `absolute`, но все четыре места живут внутри `overflow-y-auto` контейнеров (и списки `max-h-32`, и сами колонки грида GameScreen), которые клипают absolute-потомков. Карточка использует `fixed` с auto-смещениями: статическая позиция якорит её под строкой предмета, а позиционирование от viewport выводит из-под клипа. Паттерн остаётся чистым CSS (`group/item` + `group-hover/item:visible`), контент всегда в DOM — тесты без симуляции hover. +- Для правой колонки грида слотов карточка сдвигается `-translate-x-1/2` (`align="right"`), чтобы не уезжать в соседнюю колонку. +- RU-терминология по существующей локали: «КД» (не «КЗ» из формулировки таска), досягаемость в фт. Переиспользованы существующие ключи `dmg_*` (все 13 типов урона уже были), `slot_*`, `source_str…cha`, `damage`, `advantage`/`disadvantage`; новых ключей 21 (метки полей, категории оружия/брони, флаги, статы). Цена в карточке — `{{n}}g` как в остальном UI. +- Щит и ADD-модификаторы аксессуаров рендерятся одним форматом `+N <стат>`; grant_conditions — `t("condition_", defaultValue)` без новых ключей, как и требовал план. +- 8 новых компонентных тестов (`ItemDetails.test.tsx`): RU/EN карточки для брони/оружия/кольца/зелья, fallback на description, регресс кликов unequip и buy (те же WS-payload). RED подтверждён до имплементации (6 fail — карточки; 2 клик-регресса зелёные исходно, оставлены как гварды). diff --git a/frontend/src/components/game/InventoryPanel.tsx b/frontend/src/components/game/InventoryPanel.tsx index 00f86ec5..45cb2c25 100644 --- a/frontend/src/components/game/InventoryPanel.tsx +++ b/frontend/src/components/game/InventoryPanel.tsx @@ -4,6 +4,7 @@ import { useGameStore } from "@/store/gameStore" import { wsClient } from "@/transport/wsClient" import { ChevronDown, ChevronRight, Package, Sword, Shield, Crown, Footprints, Gem, ShieldPlus } from "lucide-react" import type { EquippedInfo, ItemInfo } from "@/types/game" +import { ItemDetails } from "./ItemDetails" const SLOT_ORDER = ["weapon", "armor", "shield", "head", "feet", "ring"] as const const SLOT_ICONS: Record = { @@ -46,7 +47,7 @@ function getEquipSlot(item: ItemInfo): string | undefined { return undefined } -function EquipmentSlot({ slot, item }: { slot: string; item?: EquippedInfo }) { +function EquipmentSlot({ slot, item, align }: { slot: string; item?: EquippedInfo; align?: "left" | "right" }) { const { t } = useTranslation(["game"]) const Icon = SLOT_ICONS[slot] ?? Package const waitingForAction = useGameStore((s) => s.waitingForAction) @@ -55,14 +56,15 @@ function EquipmentSlot({ slot, item }: { slot: string; item?: EquippedInfo }) {
{item ? ( - + + + ) : ( {t(`game:slot_${slot}`, { defaultValue: slot })} @@ -88,12 +90,14 @@ function BagItem({ item }: { item: ItemInfo }) { return (
- - {item.name} - {item.price != null && ( - {item.price}g - )} - + + + {item.name} + {item.price != null && ( + {item.price}g + )} + + {isConsumable && (