diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md index f5076a6b..0a40357b 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -28,6 +28,7 @@ ## Gameplay - [x] `monster-spawn` — ~~Система спавна монстров: триггеры (proximity, time, event), таблицы встреч по региону/локации, CR-бюджет~~ FIXED Sprint 018: логова (`core/lair.py`, core-death depletion), региональные encounter-таблицы (region→location fallthrough), time-of-day гейт (day/night). CR-бюджет/авто-скейлинг сознательно отброшен (кенши-стиль). Event-триггер вынесен в `spawn-event-trigger` +- [ ] **should** `flee-scene-separation` — **нужен /grilling: архитектурный вопрос, не реализовывать до разбора.** «Побег» работает странно: после flee ты в той же локации и видишь тех же монстров, но они перестают тебя видеть. Причина: `resolve_flee` (`layers/entities/combat_resolution.py`) ставит `in_combat = False` и убирает существо из боя, но `location_id` не меняется, стороны боя распускаются. Локации крупнозернистые (вся область = один `location_id`), «та же локация, но разошлись» вне battle map не выражается. Вопросы для /grilling: должен ли побег перемещать в соседнюю локацию (покинуть сцену) или вводить внутрилокационную дистанцию/состояние «оторвался»; что перезапускает бой при co-location (перцепция/агро монстра, повторный ролл встречи); отношения `location_id` ↔ позиции на battle map ↔ понятие «сцена»; поведение монстров после побега (преследование, возврат в логово, сброс); связь с activation/anchors и encounter-таблицами. Живое доказательство flip-flop (сессия a0f0fed0, 2026-07-15): `flee` 20:52:44 → фронту «Бой окончен» + «Ты пытаешься сбежать», но бой на локации не кончился (волки остались стороной), раунд продолжил выдавать игроку боевой ход, `travel` 20:52:53 отбит «'travel' недоступно в бою»; тот же цикл повторился 20:54:36→47→51. Побег без ухода со сцены гарантирует мгновенное переформирование боя. Смежно с `intents` (travel как намерение), `combat-pathfinding-avoidance` и `combat-status-single-source` - [ ] **must** `quest-system` — Система квестов. НЕ отдельный движок: квест разлагается на типизированные цели + парные триггеры + награды-события ([simulation-core](brainstorms/simulation-core.md)). Планировать после `trigger-table` и `inner-self` (цели), иначе построим параллельную систему под снос. Минимум: fetch/kill/escort. Мир не прогибается: квест может быть уничтожен самим миром - [x] `key-npcs` — ~~Ключевые NPC (антагонист, компаньон): глубокая память, реакция на мировые события, персональные цели~~ ПОГЛОЩЁН simulation-core: это `always_active` + `inner-self` + `trigger-table`. После эпиков остаётся только контент (прописать самих NPC в YAML) - [ ] **should** `npc-wandering` — Динамические маршруты NPC между поселениями (сейчас только статичные расписания). Реализуется как travel-намерения у NPC — после `intents` @@ -35,10 +36,12 @@ - [ ] **should** `divine-sense` — Divine Sense (Paladin): detect celestial/fiend/undead. Требует `CreatureType` enum на Creature, creature_type в каталогах монстров, resource pool (1 + CHA mod / long rest) - [ ] **should** `divine-smite-scaling` — Divine Smite масштабирование: slot 2 → +3d8, +1d8 vs undead/fiend. Когда будет система уровней и `CreatureType` - [x] `combat-reassess` — ~~NPC переоценивает стратегию при смене ситуации (союзник упал, новый враг появился)~~ ПОГЛОЩЁН `llm-turn-plan`: reassess-триггеры иерархии командир/исполнитель +- [ ] **should** `combat-pathfinding-avoidance` — боевой RuleBrain тупит: поиск пути наивный («иди кратчайшим путём»), существа не умеют обходить друг друга и упираются в союзников/врагов на клетках, стоят на месте вместо сближения. Симптом виден в живой партии 2026-07-15 (goblin_cave, вожак + goblin_5): по 3 неудачных действия за ход, ход принудительно завершался предохранителем `consecutive_failures_end_turn` в `round.py`. Нужен pathfinding с обходом занятых клеток (шаг к цели по проходимому маршруту, не по прямой) и в целом более умный боевой псевдо-ИИ: перевыбор действия при заблокированном пути, разумное позиционирование, отступление. Смежно с `llm-turn-plan` (умный ход для LLM-мозгов), но это про RuleBrain и движение - [ ] **should** `versatile-weapons` — Versatile weapon property: переключение одноручный/двуручный хват, разный урон (longsword 1d8/1d10, warhammer 1d8/1d10, quarterstaff 1d6/1d8). WeaponDef.versatile_damage, автовыбор хвата по наличию щита -- [ ] **should** `hit-dice-short-rest` — Hit Dice spending на коротком отдыхе: ResourcePool(hit_dice, max=level, reset_on=LONG_REST), игрок выбирает сколько тратить, за каждую кость roll(class_hit_die)+CON_mod HP. Long rest восстанавливает max(1, level//2) костей (partial reset). Нужен PlayerBrain callback для выбора количества + UI +- [ ] **should** `hit-dice-short-rest` — Hit Dice spending на коротком отдыхе: ResourcePool(hit_dice, max=level, reset_on=LONG_REST), игрок выбирает сколько тратить, за каждую кость roll(class_hit_die)+CON_mod HP. Long rest восстанавливает max(1, level//2) костей (partial reset). Нужен PlayerBrain callback для выбора количества + UI. Подтверждено в живой партии 2026-07-15: короткий отдых не лечит и кости не восстанавливаются, потому что механики костей как ресурса нет вообще — `HIT_DICE` (`rules/character_creation.py`) используется только для расчёта HP при создании, пула `hit_dice` на `Creature.resource_pools` не существует, `handle_short_rest` (`rules/handlers/rest.py`) сбрасывает лишь SHORT_REST-пулы (`second_wind`/`action_surge`). NB по правилам: короткий отдых кости не восстанавливает, а тратит (лечение за них), восстанавливает половину именно длинный отдых, `reset_on=LONG_REST` уже это отражает - [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** `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 @@ -69,6 +72,9 @@ ## UX / World Builder - [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 скрыт +- [ ] **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 - [ ] **could** `mobile-layout` — Мобильная адаптация dashboard @@ -99,11 +105,16 @@ ## Performance +- [ ] **should** `faction-hostility-check-cost` — `check_faction_hostility(observer, other)` (`awareness_builder.py:425`) зовётся на каждую пару существ при пересборке awareness, а пересборка идёт после каждого действия раунда → O(N²) на ребилд, ребилдов много за бой. Замерено в живой партии 2026-07-15: этот один event — **70.8% всего backend-лога** (3610/5102 строк) при 11 волках. Две стороны: (1) **лог** — `logger.info("faction_hostility_check", …)` на каждую пару, чистый спам, топит сигнал и жрёт I/O; опустить до DEBUG или убрать. (2) **вычисление** — на каждую пару заново конструируется `make_relation_fn(query_fn)` (строка 433, лишняя аллокация N² раз) и `effective_relation` может делать кросс-слойный запрос в политику (faction-to-faction), то есть O(N²) запросов вниз по стеку на ребилд. Фикс: строить relation_fn один раз на ребилд (не на пару), мемоизировать faction-pair отношения на tick/ребилд. Часть общей проблемы `awareness-rebuild-cache` (WorldSnapshot per tick снимет и это) - [ ] **could** `awareness-rebuild-cache` — `build_awareness()` делает 4-5 query к нижним слоям на каждый ход каждого существа (O(N)/раунд), bottleneck при >20 LlmBrain NPC. Решение: WorldSnapshot per (region, tick) для weather/region/settlements/politics + dirty-flag per location для nearby entities. `llm-turn-plan` снизит частоту пересборок для LLM-мозгов; остаётся актуально для сцен. Делать когда начнёт тормозить ## Bugs - [ ] **should** `load-combat-round-resume` — загрузка сейва посреди боя: раунд-луп продолжает крутить ходы NPC сразу после load, до реконнекта игрока — UI после реконнекта показывает Round 3/4 вместо сохранённого Round 1; в логах `listener_error` в `WsEventListener.on_turn`. Сейв корректен (`turn_order`/`sides`/`round_number` на месте — sprint 021 phase 2 это починил), проблема в lifecycle: после load раунд должен стоять на паузе до подключения player listener (родня grace-period из `session-disconnect-debounce`). Repro: [e2e phase2-report](sprints/021-save-schema/e2e/phase2-report.md), Finding 1 +- [ ] **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** `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 - [ ] **should** `equip-in-combat-free` — Семейство экипировки слотов в `core/action_defs.py` (`EQUIP`/`UNEQUIP`, armor/shield/head/feet/ring) зарегистрировано как `cost_type=FREE` и без `combat_mode`, то есть доступно в бою через дефолт `ANY`. Enforcement сейчас идёт через `check_action_mode` и `check_budget` в `rules/validation.py`, поэтому броню, шлем, обувь и кольцо можно менять в бою бесплатно. Фикс: `EQUIP_ARMOR`/`UNEQUIP_ARMOR` и accessory-слоты сделать `PEACEFUL_ONLY`; `EQUIP_SHIELD`/`UNEQUIP_SHIELD` оставить в бою, но сделать `cost_type=ACTION`; оружейные `EQUIP`/`UNEQUIP` можно оставить `FREE` как object interaction. Синхронизировать `ends_peaceful_turn`: сейчас он стоит у оружия, но не у брони/щита/accessory @@ -116,6 +127,7 @@ - [x] `look-action-i18n-hardcode` — ~~`_cmd_look` в GameService хардкодит строки «Terrain:»/«Weather:» вместо `_()`~~ OBSOLETE Sprint 019 phase 3: `_cmd_look` удалён в раннем рефакторе, строк «Terrain:»/«Weather:» в `service/` нет (остались только устаревшие msgid в `.po`, помечены obsolete в phase 3 task 1) - [x] `player-xp-not-persisted`: ~~XP и `level_up_available` игрока не переживают save/reload через современный путь~~ FIXED Sprint 020 phase 1 task 1 (сериализационная половина): `experience`/`level_up_available` в `to_full_save_data()` + `PlayerContent`/`_to_player`, round-trip regression-тест. Dev-симптом с WS StrictMode evict→restore закрыт в `session-disconnect-debounce` (транспортная половина) - [ ] **could** `spawn-role-freetext-enum` — мастерский Spawn Creature диалог (`CreatureForm`) рендерит Role как свободный textbox, но бэкенд `NpcContent.role` — enum (`commoner`/`blacksmith`/`tavern_keeper`/`guard`/`merchant`/`farmer`/`gladiator`). Пустой/произвольный role → HTTP 400 с сырым Pydantic-сообщением прямо в диалоге (E2E sprint 019 phase 1). Сделать Role дропдауном `NpcRole` (и/или маппить ошибку в дружелюбный i18n-тост). Сосед `corpse-nearby-actions` по теме visible-gaps +- [ ] **should** `action-bar-equip-i18n` — кнопка «Экипировать» не переведена на русский (видно в живой партии 2026-07-15). Та же семья, что `action-bar-unequip-i18n`, но про надевание: метки/описания надевания приходят по 12 equip-`ActionType` (`equip`/`equip_armor`/`equip_shield`/`equip_head`/`equip_feet`/`equip_ring`), переведена только часть, кнопка показывает английское «Equip»/сырой ID. Фикс: добавить equip-метки в таблицу локализации фронта (`actionCategories.ts` / фронтовый label-map) и перевести описания в `.po` рядом с unequip. При будущем `equip-action-collapse` (12 ActionType → один `EQUIP`/`UNEQUIP` со `slot`) синхронизировать оба i18n-айтема одним PR - [ ] **should** `action-bar-unequip-i18n` — кнопки снятия экипировки в боевом action bar показывают сырые ID и английские описания (E2E sprint 020 phase 1). Оружие: метка «Снять» (RU ✓), но описание «Put away your equipped weapon. You will fight with fists.» (EN ✗). Броня/щит: метки `unequip_armor`/`unequip_shield` — сырые `ActionType`-строки (EN ✗), описания тоже английские. Только weapon-unequip переведён. Фикс: добавить `unequip_armor`/`unequip_shield` в таблицу локализации фронта рядом с `unequip`; перевести описания в `.po`. NB: Sprint 020 phase 3 task 6 сохранил 12 ActionType (реестр сделан бэкенд-internal, коллапс отложен в `equip-action-collapse`) — эти метки не меняются сейчас; при будущем коллапсе синхронизировать оба айтема одним PR - [ ] **could** `second-wind-zero-heal` — Second Wind показывает «восстанавливаешь 0 ОЗ» когда игрок уже при максимальных HP (E2E sprint 020 phase 1). Сообщение корректно с механической точки зрения (ресурс потрачен, лечение = 0), но выглядит как баг. Подавлять или заменять на «ты уже в полном здравии» когда `healed == 0`. - [x] `combat-log-i18n-gaps` — ~~при дефолтном `DND_LANGUAGE=ru` боевой лог наполовину английский~~ FIXED: movement-ошибки обёрнуты в `_()` Sprint 019 phase 3; остальные хендлеры (items/equipment/trade/action_surge/loot/combat) + прогон каталога и RU-перевод Sprint 020 phase 1 task 4. Остаточные фронтовые метки — в `action-bar-unequip-i18n`