diff --git a/.claude/skills/update-docs/state.json b/.claude/skills/update-docs/state.json index 610400bc..48a252d6 100644 --- a/.claude/skills/update-docs/state.json +++ b/.claude/skills/update-docs/state.json @@ -1,6 +1,6 @@ { - "last_run": "2026-07-14", + "last_run": "2026-07-16", "last_run_mode": "full", - "last_commit": "29db120", - "last_full_run": "2026-07-14" + "last_commit": "34a97a6", + "last_full_run": "2026-07-16" } diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b7c0e106..43367c9d 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -2,9 +2,9 @@ ## Overview -The simulator is built as a stack of simulation layers, each depending only on the layers below it. A World container manages global time and propagates events between layers. An LLM-powered Master (Dungeon Master) orchestrates everything — interpreting player actions, querying layers, advancing time, and composing narrative responses. +The simulator is built as a stack of simulation layers, each depending only on the layers below it. A World container manages global time and propagates events between layers. The `Round` orchestrator drives creature turns; each creature's brain (rule-based, LLM, or player) decides what it does. `master/` is a stub reserved for a future LLM Dungeon Master that would narrate and guide; nothing runs there yet. -The game logic is fully transport-agnostic: a `GameService` provides the interface, and thin adapters (CLI, REST API, Telegram) plug into it. +The game logic is fully transport-agnostic: a `GameService` provides the interface, and thin adapters plug into it. The FastAPI REST + WebSocket adapter is the only one implemented; CLI and Telegram remain possible without touching game logic. ## Layer Stack @@ -46,9 +46,9 @@ src/dnd_simulator/ │ ├── activation_manager.py — ActivationManager (proximity activation + encounter rolling, region/time-of-day) │ ├── query_handler.py — QueryHandler (layer query dispatch) │ └── perception.py — event perception and visibility filtering -├── master/ — DM orchestrator (LLM-powered) -├── rules/ — pure functions: D&D mechanics, combat/initiative, movement, validation (+ target scope), conditions, weapons, modifiers, proficiency, sneak attack, divine smite, fighting style, reactions, reputation, combat_sides, resources, character creation, leveling (XP/thresholds/perform_level_up), action providers, encounters (time-of-day gate), inventory (transfer_items), loot, abstract combat, physics, economics, RuleBrain (utility scoring) -│ └── handlers/ — per-action-type execution (combat, attack_resolution, movement, equipment, items, rest, trade, loot, reactions) +├── master/ — stub for a future LLM DM orchestrator (docstring only, no code) +├── rules/ — pure functions: D&D mechanics, combat/initiative, movement, validation (+ target scope), conditions, weapons, modifiers, proficiency, sneak attack, divine smite, fighting style, reactions, reputation, combat_sides, resources, character creation, leveling (XP/thresholds), perform_level_up, action providers, encounters (time-of-day gate), inventory (transfer_items), loot, trade, lairs, abstract combat, geography/politics/settlements formulas, dice, checks, RuleBrain (utility scoring) +│ └── handlers/ — per-action-type execution (combat, attack_resolution, action_surge, movement, equipment, items, rest, trade, loot, reactions, triggers) ├── llm/ — LLM client (with logging), LlmBrain, prompt builders (peaceful + combat), tool schemas, MemorySummarizer (layer-agnostic: only depends on core/ and rules/) ├── i18n.py — gettext internationalization, per-session language via contextvars ├── adapters/ — transport layer @@ -83,7 +83,9 @@ src/dnd_simulator/ │ ├── action_parsing.py — parse JSON action payloads into Action (ActionParseError); keeps adapters off core Action/ActionType │ ├── brain_factory.py — creates Brain instances from BrainType │ ├── base.py — GameServiceProtocol base shared by the command mixins -│ ├── dto.py — typed DTOs returned by service methods (PlayerStatusData, ResourcePoolView) +│ ├── dto.py — typed DTOs returned by service methods (PlayerStatusData, ResourcePoolView, JourneyView) +│ ├── transport_payloads.py — JSON-safe payload building for REST/WS +│ ├── contextual_providers.py, errors.py │ └── commands_creatures.py, commands_politics.py, commands_save.py, commands_time.py, commands_world_state.py └── round.py — Round orchestrator: multi-action turn loop with budget enforcement @@ -104,12 +106,13 @@ frontend/ — React + TypeScript SPA (Vite + shadcn/ui + Zustand) ├── src/components/game/ — GameScreen (dashboard: 3-col grid), EventLog (compact strip + expand overlay), │ BattleMap (interactive CSS Grid, click-to-move, click-to-inspect), │ ActionBar (orchestrator) + action-bar/ (ActionButton, SayAction, drawers, utils), -│ CombatPanel, NpcInspectModal, Perception, LocationPanel +│ CombatPanel, NpcInspectModal, Perception, LocationPanel, +│ InventoryPanel / TradePanel / LootPanel + ItemDetails (localized card from item props) ├── src/components/master/ — MasterScreen (Worlds/Sessions tabs), WorldEditor (layer stepper), │ EntityListEditor (schema-driven CRUD), SchemaForm, CatalogBrowser, │ SessionView (WorldOverview, CreatureList, TimeControl, SavesPanel) ├── src/store/ — Zustand store (slices: connection, player, turn, log) -├── src/transport/ — apiClient (REST), wsClient (WebSocket) +├── src/transport/ — requestCore + per-area REST clients (player, master session/world/content/creature), wsClient (WebSocket) └── src/i18n/ — i18next with EN/RU locale files ``` @@ -139,7 +142,7 @@ run_loop: run_round → on_round_end callback Player input flow (service/, command-based): - Player input → Adapter (CLI/API/TG) → GameService → response + Player input → Adapter (REST/WS) → GameService → response REST API flow (adapters/api/): FastAPI routes → GameService methods → JSON responses @@ -202,7 +205,9 @@ Combat is managed by `EntitiesLayer` through `CombatState` and `BattleMap` (defi **Turn order:** Initiative = d20 + DEX modifier, tiebreaker by DEX score. Order is fixed for the entire combat. Game loop iterates combatants in this order. -**Battle map:** Each `CombatState` owns a `BattleMap` — a 2D grid (coordinates in feet, 5-ft cells). Entities have `Position`s on the map. `Wall` segments block movement between adjacent cells. Perimeter walls auto-generated from map dimensions. Movement uses `rules/movement.py`: atomic direction + distance steps, D&D 5e alternating diagonal cost (5/10/5/…), wall collision. Dash is a self-buff action that adds speed to the movement pool. Abstract moves (toward/away from target) are resolved to concrete directions server-side. Budget is consumed only on a successful action, so a blocked move costs nothing. `move_to(x, y)` uses BFS pathfinding with D&D 5e diagonal costs and budget-aware stepping; player-only (excluded from LLM schemas via `provider_managed`). +**Battle map:** Each `CombatState` owns a `BattleMap` — a 2D grid (coordinates in feet, 5-ft cells). Entities have `Position`s on the map. `Wall` segments block movement between adjacent cells. Perimeter walls auto-generated from map dimensions. Movement uses `rules/movement.py`: atomic direction + distance steps, D&D 5e alternating diagonal cost (5/10/5/…), wall collision. Dash is a self-buff action that costs an action and adds speed to the movement pool. Abstract moves (toward/away from target) are resolved to concrete directions server-side. Budget is consumed only on a successful action, so a blocked move costs nothing. `move_to(x, y)` uses `compute_reachable` (Dijkstra with D&D 5e diagonal costs) and budget-aware stepping; player-only (excluded from LLM schemas via `provider_managed`). + +**Movement budget ownership:** `ActionDispatcher` charges action, bonus action and reaction costs; it never charges movement. `MOVE` and `MOVE_TO` are `CostType.FREE` in `core/action_defs.py`, and the handlers in `rules/handlers/movement.py` subtract the distance actually moved. `handle_move` prices the whole compass step up front and rejects it if the budget can't cover it, so a mover never lands where it can't pay; `handle_move_to` walks the path and charges what it spent, including when an OA kills the mover mid-path. `rules/validation.check_movement_available` only gates on having movement left. **Dual awareness:** Creatures in combat get a focused prompt (HP, weapon, nearby combatants with positions/distances, round number — no weather/time/politics). Peaceful creatures get full world awareness. Two separate tool sets: combat (attack/move/dodge/flee/idle, no say — use description for flavor) and peaceful (say/attack/idle). @@ -221,7 +226,9 @@ Combat is managed by `EntitiesLayer` through `CombatState` and `BattleMap` (defi **Conditions** (`core/conditions.py`): D&D 5e status effects as a `Condition` enum (Blinded, Poisoned, Prone, Stunned, etc. + Blessed). `ConditionsMap` = `dict[Condition, int | None]` — maps active conditions to remaining rounds (`int`) or permanent (`None`). Pure mechanics in `rules/conditions.py`: `is_incapacitated()`, `effective_speed()`, `attack_advantage()`, `tick_conditions()` (decrement/expire at turn start). -**Items** (`core/items.py`): `Item` dataclass with `ItemType` (WEAPON, POTION, ARMOR, SHIELD). Weapons carry a `WeaponDef` — attack name, damage components, reach, ability, magic bonus, finesse flag, `WeaponCategory` (simple/martial), and can grant passive conditions and bonus action types while equipped. Armor carries `ArmorDef` — base AC, DEX cap, `ArmorCategory` (light/medium/heavy). Shields carry `ShieldDef` — AC bonus. `rules/weapons.py`: `get_weapon_attack()` builds `Attack` from equipped weapon, falling back to `creature.attacks` or unarmed strike (1 bludgeoning). `rules/proficiency.py`: proficiency bonus by level, weapon/armor proficiency tables per class. `Creature.equipped_weapon`/`equipped_armor`/`equipped_shield` are the active equipment; inventory holds all items. Equip/unequip actions swap equipment from inventory. +**Items** (`core/items.py`): `Item` dataclass with `ItemType` (WEAPON, POTION, ARMOR, SHIELD, ACCESSORY) and an optional SRD `price`. Weapons carry a `WeaponDef` — attack name, damage components, reach, ability, magic bonus, finesse/two-handed/light/heavy flags, `WeaponCategory` (simple/martial), and can grant passive conditions and bonus action types while equipped. Armor carries `ArmorDef` — base AC, DEX cap, `ArmorCategory` (light/medium/heavy). Shields carry `ShieldDef` — AC bonus. Accessories carry `AccessoryDef` — `EquipmentSlot` (HEAD/FEET/RING) and `grant_modifiers` applied while equipped. `rules/weapons.py`: `get_weapon_attack()` builds `Attack` from equipped weapon, falling back to `creature.attacks` or unarmed strike (1 bludgeoning). `rules/proficiency.py`: proficiency bonus by level, weapon/armor proficiency tables per class. `Creature.equipped_weapon`/`equipped_armor`/`equipped_shield` are the active equipment; inventory holds all items. Slot-based equip/unequip handlers live in `rules/handlers/equipment.py`, where `SlotConfig` maps each slot to its item type, creature field, and action types. + +**Item views:** `core/awareness.py` builds the views brains and UI see. `describe_item()` renders a human-readable string; `item_props()` returns JSON-primitive structured properties (`kind`, damage, reach, category, flags, modifiers) or `None` for items with no typed def and no potion effect. Both `ItemInfo` (inventory/loot/merchant) and `EquippedInfo` carry `props`, built via `item_info()` in `layers/entities/awareness_builder.py`. Merchant and loot payloads go through `dataclasses.asdict` without a JSON pass, so `item_props()` values must stay primitives. The frontend renders localized labels from `props` instead of parsing the description text. ## Lairs, Encounters & Loot @@ -253,11 +260,11 @@ Composition-based class mechanics (`core/class_features.py`). Each D&D class get ## XP & Leveling -Pure functions in `rules/leveling.py`: `xp_for_kill(cr)` (D&D 5e Monster Manual table, CR 0→10 XP … CR 30→155k), `level_for_xp(xp)` / `xp_to_next_level(xp)` (PHB thresholds, L1=0, L2=300, L3=900, …), `can_level_up(xp, level)`. +Pure functions in `rules/leveling.py`: `xp_for_cr(cr)` (D&D 5e Monster Manual table, CR 0→10 XP … CR 30→155k), `level_for_xp(xp)` / `xp_to_next_level(xp)` (PHB thresholds, L1=0, L2=300, L3=900, …), `can_level_up(xp, level)`. **Grant on kill:** `CombatManager.resolve_attack` emits `XP_GAINED` and increments `Character.experience` for Character-class attackers when the target has non-zero `xp_value`. XP is omniscient (like reputation drops) — not perception-gated. Sets `Character.level_up_available` when crossing a threshold. -**Level-up operation:** `perform_level_up(character, fighting_style=None)` mutates the character in-place: bumps `level`, adds hit-die-average HP, unlocks class-specific features (Fighter Action Surge pool, Paladin L2 Fighting Style/Smite/spell slot, Rogue HP only). It is stateful by design (pinned by unit-test invariant) and reachable only through `GameService.level_up_player(session_id, fighting_style)` — adapters translate HTTP to the service call and never import the rule directly. The companion `GameService.player_status(session_id, player_id=None)` returns a typed `PlayerStatusData` DTO (`service/dto.py`) with derived fields (`xp_to_next_level`, effective AC, resource pools) plus the player's `equipped` items and `inventory`, which `routes_player._player_status` then maps to the REST response. +**Level-up operation:** `perform_level_up(character, fighting_style=None)` (in `rules/perform_level_up.py`) mutates the character in-place: bumps `level`, adds hit-die-average HP, unlocks class-specific features (Fighter Action Surge pool, Paladin L2 Fighting Style/Smite/spell slot, Rogue HP only). It is stateful by design (pinned by unit-test invariant) and reachable only through `GameService.level_up_player(session_id, fighting_style)` — adapters translate HTTP to the service call and never import the rule directly. The companion `GameService.player_status(session_id, player_id=None)` returns a typed `PlayerStatusData` DTO (`service/dto.py`) with derived fields (`xp_to_next_level`, effective AC, resource pools) plus the player's `equipped` items and `inventory`, which `routes_player._player_status` then maps to the REST response. **Transport:** `POST /api/player/sessions/{id}/level-up` (with optional `fighting_style` for Paladin L2) drives the modal. Player state payload (REST + WS) carries `experience`, `level`, `level_up_available`, `xp_to_next_level`. Frontend shows a Level Up button on the Character panel; `LevelUpModal` is class-switched (Fighter/Rogue confirm only, Paladin picks Fighting Style). diff --git a/CLAUDE.md b/CLAUDE.md index 4e0204fb..66e15e6b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,7 @@ service/ — GameService, ActionDispatcher, BrainFactory, command modu ↓ adapters/ — FastAPI REST + WebSocket API -rules/ — pure D&D mechanics: combat, validation, conditions, weapons, modifiers, proficiency, sneak attack, divine smite, fighting style, resources, character creation (point buy, HP, starting equipment), leveling (XP-by-CR, thresholds, perform_level_up), action providers, handlers/ package, reputation, combat_sides, encounters (time-of-day gate), inventory (transfer_items), loot, rule_brain (no deps) +rules/ — pure D&D mechanics: combat, validation, conditions, weapons, modifiers, proficiency, sneak attack, divine smite, fighting style, resources, character creation (point buy, HP, starting equipment), leveling (XP-by-CR, thresholds), perform_level_up, action providers, handlers/ package, reputation, combat_sides, encounters (time-of-day gate), inventory (transfer_items), loot, rule_brain (no deps) llm/ — LLM client, prompt builders, tool schemas (OpenRouter) storage/ — SaveStore interface, JsonFileStore, versioned save schema (SaveGame, schema_version=1, world seed + RNG state в сейве) content_loader/ — loads worlds, nations, settlements, NPCs, player from YAML; Pydantic content schemas, JSON Schema generation, entity CRUD, manifest resolver, library catalog, world assembly, catalog loader (monsters/items) @@ -103,7 +103,7 @@ frontend/ — React + TypeScript SPA (Vite, shadcn/ui, Zustand) ### Multi-Action Turns -Each creature's turn is a multi-action loop orchestrated by `Round` (in `round.py`). A `TurnBudget` (actions, bonus_actions, movement_remaining, reaction) lives on `Creature.turn_budget` — persists between turns for reaction spending. Created fresh at the start of each creature's turn. The brain is called repeatedly: choose action → `ActionDispatcher` validates (budget, target, reach via `rules/validation.py`) → executes handler (`rules/handlers/`) → rebuilds awareness → repeat, until the brain returns `end_turn` or budget is exhausted. `ActionProvider` (`rules/action_provider.py`) determines which actions are currently available to a creature. `PlayerBrain` uses a queue + callback pattern for interactive I/O. +Each creature's turn is a multi-action loop orchestrated by `Round` (in `round.py`). A `TurnBudget` (actions, bonus_actions, movement_remaining, reaction) lives on `Creature.turn_budget` — persists between turns for reaction spending. Created fresh at the start of each creature's turn. The brain is called repeatedly: choose action → `ActionDispatcher` validates (budget, target, reach via `rules/validation.py`) → executes handler (`rules/handlers/`) → rebuilds awareness → repeat, until the brain returns `end_turn` or budget is exhausted. The dispatcher owns action, bonus action and reaction costs only. Movement is charged inside `rules/handlers/movement.py` by the distance actually moved, so `MOVE` and `MOVE_TO` are `CostType.FREE` at the dispatcher. `ActionProvider` (`rules/action_provider.py`) determines which actions are currently available to a creature. `PlayerBrain` uses a queue + callback pattern for interactive I/O. ### Reactions & Opportunity Attacks @@ -111,9 +111,9 @@ D&D 5e reaction system. `Brain.choose_reaction(creature, trigger, available_reac ### Conditions & Items -D&D 5e conditions (`core/conditions.py`) — `Condition` enum + `ConditionsMap` (condition → remaining rounds or permanent). Pure mechanics in `rules/conditions.py`: `is_incapacitated()`, `effective_speed()`, `attack_advantage()`. Conditions tick down at turn start; weapons can grant permanent conditions while equipped. +D&D 5e conditions (`core/conditions.py`) — `Condition` enum + `ConditionsMap` (condition → remaining rounds or permanent). Pure mechanics in `rules/conditions.py`: `is_incapacitated()`, `tick_conditions()`; condition effects on stats go through the modifier pipeline (`rules/modifiers.py`). Conditions tick down at turn start; weapons can grant permanent conditions while equipped. -Items (`core/items.py`) — `Item` with `ItemType` (WEAPON, POTION, ARMOR, SHIELD, ACCESSORY). `WeaponDef` defines attack name, damage, reach, ability, magic bonus, finesse, category, two-handed, light, heavy, and can grant conditions/actions. `ArmorDef` defines base AC, DEX cap, armor category (light/medium/heavy). `ShieldDef` defines AC bonus. `AccessoryDef` defines slot (HEAD/FEET/RING) and `grant_modifiers` (stat modifiers while equipped). `EquipmentSlot` enum: WEAPON, ARMOR, SHIELD, HEAD, FEET, RING. `rules/weapons.py`: `get_weapon_attack()` builds `Attack` from equipped weapon or falls back to creature attacks / unarmed strike. `rules/proficiency.py`: weapon/armor proficiency per class, proficiency bonus by level. Generic slot-based equip/unequip handlers in `rules/handlers/equipment.py` — `SlotConfig` maps each slot to item type, creature field, and action types. Item catalogs (`content/catalogs/items/`) define SRD weapons (12) and armor (12 + shield) as reusable YAML entries resolved via `ref:` in entity definitions. +Items (`core/items.py`) — `Item` with `ItemType` (WEAPON, POTION, ARMOR, SHIELD, ACCESSORY). `WeaponDef` defines attack name, damage, reach, ability, magic bonus, finesse, category, two-handed, light, heavy, and can grant conditions/actions. `ArmorDef` defines base AC, DEX cap, armor category (light/medium/heavy). `ShieldDef` defines AC bonus. `AccessoryDef` defines slot (HEAD/FEET/RING) and `grant_modifiers` (stat modifiers while equipped). `EquipmentSlot` enum: WEAPON, ARMOR, SHIELD, HEAD, FEET, RING. `rules/weapons.py`: `get_weapon_attack()` builds `Attack` from equipped weapon or falls back to creature attacks / unarmed strike. `rules/proficiency.py`: weapon/armor proficiency per class, proficiency bonus by level. Generic slot-based equip/unequip handlers in `rules/handlers/equipment.py` — `SlotConfig` maps each slot to item type, creature field, and action types. Item catalogs (`content/catalogs/items/`) define SRD weapons, armor, shield, and accessories as reusable YAML entries with an SRD `price`, resolved via `ref:` in entity definitions. ### Class Features & Resources @@ -125,7 +125,7 @@ Action definitions (`core/action_defs.py`) — centralized `ActionDef` registry: ### XP & Leveling -`rules/leveling.py` — pure functions: `xp_for_kill(cr)` (D&D 5e MM table), `level_for_xp(xp)` / `xp_to_next_level(xp)` (PHB thresholds), `can_level_up(xp, level)`. On kill, the combat manager grants XP to `Character` attackers (`Creature.xp_value` on target, zero on other Characters), emits an `xp_gained` event, and updates `Character.level_up_available`. `perform_level_up(character, fighting_style=None)` (in `rules/leveling.py`) applies class-specific L2 deltas: `FighterFeatures` → Action Surge pool, `RogueFeatures` → HP bump only, `PaladinFeatures` → Fighting Style + Divine Smite + level-1 spell slots. The operation is stateful (mutates the character in-place) and is invoked only through `GameService.level_up_player` / `GameService.player_status` — adapters never call the rule directly. Level gates class features: `collect_self_modifiers` / `collect_attack_modifiers` check `level >= required_level`, so a L1 Paladin cannot use Fighting Style or Smite. +`rules/leveling.py` — pure functions: `xp_for_cr(cr)` (D&D 5e MM table), `level_for_xp(xp)` / `xp_to_next_level(xp)` (PHB thresholds), `can_level_up(xp, level)`. On kill, the combat manager grants XP to `Character` attackers (`Creature.xp_value` on target, zero on other Characters), emits an `xp_gained` event, and updates `Character.level_up_available`. `perform_level_up(character, fighting_style=None)` (in `rules/perform_level_up.py`) applies class-specific L2 deltas: `FighterFeatures` → Action Surge pool, `RogueFeatures` → HP bump only, `PaladinFeatures` → Fighting Style + Divine Smite + level-1 spell slots. The operation is stateful (mutates the character in-place) and is invoked only through `GameService.level_up_player` / `GameService.player_status` — adapters never call the rule directly. Level gates class features: `collect_self_modifiers` / `collect_attack_modifiers` check `level >= required_level`, so a L1 Paladin cannot use Fighting Style or Smite. ### Modifier Pipeline diff --git a/README.md b/README.md index 36b07b2f..ab9640b3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ Text RPG with multi-level LLM simulation, inspired by classic text adventures and powered by modern language models. -An LLM-powered Dungeon Master orchestrates a layered world simulation where each layer builds on top of the previous one — from geography and weather up to individual NPC conversations. +A layered world simulation where each layer builds on top of the previous one, from geography and weather up to individual NPC conversations. NPCs are driven by swappable brains: rule-based utility scoring, or an LLM. See [ARCHITECTURE.md](ARCHITECTURE.md) for details on how the system is structured. See [docs/VISION.md](docs/VISION.md) for product vision and [docs/ROADMAP.md](docs/ROADMAP.md) for current status and plans. @@ -16,6 +16,6 @@ make install # install Python dependencies make check # backend + frontend lint/typecheck/test (CI minus integration) make format # auto-format code make test # run tests only -make serve # start API server on :8001 (auto-builds frontend) -make frontend # start Vite dev server for frontend +make serve # start API server on :8001 +make frontend # start Vite dev server on :5173, proxies /api to :8001 ``` 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/BACKLOG.md b/docs/BACKLOG.md index 0a40357b..cee63d90 100644 --- a/docs/BACKLOG.md +++ b/docs/BACKLOG.md @@ -41,7 +41,9 @@ - [ ] **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` (цена — одно из свойств для отображения) +- [x] **should** `catalog-item-prices` — ~~стартовые предметы нельзя продать торговцу: `validate_sell` (`rules/trade.py`) отбивает продажу при `item.price is None`, а в каталоге `content/catalogs/items/` цена не задана ни у одного предмета~~ FIXED Sprint 024 phase 2 task 1: `price` проставлен во все 31 каталожную запись (PHB для мирского снаряжения, редкость DMG для магии). Правок кода не потребовалось — плумбинг уже нёс цену от каталога до `Item.price`. Регресс-гварда `TestCatalogPrices` + продажа снятого `chain_mail` через реальный резолв каталога; E2E: 75g SRD, gold 950→1025 +- [ ] **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`. Мелочь, докрутить перевод. Воспроизведено повторно E2E sprint 024 post-audit («Ser Aldric moved (10 ft)») - [ ] **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 @@ -72,8 +74,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 скрыт +- [x] **should** `item-properties-ui` — FIXED Sprint 024 phase 3: структурный `props` из типизированных дефов на `ItemInfo`/`EquippedInfo` по всем каналам + компонент `ItemDetails` (CSS-hover карточка, i18n EN+RU) в инвентаре (bag + слоты) и торговле (buy + sell). Остаток вынесен в `loot-panel-item-props` +- [ ] **should** `loot-panel-item-props` — LootPanel — пятая точка рендера предметов, пропущенная Sprint 024 phase 3: панель «Добыча» до сих пор вешает английский `describe_item()` в native `title` (`Frost Dagger (weapon: 1d4 piercing, reach 5ft [finesse, magic, +1])`) вместо карточки `ItemDetails` (E2E sprint 024 post-audit). Канал `props` в лут-payload уже есть (phase 3 task 1 покрыл все четыре канала включая лут) — осталось отрисовать: заменить `title=` на `ItemDetails` по образцу InventoryPanel/TradePanel +- [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 @@ -81,6 +84,7 @@ - [ ] **could** `log-filter-tabs` — Фильтрация лога табами (Все/Бой/Диалоги) - [x] **should** `attack-buttons-accessible-names` — кнопки Attack/Talk/Inspect в nearby-списке и в inspect-модалке получили target-aware `aria-label` по уникальному `entity.id` (тот же контракт, что у action-bar `TargetDropdown`); SmiteChoice тоже именует цель по id. Закрыто в Sprint 022 phase 4 task 3: EN/RU уникальность подтверждена браузерным E2E (три NPC на рынке → три различимых имени) + компонентные тесты. E2E sprint 021 close - [ ] **should** `master-panel-creature-inventory` — `CreatureResponse` / `all_entities` query не включают inventory/equipped_weapon; мастер не видит предметы существ. Добавить поля в схему и query +- [ ] **could** `master-player-brain-label` — в master-таблице существ колонка ИИ у игрока показывает `rule_based`, хотя реальный мозг — PlayerBrain (E2E sprint 024 post-audit). Показать `player` (и не давать toggle перещёлкивать мозг игрока, если это сейчас возможно) - [x] `master-give-item-ui` — ~~endpoint для give_item есть, кнопки нет~~ FIXED Sprint 007 phase 2: кнопка «Выдать предмет» в карточке существа - [x] `inspect-as-idle-param` — ~~inspect шёл как `Action(IDLE, {inspect_target})`~~ FIXED Sprint 009 phase 4: клиентская NpcInspectModal из awareness - [x] `world-builder-js-modules` — ~~world-builder.js 1700+ строк~~ OBSOLETE Sprint 008 phase 4: legacy vanilla JS заменён React SPA @@ -95,6 +99,7 @@ - [ ] **could** `control-interfaces-donor` — Донор-ветка `sprint/020-control-interfaces` и PR #15: identity/roles, три линзы и admin park реализованы против до-thermo control-plane. При планировании control-interfaces использовать как референс задач и тестов; raw merge невозможен из-за конфликтов с thermo-sweep. Grace-period и spectator уже перенесены отдельно - [x] **should** `wait-no-fastforward-with-npc` — FIXED Sprint 022 phase 2: intent делает якорь dormant, соседний RuleBrain NPC больше не удерживает сцену, мир быстро доходит до ближайшей wake-точки - [ ] **could** `saved-session-accumulation` — тест-гигиена закрыта Sprint 021 phase 3 (интеграционный стек чистит saves/ через session fixture); остаётся UX-половина (пагинация/фильтр/TTL в Sessions-вкладке). Master → Sessions грузит ВСЕ сохранённые сессии без пагинации/очистки; за прогоны integration-тестов в общий `saves/` накопилось ~900 сессий (E2E sprint 020 phase 2), вкладка Sessions раздувается, ручной поиск конкретной сессии непрактичен (снимок дерева перевалил за токен-лимит). Две стороны: (1) тест-гигиена — integration-тесты не чистят созданные сейв-сессии в `saves/`; (2) UX/масштаб — в списке нет пагинации/фильтра/TTL. Мин. фикс: чистка `saves/` в teardown интеграционных тестов; долгий — пагинация + фильтр в Sessions-вкладке +- [ ] **should** `round-stop-timeout-on-disconnect` — при закрытии вкладки игрока в мирном режиме первый `stop_round` на disconnect упёрся в 5s-таймаут (`DND_ROUND_STOP_TIMEOUT_SECONDS`): в логах `stop_round_timeout` + `disconnect_stop_failed` уровня error (E2E sprint 024 post-audit, 16:19:25). Grace-period eviction через 1.5s остановил round-тред штатно (`round_end`), сессия осталась загружаемой — самоизлечилось, но выглядит так, что stop не будит поток, ждущий действия игрока (PlayerBrain queue), и до eviction-прохода тред висит. Проверить: stop_round должен прерывать ожидание player input. Бонус-находка там же: `exc_info: true` не рендерится в JSON-лог — стек `disconnect_stop_failed` потерян, structlog-процессор не форматирует исключения. Смежно с `session-disconnect-debounce` (тот же lifecycle) - [x] **should** `session-disconnect-debounce`: FIXED 2026-07-10. При уходе последнего player listener `GameSession` **сразу** ставит раунд на паузу (`stop_round`), а откладывает только выселение из реестра: ставится grace-period timer (default 1.5s, `DND_EVICT_GRACE_SECONDS`), reconnect в окне отменяет timer, а вернувшийся игрок перезапускает раунд через `start_round`. Раньше откладывались И stop_round, И evict — из-за этого player-less раунд-луп продолжал крутить ходы NPC в grace-окне (при сетевом блипе игрок молча терял боевые ходы), а поскольку все сессии тянут один процесс-глобальный RNG костей, пересекающиеся «осиротевшие» раунды делали seed-зависимые integration-тесты недетерминированными (`test_player_state_xp::test_rest_status_updated_after_kill` флакал в CI). Spectator listeners не держат сессию живой и не запускают round lifecycle. WS arena tests переведены на fresh session per test, поэтому больше не зависят от evict-reset и не накапливают arena combat до `game_over`. ## DevOps / Infra @@ -105,7 +110,7 @@ ## 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 снимет и это) +- [x] **should** `faction-hostility-check-cost` — ~~`check_faction_hostility` зовётся на каждую пару существ при пересборке awareness: `logger.info("faction_hostility_check", …)` на пару дал 70.8% всего backend-лога живой партии 2026-07-15 (3610/5102 строк при 11 волках), плюс `make_relation_fn` конструировался заново на каждую пару~~ FIXED Sprint 024 phase 1 task 2: `faction_hostility_check` INFO→DEBUG, `relation_fn` строится один раз на ребилд, hostility вынесен в `_hostility_from_relation(relation_fn)`, регресс-гварда `test_awareness_relation_fn_once.py`. Мёртвая публичная обёртка `check_faction_hostility` снесена в аудит-триаже. Post-audit E2E: в `full.jsonl` только DEBUG-записи. Остаток — кросс-слойный запрос faction-to-faction на пару (мемоизация на tick/ребилд) — покрыт `awareness-rebuild-cache` - [ ] **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 @@ -113,9 +118,12 @@ - [ ] **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] **should** `combat-move-budget-not-consumed` — ~~бюджет движения в бою не списывается, кайтинг бесконечен~~ FIXED Sprint 024 phase 1 task 1, с переформулировкой: исходная премиса неверна, бюджет списывал диспетчер (`MOVE` = `cost_type=MOVEMENT`, `TurnBudget.consume` после хендлера), speed=30 через реальный диспетчер проходил ровно 30ft. Реальной проблемой был раздвоенный учёт — `MOVE` по запросу в диспетчере против `MOVE_TO`/`DASH` по факту в хендлере — с латентными багами диагонали (плоские 5 вместо 5/10) и частичного шага (списывался запрос, не пройденное). Ось движения унифицирована: `MOVE`→`cost_type=FREE`, `handle_move` списывает фактический `moved_ft` атомарно (reject целиком при нехватке), `CostType.MOVEMENT` удалён, `handle_move_to` различает «нет пути» и «не хватает движения», остаток движения и достижимость целей ушли в LLM-промпт. Инвариант: диспетчер владеет осью действий, `movement_remaining` — только movement-хендлеры. E2E: click-to-move списал 25 ft атомарно (30→5фт) +- [x] **should** `npc-action-errors-leak-to-log` — ~~ошибки неудачных ходов НПС попадают в боевой лог игрока («Туда не пройти, путь заблокирован» от волка): `on_action` в `service/session.py` клал `msg["error"]` без фильтра по актору~~ FIXED Sprint 024 phase 1 task 2: сборка payload вынесена в `build_action_result` (`service/transport_payloads.py`), `error` и `budget` проставляются только когда актор — игрок. Post-audit E2E: свои отказы в логе видны, чужих технических нет +- [ ] **could** `ui-language-mixing` — в английском UI серверные строки идут по `DND_LANGUAGE=ru`: combat log «Бой начался», «КЗ», «промах», тип NPC «человек» при английском shell UI. Язык клиента и сервера не согласован (сервер берёт env, клиент — свой). Сосед `npc-language`. E2E sprint 021 phase 2. Дополнено sprint 024: клиентское «КД» против серверного «КЗ» в одном экране (панель боя vs лог), «+2 str» в damage-breakdown при русской строке +- [ ] **should** `nearby-shows-raw-id` — панель «Поблизости» и aria-кнопки показывают raw entity id (`marta`, `gretta`, `aldric`) вместо display name («Гретта Торговка», «Сэр Олдрик» — имена есть: master-таблица и inspect-модалка их показывают), E2E sprint 024 post-audit. При этом лог событий корректно анонимизирует незнакомца до расы («человек говорит») — то есть панель ещё и подтекает мимо perceive(): игрок видит внутренний id существа, которого «не знает». Решить, что игрок должен видеть (display name или анонимную расу), и убрать id из player-facing payload +- [ ] **could** `ru-duplicate-condition-label` — в форме редактирования существа (master) две кнопки состояний с одинаковой меткой «Оглушён»: Stunned и Deafened переведены одним словом (E2E sprint 024 post-audit). Deafened → «Оглохший»/«Глухота» в `.po` +- [ ] **could** `combat-panel-weapon-summary-truncated` — строка «Оружие» в панели боя показывает только основной дайс («flaming slash (1d8)») без дополнительных компонентов урона (1d6 огненный), E2E sprint 024 post-audit. Карточка предмета и лог урона полные — сократили только тут; дорисовать полный damage-набор из `props` - [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 - [ ] **could** `take-action-cost-vestigial` — `ActionType.TAKE` (`core/action_defs.py`) объявлен `cost_type=ACTION`, но `combat_mode=PEACEFUL_ONLY`. В мирном ходу бюджета нет, `check_budget` в `rules/validation.py` сразу возвращает `None`, поэтому ACTION-стоимость никогда не списывается. Либо лут должен быть доступен в бою и тогда ACTION начнёт работать, либо стоимость надо снять как вестигиальную @@ -127,9 +135,10 @@ - [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] **should** `action-bar-equip-i18n` — ~~кнопка «Экипировать» не переведена: метки надевания по 12 equip-`ActionType` переведены частично, кнопка показывает английское «Equip»/сырой ID~~ FIXED Sprint 024 phase 2 task 2: 10 slot-меток (`equip_armor`…`unequip_ring`) и короткий `use` в `en`/`ru` `game.json`, хардкод `USE`/`EQUIP` в `InventoryPanel` уведён в `t()`, 12 RU-описаний equip/unequip дописаны в `.po` руками (`make messages` не годится: гонит `pygettext --keyword=_` без `N_` и падает на f-строках). Правка аддитивная, `equip-action-collapse` не тронут +- [x] **should** `action-bar-unequip-i18n` — ~~кнопки снятия экипировки показывают сырые ID (`unequip_armor`/`unequip_shield`) и английские описания, переведён только weapon-unequip~~ FIXED Sprint 024 phase 2 task 2 одной правкой с `action-bar-equip-i18n`: slot-метки в `game.json` + RU-описания в `.po`. E2E: Надеть/Убрать/Экипировать/Использовать и slot-метки Броня/Голова/Ноги/Кольцо/Сумка по-русски. При будущем `equip-action-collapse` метки перенести на новый контракт +- [x] **could** `second-wind-zero-heal` — ~~Second Wind на полном HP пишет «восстановлено 0 ОЗ»~~ FIXED Sprint 024 phase 1 task 3: `_perceive_second_wind` получил ветку `healed == 0` (self/other) с сообщением о полном здоровье, ненулевой путь не тронут, EN-строки + RU-перевод. Post-audit E2E подтвердил и нулевой, и ненулевой путь (3→8 HP). Остаток по use_item вынесен в `use-item-zero-heal` +- [ ] **could** `use-item-zero-heal` — тот же паттерн, что закрыт для Second Wind в Sprint 024 phase 1 task 3, но в перцепторе use_item: зелье лечения на полном HP пишет «Ты используешь Зелье лечения (восстановлено 0 HP)» (E2E sprint 024 post-audit), причём «HP» латиницей при «ОЗ» в остальных строках. Прогнать ту же ветку `healed == 0` для потиона и проверить прочие лечащие перцепторы (Lay on Hands клампится, но сообщение при 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` - [x] `sneak-attack-faction-check` — ~~SA ally-adjacency считала союзником любое живое существо в 5ft без учёта фракции~~ FIXED Sprint 011/014: ally detection через faction relations - [x] `flaky-initiative-test` — ~~`test_second_attack_does_not_reroll_initiative` падал рандомно~~ FIXED: AC=30 чтобы атаки всегда мазали, c2 не удаляется из turn_order @@ -139,7 +148,7 @@ ## Tech Debt (from audits 2026-03-25, updated 2026-03-29) -- [ ] **could** `equip-action-collapse` — схлопнуть 12 экипировочных ActionType (`equip`/`unequip`/`equip_armor`/… ×6 слотов) в один `EQUIP`/`UNEQUIP` со `slot`-параметром. Sprint 020 phase 3 task 6 сделал бэкенд-часть реестра (модель `Creature.equipped: dict[EquipmentSlot, Item]` + compat-свойства, фабричные хендлеры, `action_defs` циклом), но 12 ActionType и wire-контракт СОХРАНЕНЫ — полный коллапс отложен, т.к. это скоординированное изменение **бэк + фронт + wire + i18n**: фронт (`InventoryPanel.tsx` per-slot дифф `equip_armor`/`unequip_shield`/…, `actionCategories.ts`), wire-схема действий, `.po` описания. Делать одним PR через все три поверхности. Разблокирует `action-bar-unequip-i18n` (те же ActionType-метки) +- [ ] **could** `equip-action-collapse` — схлопнуть 12 экипировочных ActionType (`equip`/`unequip`/`equip_armor`/… ×6 слотов) в один `EQUIP`/`UNEQUIP` со `slot`-параметром. Sprint 020 phase 3 task 6 сделал бэкенд-часть реестра (модель `Creature.equipped: dict[EquipmentSlot, Item]` + compat-свойства, фабричные хендлеры, `action_defs` циклом), но 12 ActionType и wire-контракт СОХРАНЕНЫ — полный коллапс отложен, т.к. это скоординированное изменение **бэк + фронт + wire + i18n**: фронт (`InventoryPanel.tsx` per-slot дифф `equip_armor`/`unequip_shield`/…, `actionCategories.ts`), wire-схема действий, `.po` описания. Делать одним PR через все три поверхности. NB: i18n меток закрыт аддитивно в Sprint 024 phase 2 (`action-bar-equip-i18n`/`action-bar-unequip-i18n`) поверх 12 ActionType — при коллапсе метки и `.po`-описания перенести на новый контракт - [x] `god-class-entities` — ~~EntitiesLayer 1215 строк~~ FIXED Sprint 005: extracted awareness_builder, activation_manager, query_handler, combat_manager, perception - [x] `god-class-game-service` — ~~GameService 1044 строки, растёт~~ FIXED Sprint 019 phases 2-3: раздроблен 1044 → 357 строк (`WorldBuilderCommands` + `PlayerCommands` mixins, тонкий фасад над `commands_*`). Больше не god-class (verified audit 2026-06-29) - [x] `god-class-politics` — ~~PoliticsLayer 609 строк~~ FIXED Sprint 014 phase 0: split into diplomacy.py, warfare.py, economy.py submodules diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 5100b000..93aaa955 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -135,6 +135,10 @@ Paladin L1-L2 как первый caster-класс. Phase 1: spell slots как Третий эпик simulation-core. События получили строгие immutable payload-контракты, а YAML-триггеры `{on, until}` индексируются по `EventType`, активируют и гасят существ, переживают save/load и дополняются persistent GM override. Смерть materialized lair core сразу делает ecology-логово terminal depleted и не создаёт второй roster после save/load/reconnect. Post-audit refactor разделил event runtime, perception и transport; final audit triaged без sprint blockers, integration: 163/163, E2E зелёный. → [план спринта](sprints/023-trigger-table/sprint.md) +### Sprint 024 — Playtest Quick Wins (фазы 1-3) +UX-спринт по запросу оператора: сливки с кластера находок живой партии 2026-07-15, эпик simulation-core не двигает. Phase 1: единый учёт бюджета движения (`MOVE`→FREE, `handle_move` списывает фактический `moved_ft` атомарно, `CostType.MOVEMENT` удалён; премиса «бюджет не тратится» при разведке оказалась неверной, чинили раздвоенный учёт), чужие технические отказы и бюджет не текут в лог игрока (`build_action_result`), `faction_hostility_check` INFO→DEBUG и `relation_fn` один раз на ребилд, Second Wind на полном здоровье не пишет «0 ОЗ». Phase 2: SRD-цены в 31 каталожной записи (стартовое снаряжение продаётся торговцу), i18n кнопок и описаний надеть/снять. Phase 3: машиночитаемый `props` из типизированных дефов по всем четырём player-facing каналам + карточка `ItemDetails` (EN+RU) в инвентаре и торговле. Integration 166, post-audit E2E 26/26, аудит без блокеров. +→ [план спринта](sprints/024-playtest-quick-wins/sprint.md) + ## Planned ### Level 2 — Расходуемые ресурсы diff --git a/docs/STATUS.md b/docs/STATUS.md index 824905d9..1b48fd71 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -2,10 +2,11 @@ Текущее состояние проекта. Один файл — быстрый ответ на "где мы сейчас". -**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) закрыт: кластер находок живой партии 2026-07-15 снят, эпик simulation-core не двигался. Следующий шаг по ROADMAP — вернуться к simulation-core (Level 2 «Расходуемые ресурсы») либо добить боевой кластер. +**Next:** Активного спринта нет. Кандидаты из BACKLOG (`must`/`should`): `ac-stale-on-unequip` (снятие брони повышает КЗ), `combat-status-single-source` (зонтик над `rest-in-combat-not-rejected`/`flee-scene-separation`), `load-combat-round-resume`, `hit-dice-short-rest`. **Blockers:** Нет. +**Closure:** Ждём завершения CI для `sprint/024-playtest-quick-wins`. ## Current Sprint @@ -25,6 +26,7 @@ No active sprint. | Sprint | Goal | Started | Completed | |--------|------|---------|-----------| +| 024-playtest-quick-wins | Быстрые UX-победы из живой партии 2026-07-15: боевое движение и чистота лога, полировка торговли и i18n снаряжения, панель свойств предметов | 2026-07-16 | 2026-07-16 | | 023-trigger-table | Парные триггеры `{on, until}` на типизированной таксономии событий; событийный write-back смертей логова | 2026-07-12 | 2026-07-14 | | 022-intents-travel | Player-agnostic якоря и сохраняемые wait/sleep/travel intent; travel по рёбрам; согласованный lifecycle save/load/autosave | 2026-07-10 | 2026-07-12 | | 021-save-schema | Версионированная Pydantic-схема сейва (schema_version=1, RNG в сейве, combat sides), воспроизводимость мира от DND_WORLD_SEED, периодический автосейв | 2026-07-10 | 2026-07-10 | diff --git a/docs/audit.md b/docs/audit.md index aa83badc..d8b88920 100644 --- a/docs/audit.md +++ b/docs/audit.md @@ -1,14 +1,14 @@ # Code Audit -> **Date**: 2026-07-14 -> **Scope**: full codebase, final audit after Sprint 023 Phase 8 (`c820d3c`) +> **Date**: 2026-07-16 +> **Scope**: full codebase, post-Sprint 024 (playtest-quick-wins, `f74aa18`) > Transient snapshot. Canonical tracking lives in [BACKLOG.md](BACKLOG.md); known backlog items are marked `known` and should not be duplicated during triage. ## Summary -- Dead code: 0 issues -- Code smells: 3 issues +- Dead code: 1 issue +- Code smells: 4 issues - Security: 4 issues - Architecture violations: 0 issues - Convention violations: 4 issues @@ -16,66 +16,71 @@ - Test gaps: 0 issues - Vision drift: 0 issues -**Total: 11 issues.** The Phase 8 Paladin E2E alignment, level-up modal repair, and lair-core lifecycle fix introduce no architecture, transport, or test-coverage finding. No sprint blocker was found; the remaining items are known security, maintainability, and style debt. +**Total: 13 issues.** Sprint 024 (movement-budget unification, log gating, item props payload + details card, i18n) introduces no architecture, security, or test-coverage regression. Two new low-severity maintainability findings; the rest is carried-forward known debt. No sprint blocker. ## Dead Code | File | Issue | Action | |------|-------|--------| -| none | `uv run ruff check src/ --select F401` is clean. No `TODO/FIXME/HACK/XXX` remains in `src/`. The new event-runtime, transport, WS-envelope, autosave, and lair-death write-back paths have production callers. | none | +| `layers/entities/awareness_builder.py:404` | `new, FIXED in triage`: `check_faction_hostility(query_fn)` lost its last production caller in the sprint 024 relation-fn refactor — both awareness paths now call `_hostility_from_relation` with a prebuilt callback. The public wrapper was exercised only by tests. | fixed 2026-07-16: wrapper removed; tests repointed via a `_check_hostility` helper that mirrors the production path | + +`uv run ruff check src/ --select F401` is clean; no `TODO/FIXME/HACK/XXX` in `src/`. New sprint 024 helpers (`item_props`, `item_info`, `build_action_result`, `ItemDetails`) all have production callers. ## Code Smells | File | Issue | Suggestion | |------|-------|------------| -| `src/dnd_simulator/service/session.py` (502 lines) | `known, improved`: Phase 5 extracted transport payload builders and reduced the module from 741 lines, but it still owns round lifecycle, listener dispatch, save snapshots, and three locking domains. | Keep `test-gap-session`; extract a lifecycle/locking collaborator before another session-control feature. | -| `src/dnd_simulator/layers/entities/layer.py` (568 lines), `layers/entities/perception.py` (585 lines), `round.py` (574 lines), `core/action_defs.py` (564 lines), `service/commands_worldbuilder.py` (535 lines) | `known, improved`: trigger runtime, event logging, and world perception moved out, but these facades remain over the audit threshold. | Keep `entities-layer-regrowth`, `perception-fail-fast`, `round-growing`, and `action-defs-growing`; extract only along an existing responsibility boundary. | -| `frontend/src/components/game/EventLog.tsx:244`, `frontend/src/components/master/SchemaForm.tsx:62` | `known`: two `eslint-disable react-hooks/exhaustive-deps` suppressions remain. | Keep `event-log-eslint-suppress` and `schema-form-eslint-suppress`; remove only with focused effect tests. | +| `src/dnd_simulator/service/session.py` (498 lines) | `known, improved`: `on_action` payload assembly moved to `build_action_result` (transport_payloads), dropping the module below 500 lines, but it still owns round lifecycle, listener dispatch, save snapshots, and three locking domains. | Keep `test-gap-session`; extract a lifecycle/locking collaborator before another session-control feature. | +| `layers/entities/perception.py` (591 lines), `round.py` (574), `layers/entities/layer.py` (568), `core/action_defs.py` (565), `service/commands_worldbuilder.py` (535) | `known`: facades over the 400-line audit threshold; perception grew +6 lines (second-wind zero-heal branch). | Keep `entities-layer-regrowth`, `perception-fail-fast`, `round-growing`, `action-defs-growing`; extract only along an existing responsibility boundary. | +| `service/session.py:31` | `new, low, FIXED in triage`: imported underscore-private `_reaction_to_dict` from `transport_payloads` across a module boundary. | fixed 2026-07-16: renamed to public `reaction_to_dict`. | +| `frontend/src/components/game/EventLog.tsx:244`, `frontend/src/components/master/SchemaForm.tsx:62` | `known`: two `eslint-disable react-hooks/exhaustive-deps` suppressions remain. | Keep `event-log-eslint-suppress`, `schema-form-eslint-suppress`; remove only with focused effect tests. | ## Security | File:Line | Issue | Severity | |-----------|-------|----------| -| `src/dnd_simulator/adapters/api/app.py:151-157` | `known`: CORS origins are configurable, but the default origins, methods, and headers are wildcard. Acceptable for local development, unsafe for non-local deployment. | low | -| `src/dnd_simulator/adapters/api/routes_ws.py:160-166` | `known`: WS origin validation is optional and disabled by default. Session IDs remain the only access handle. | medium | -| `src/dnd_simulator/adapters/api/schemas.py:69-103` | `known`: item creation fields including `price`, `reach`, `base_ac`, `max_dex_bonus`, `strength_req`, and `ac_bonus` lack bounds. | low | -| `src/dnd_simulator/adapters/api/app.py:174-177` | `known`: `/api/frontend-error` accepts arbitrary JSON without a Pydantic schema or size limit. | low | +| `adapters/api/app.py:151-157` | `known` (`cors-wildcard`): CORS origins are configurable, but `allow_methods`/`allow_headers` stay wildcard. Acceptable for local dev, unsafe for non-local deployment. | low | +| `adapters/api/routes_ws.py:160-166` | `known` (`ws-origin-optional`): WS origin validation is optional and disabled by default; session IDs remain the only access handle. | medium | +| `adapters/api/schemas.py:69-103` | `known` (`item-create-bounds`): item creation fields (`price`, `reach`, `base_ac`, `max_dex_bonus`, `strength_req`, `ac_bonus`) lack bounds. | low | +| `adapters/api/app.py:174-177` | `known` (`frontend-error-endpoint`): `/api/frontend-error` accepts arbitrary JSON without a schema or size limit. | low | -Verified clean or hardened: no hardcoded secrets, `.env` is gitignored, no subprocess calls, no `dangerouslySetInnerHTML`, and WS input has token-bucket rate limiting. Player and spectator WS paths now reject syntactically valid non-object JSON with a recoverable protocol error. +Verified clean or hardened: no hardcoded secrets, `.env` gitignored, no subprocess calls, no `dangerouslySetInnerHTML` (the new `ItemDetails` card renders plain JSX text), WS input keeps token-bucket rate limiting on both player and spectator paths. The new `props` payload is built server-side from typed defs (enums → `.value`), no user-controlled strings. ## Architecture Violations | File:Line | Violation | Should Be | Severity | |-----------|-----------|-----------|----------| -| none | No imports cross between distinct simulation layers. `core/` imports neither layers nor service/round/adapters; `rules/` has no I/O or upward dependencies; adapters do not import layers directly. | none | none | +| none | No imports cross between distinct simulation layers (only same-package and `layers/common/rng_state`). `core/` imports neither layers nor service/round/adapters; `rules/` has no I/O or upward imports; adapters import only core enums/schemas and go through the service. `LlmClient` is constructed only in `adapters/api/app.py`. | none | none | -The Phase 5 extractions retain the dependency flow: typed event payloads stay in `core`, entity runtime collaborators remain within the entities layer, and WS handling delegates game state to `GameSession`. +Sprint 024 respects the flow: `item_props`/`item_info` live in `core/awareness.py` beside the dataclasses they build; movement-budget accounting stayed in `rules/handlers/movement.py` (handler-owned, dispatcher keeps MOVE as FREE); player-only gating of `error`/`budget` lives in `service/transport_payloads.build_action_result`, not in the adapter. ## Convention Violations | File:Line | Violation | Rule | |-----------|-----------|------| -| `src/dnd_simulator/storage/save_schema.py:41`, `layers/*/state.py`, `layers/entities/save_models.py:304`, `layers/common/rng_state.py` | `known, accepted`: RNG state uses `list[Any]` because `random.Random.getstate()` has an implementation-defined nested shape. | Prefer `object` over `Any`; keep the exception localized unless a typed RNG-state codec is introduced. | -| `src/dnd_simulator/content_loader/monsters.py:129`, `content_loader/refs.py:62` | `known`: raw-YAML and dynamic-model helpers use `Any`. | Existing `any-to-object-sweep` / `any-encounter-entries`. | -| `src/dnd_simulator/core/events.py:20-23` | `new`: generic dataclass encoding uses `Any` and `cast(Any, value)` in the typed event codec. | Replace the dynamic bridge with an object-safe dataclass encoder or document a narrow exception. | -| `src/dnd_simulator/service/session.py`, `round.py`, `core/character.py`, `core/combat.py`, `core/resource.py`, layer model files | `known, accepted`: mutable dataclasses model explicit runtime state. | Use `frozen=True` for value objects; document stateful exceptions. | +| `storage/save_schema.py:41`, `layers/*/state.py`, `layers/entities/save_models.py:304`, `layers/common/rng_state.py` | `known, accepted`: RNG state uses `list[Any]` — `random.Random.getstate()` has an implementation-defined nested shape. | Prefer `object` over `Any`; keep the exception localized. | +| `content_loader/monsters.py:129`, `content_loader/refs.py:62`, `service/*` (`dict[str, Any]` transport payloads) | `known`: raw-YAML/dynamic-model helpers and service payload dicts use `Any`. | Existing `any-to-object-sweep` / `any-encounter-entries`. | +| `core/events.py:20-23` | `known`: generic dataclass codec uses `Any` and `cast(Any, value)`. | Tracked as a sub-item of `any-to-object-sweep` (Sprint 023 triage). | +| `service/session.py`, `round.py`, `core/character.py`, `core/combat.py`, `core/resource.py`, layer model files | `known, accepted`: mutable dataclasses model explicit runtime state. | Use `frozen=True` for value objects; document stateful exceptions. | -Line length is clean. +Line length is clean. New sprint 024 user-visible strings all go through `_()` (backend) / `t()` (frontend); `.po`/`.mo` updated. ## Layer Contract | Layer | Issue | |-------|-------| -| none | Geography, politics, settlements, ecology, and entities implement `name`, `tick_interval`, `tick`, `handle_event`, `query`, `get_state`, and `load_state`. | +| none | Geography, politics, settlements, ecology, and entities implement `name`, `tick_interval`, `tick`, `handle_event`, `query`, `get_state`, `load_state`. Unchanged since the 2026-07-14 audit. | ## Test Gaps | Source File | Expected Test | Status | |-------------|---------------|--------| -| none | Player and spectator non-object WS JSON are covered; the final shutdown-autosave failure logs once while lifespan exits normally; Master `current_hp=0` depletes a lair without a duplicate roster through save/load and reconnect. | previous gaps fixed in Sprint 023 Phase 5; Phase 8 lifecycle regression covered by integration and targeted E2E | +| none | Sprint 024 code is covered: `test_item_props.py` (typed-def resolution + JSON guard over all 31 catalog entries), `test_handlers_movement.py`/`test_move_to.py`/`test_action_dispatcher.py` (handler-owned budget, unreachable-vs-blocked errors), `test_awareness_relation_fn_once.py` (O(N²) allocation regression guard), `test_perception.py` (second-wind zero-heal), `test_equip_desc_i18n.py`, frontend `ItemDetails.test.tsx` (8 cases incl. unequip/buy click regressions) + `equipI18n.test.tsx`. No skipped/xfail tests. WS tests cover invalid session, unknown message type, non-object JSON, turn exchange. | — | + +Note: the audit-skill naming pattern `tests/unit/test_rules_.py` does not match this repo's convention (`tests/unit/test_.py`, e.g. `test_dice.py`, `test_combat.py`); coverage was verified against actual test names. ## Vision Drift | Change | Invariant Violated | Impact | |--------|-------------------|--------| -| none | Typed events, trigger lifecycle, transport extraction, WS containment, and the Phase 8 service-mediated lair death preserve classic-mode, the single global round, layer independence, service-mediated master controls, swappable brains, and YAML content. | none | +| none | Sprint 024 is UX polish over existing mechanics: movement budget stays inside the single global round; `props` enriches perceived payloads without leaking raw character data; LLM prompt gains `movement_remaining` while RuleBrain paths are untouched (classic mode intact); item prices/props come from YAML catalogs (content is data). | none | diff --git a/docs/e2e-playbook.md b/docs/e2e-playbook.md index 18c637b5..074dd379 100644 --- a/docs/e2e-playbook.md +++ b/docs/e2e-playbook.md @@ -2,7 +2,7 @@ Сценарии для регрессионного тестирования через Playwright. Каждый сценарий — что делаем, что ожидаем. Обновляется при добавлении новых фич. -**Последнее обновление:** 2026-07-14 +**Последнее обновление:** 2026-07-16 --- @@ -42,11 +42,11 @@ ### 2.3 Wait and time advance - Нажать Wait → время сдвигается на 1 час -- **Ожидание:** время обновилось в Location Panel, возможно weather_changed в логе +- **Ожидание:** часы в Header обновились (в Location Panel часов нет), возможно weather_changed в логе ### 2.4 Move between locations -- Нажать Move toward соседнюю локацию (если есть paths) -- **Ожидание:** location_id меняется, Location Panel обновляется +- В Location Panel нажать соседнюю локацию в списке paths (в action bar кнопок move/travel нет, они скрыты) +- **Ожидание:** уходит действие `travel` с `destination_id`, location_id меняется, Location Panel обновляется --- @@ -81,6 +81,14 @@ - Завершить ход, на следующем раунде атаковать `practice_thug` опцией `getByRole('menuitem', { name: /Attack practice_thug \+ Smite \(slot 1\)/ })` - **Ожидание:** в damage breakdown: `1d8 + 2d8 divine_smite + +2 str + +2 дуэлянт`, `spell_slot_1` уменьшается до 1/2, бой завершается +### 3.6 Бюджет движения (Sprint 024) +- В бою двигаться кликами по карте, пока движение не кончится +- **Ожидание:** списывается фактически пройденная дистанция (3 клетки по прямой = 15 ft из 30). При 0 движения подсветка достижимых клеток пуста, ходить некуда; отказ move_to приходит с понятным текстом («Запас движения исчерпан» / «Не хватает движения, чтобы дойти туда»), а не молча + +### 3.7 Чужие ошибки не текут в лог игрока (Sprint 024) +- Провести несколько раундов с ходами NPC, спровоцировать свой отказ (атака вне досягаемости) +- **Ожидание:** свои отказы видны в логе, технических отказов других существ и их бюджета там нет + --- ## 4. Class Features @@ -105,6 +113,10 @@ - Создать rogue L1+, в бою увидеть Dash/Disengage как bonus action - **Ожидание:** Dash потребляет bonus action (не action), Attack остаётся доступным +### 4.6 Second Wind на полном HP (Sprint 024) +- Fighter без урона нажимает Second Wind +- **Ожидание:** в логе «Ты переводишь дух, но твоё здоровье уже полное», без «восстановлено 0 HP»; use всё равно тратится (кнопка исчезает до short rest) + --- ## 5. Equipment @@ -183,7 +195,7 @@ ### 7.1 Apply condition via master - Master → Edit creature → toggle Prone -- **Ожидание:** condition badge виден в CombatPanel, speed снижена +- **Ожидание:** condition badge виден в CombatPanel; лежащий атакует с disadvantage, по нему melee-атака с advantage. Скорость Prone не режет, её обнуляют Grappled/Restrained/Stunned ### 7.2 Condition affects combat - Stunned враг → атака с advantage @@ -205,6 +217,14 @@ - Снять экипированный аксессуар - **Ожидание:** слот пустой, модификатор снят, предмет в сумке +### 8.4 Карточка свойств предмета (Sprint 024) +- Навести на предмет в сумке и в экипированном слоте; проверить все пять видов (оружие, броня, щит, зелье, аксессуар) в RU и EN +- **Ожидание:** локализованная карточка со свойствами (оружие: урон/досягаемость/категория/флаги вроде Finesse, Light; броня: базовый КД, кап ЛОВ; щит: +N КД; зелье: лечение; аксессуар: слот + модификаторы), цена, подсказка «Нажмите, чтобы снять» в слоте. Клик сквозь карточку по-прежнему экипирует/снимает + +### 8.5 Локализация кнопок экипировки (Sprint 024) +- В RU-локали открыть инвентарь с бронёй, зельем и занятыми слотами +- **Ожидание:** RU-лейблы кнопок (Надеть / Снять / Использовать) и слотов (Броня, Голова, Ноги, Кольцо, Сумка); лог экипировки тоже по-русски + --- ## 9. Trading @@ -225,6 +245,14 @@ - Попытаться купить предмет без достаточного количества золота - **Ожидание:** ошибка "Not enough gold", покупка не проходит +### 9.5 Карточка свойств в торговле (Sprint 024) +- В TradePanel навести на предмет в списке покупки и в списке продажи +- **Ожидание:** та же карточка свойств, что в инвентаре ([§8.4](#84-карточка-свойств-предмета-sprint-024)), с ценой; работает в обоих списках + +### 9.6 Продажа стартовой экипировки (Sprint 024) +- Снять стартовую броню (chain mail) → продать торговцу +- **Ожидание:** у всех каталожных предметов есть SRD-цена (chain mail 75g); снятая броня уходит из сумки к торговцу, золото растёт на цену + --- ## 10. Dashboard Layout (Sprint 009) @@ -235,7 +263,7 @@ ### 10.2 Compact log + expand overlay - В игре кликнуть кнопку expand лога (стрелка вниз) -- **Ожидание:** overlay с полным логом событий, кнопка закрытия. Compact лог показывает последние 1-2 события. +- **Ожидание:** overlay с полным логом событий, кнопка закрытия. Compact лог остаётся узкой прокручиваемой полосой (`max-h-24`), примотанной к последнему событию. ### 10.3 NPC inspect modal - Нажать кнопку inspect (лупа) на NPC в Nearby панели @@ -251,11 +279,11 @@ ### 10.7 Click occupied cell → creature inspect - В бою кликнуть на клетку, занятую существом (NPC или враг) -- **Ожидание:** открывается карточка существа (NPC inspect modal) с именем, расой, HP, фракцией (display name, не raw ID) +- **Ожидание:** открывается карточка существа (NPC inspect modal) с display name (не raw ID) и состояниями. Раса/роль/фракция/описание приходят только из мирного nearby-инспекта, в бою их в карточке нет ### 10.6 Action bar budget display - В бою проверить action bar -- **Ожидание:** строка бюджета: Actions/Bonus/Movement/Reaction с числами. После использования действия — соответствующие кнопки исчезают. +- **Ожидание:** строка бюджета: Actions/Bonus/Movement/Reaction с числами. После траты бюджета кнопки остаются на месте, но гаснут и получают `data-depleted` (список действий по бюджету не фильтруется). --- @@ -343,11 +371,11 @@ - **Ожидание:** после смерти ядра доступна казна-`Container`, `take` переносит её золото/лут игроку; респавн населения выключен навсегда и переживает уход/возврат + save/load ### 15.3 Региональная таблица встреч (fallthrough) -- Войти в локацию БЕЗ своей encounter-таблицы, лежащую в регионе с таблицей → промотать время / двигаться, чтобы прокрутить ролл активации +- Войти в локацию БЕЗ своей encounter-таблицы, лежащую в регионе с таблицей (ролл крутится только на приходе в новую локацию, промотка времени его не запускает; есть кулдаун на локацию) - **Ожидание:** встреча из региональной таблицы спавнится (`encounter_spawned`); в локации со своей таблицей роллится её таблица, регион игнорируется ### 15.4 Спавн по времени суток -- В локации с ночным тегом встречи (`time_of_day: night`) промотать время в ночь (Master → Advance time), затем сравнить с днём +- В локации с ночным тегом встречи (`time_of_day: night`) промотать время в ночь (Master → Advance time), затем зайти в локацию заново (ролл идёт на входе, не на промотке); сравнить с дневным заходом - **Ожидание:** ночная встреча выстреливает ночью (`encounter_spawned`), днём пусто (в логах `encounter_roll_off_hours`, `is_day:false` ночью); встреча без тега роллится всегда ### 15.5 Кнопки действий у трупа (Sprint 019) diff --git a/docs/e2e-reports/2026-07-16-sprint024-post-audit.md b/docs/e2e-reports/2026-07-16-sprint024-post-audit.md new file mode 100644 index 00000000..9cf158f7 --- /dev/null +++ b/docs/e2e-reports/2026-07-16-sprint024-post-audit.md @@ -0,0 +1,153 @@ +# E2E Report: sprint024-post-audit + +**Date:** 2026-07-16 +**Flags:** --no-llm +**Sections tested:** 1, 2, 3, 5, 6 (частично), 8, 9, 10, 11.1, 13.2/13.3, 15.1/15.5, 16.1/16.2 + авто-сценарии по аудит-фиксам +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs, мир Sword Vale, RU UI + +Пост-аудитный прогон спринта 024. С момента phase3-E2E изменились только два аудит-квикфикса +(`reaction_to_dict` в public API transport_payloads, снос обёртки `check_faction_hostility` в awareness_builder), +поэтому фокус — реакции/OA, hostility в perception и общий смоук. + +## Summary + +- Scenarios: 26 tested, 26 passed (0 failed) +- Quick fixes: 0 +- Blockers: 0 + +## Results + +### Section 1: Session Setup + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 1.1 | Landing Player/DM split | pass | | +| 1.2 | Quick start Sword Vale | pass | Сессия, WS, первый ход | +| 1.3 | Language toggle EN→RU | pass | Все лейблы переключаются | +| 1.4 | Point buy | pass | 15/27→3/27, + disabled на 15, HP 12, AC 19 c Defense | +| 1.5 | Class-specific UI | pass | Rogue: селектор стиля исчезает, экипировка меняется, HP 10 / AC 11 | + +### Section 2: Peaceful Mode + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 2.1 | Perception nearby | pass | marta с кнопками Атаковать/Говорить/Осмотреть | +| 2.2 | Talk to NPC | pass | Canned-ответ «Что будете заказывать?»; в логе NPC назван «человек», в панели — marta | +| 2.3 | Wait +1 час | pass | 10:00→11:00 ровно, без потока NPC-ходов (16.1) | +| 2.4 | Move between locations | pass | Таверна→рынок→ворота, панели обновляются (16.2 short-edge) | + +### Section 3: Combat + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 3.1 | Initiate combat | pass | Атака gretta → авто-бой, инициатива в логе, sidebar → CombatPanel (13.3) | +| 3.2 | Attack and damage | pass | Полный breakdown `[d20(7)+5=12 vs КЗ 10], 5 урона (1d8 рубящий + 1d6 огненный + +2 str)` | +| 3.3 | End turn / NPC response | pass | gretta экипирует Кинжал (лог локализован), атакует | +| 3.4 | Combat ends | pass | «человек погибает», репутация 100→80 (13.2), «Бой окончен», layout возвращается | + +### Section 5 + 8: Equipment & Accessories + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 5.1 | Equip weapon | pass | Flaming Longsword из сумки, слот обновился, атака новым оружием | +| 5.3 | Use potion | pass* | Зелье потрачено; на полном HP лог «восстановлено 0 HP» — см. Findings | +| 8.1 | Inventory panel | pass | 6 слотов + сумка + золото | +| 8.2 | Equip accessory | pass | Ring of Protection: AC 19→20 | +| 8.3 | Unequip accessory | pass | AC 20→19, слот пуст, предмет в сумке (ring не задет багом `ac-stale-on-unequip`) | +| 4.1 | Second Wind | pass | 3→8 HP (1d10+1), RU-сообщение, ненулевой путь фазы 1 цел | + +### Section 9: Trading + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 9.1 | Open trade | pass | TradePanel с ценами, золото торговца видно | +| 9.2 | Buy item | pass | Potion 50g: 1000→950, у торговца 500→550, предмет ушёл из витрины | +| 9.3 | Sell item | pass | Продажа зелья 450→500, предмет вернулся к торговцу | +| 9.4 | Insufficient gold | pass | Кнопки «Купить» (600g/550g при 450g) disabled — покупка невозможна на уровне UI | + +### Section 10-11: Layout & Reactions + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 10.1 | Three-column dashboard | pass | | +| 10.2 | Log expand overlay | pass | Оверлей корректно перехватывает клики, закрытие работает | +| 10.3 | NPC inspect modal | pass | «Ser Aldric», Человек · Стражник, фракция, описание из YAML | +| 10.4 | Click-to-move | pass | 25 ft списано атомарно: Движение 30→5фт (фаза 1 unified budget) | +| 10.5 | Combat layout switch | pass | BattleMap справа, CombatPanel слева, после боя возврат | +| 10.6 | Action bar budget | pass | Действия/Бонус/Движение/Реакция; после атаки Действия: 0, кнопки скрыты | +| 11.1 | OA on leaving reach | pass | «(атака возможности)» + «человек пользуется твоей оплошностью!», полностью RU | +| — | Flee (Бегство) | pass | «Ты пытаешься сбежать», бой завершён | + +### Section 15: Loot + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 15.1 | Take all | pass | 1000g + 4 предмета одним действием, холдер «Пусто», кнопка disabled | +| 15.5 | Corpse buttons | pass | У трупа только «Осмотреть» | + +### Section 6: Master Panel (смоук) + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 6.1 | Worlds tab | pass | RU-названия миров, editable (Форк+Удалить) vs library (Форк) | +| 6.5 | Sessions list | pass | Сессия видна, «Управление» открывает session view | +| 6.7 | Edit creature HP | pass | Player HP 3→12 через форму, таблица обновилась | +| 6.11 | Save and load | pass | Именованный сейв + load с подтверждением; смерть gretta пережила load | + +### Auto-discovered scenarios (аудит-фиксы) + +| Scenario | Reason | Status | Notes | +|----------|--------|--------|-------| +| Hostility в perception после рефакторинга `_hostility_from_relation` | awareness_builder quick-fix | pass | Противники в бою помечены верно, труп не hostile, после боя aldric снова FRIENDLY | +| Реакции/OA end-to-end | transport_payloads rename | pass | NPC OA отработал; player-side prompt (`reaction_to_dict`) покрыт юнитами `test_reaction_wiring.py` — детерминированно застейджить его в UI нечем (RuleBrain-melee не покидает reach игрока) | +| `faction_hostility_check` уровень лога | фаза 1 | pass | В full.jsonl только DEBUG-записи | +| Гейт чужих ошибок в логе игрока | фаза 1 (build_action_result) | pass | Свои отказы видны («Цель слишком далеко (25 ft, досягаемость 5 ft)»), чужих технических отказов в логе нет | +| Props-карточки предметов | спринт 024 фаза 3 | pass | RU-карточки weapon/armor/shield/potion/accessory в инвентаре и торговле | + +## Quick Fixes + +Нет. + +## Findings + +### Blockers + +Нет. + +### Minor + +- **`loot-panel-raw-title`** — LootPanel всё ещё вешает английский `describe_item()` в native `title` + (`Frost Dagger (weapon: 1d4 piercing, reach 5ft [finesse, magic, +1])`). Фаза 3 покрыла 4 точки рендера + (инвентарь bag+слоты, торговля buy+sell), панель добычи — пятая, пропущенная. Кандидат в бэклог к `item-properties-ui`. +- **`potion-zero-heal-message`** — зелье на полном HP: «Ты используешь Зелье лечения (восстановлено 0 HP)». + Тот же паттерн, что фаза 1 чинила для Second Wind, но в перцепторе use_item. Плюс «HP» латиницей при «ОЗ» везде. +- **`npc-move-log-not-localized`** — движение NPC в логе по-английски: «Ser Aldric moved (10 ft)» при полностью + RU-логе остальных событий. +- **`ru-duplicate-condition-label`** — в форме редактирования существа две кнопки состояний «Оглушён» + (Stunned и Deafened переведены одинаково). +- **`nearby-shows-raw-id`** — панель «Поблизости» показывает raw id (marta, gretta, aldric) вместо display name + («Гретта Торговка», «Сэр Олдрик» — имена есть, master-таблица и inspect-модалка их показывают). Лог событий + говорит «человек говорит» — третий вариант именования того же NPC. Часть кластера `ui-language-mixing`. +- **`master-player-brain-label`** — в master-таблице существ у игрока ИИ показан как `rule_based` (реально PlayerBrain). +- **`round-stop-timeout-on-disconnect`** — при закрытии вкладки игрока в мирном режиме первый `stop_round` + упёрся в 5s-таймаут (`stop_round_timeout` + `disconnect_stop_failed`, level=error), но eviction-проход через + 1.5s остановил round-тред штатно, сессия осталась загружаемой. Похоже, stop не будит поток, ждущий действия + игрока; самоизлечилось через grace-path. Смежно с бэклог-айтемом `save-round-concurrency`. + Бонус-находка: `exc_info: true` не рендерится в JSON-лог — стек `disconnect_stop_failed` потерян. + +### Observations (не баги) + +- Дважды за прогон подряд идущие d20 одного актёра в одном раунде совпали (3/3 у игрока, 15/15 у aldric + turn-attack + OA). Все три rng-пути Round используют один общий `dice_rng`, так что это почти наверняка + совпадение (p≈1/20 на пару), но если паттерн вернётся — стоит юнитом прогнать attack+OA в одном раунде. +- Цены торговца — авторские (обычный Dagger 200g при SRD 2g в каталоге); продажа Longsword идёт по SRD 15g. + Выглядит намеренно (magic-shop наценка), но разница x100 на немагическом кинжале бросается в глаза. +- Combat-панель показывает «Оружие: flaming slash (1d8)» без огненного 1d6 — карточка предмета и лог урона + полные, сокращение только в этой строке. + +## Log Analysis + +- Единственные error-записи — пара `stop_round_timeout`/`disconnect_stop_failed` (см. Findings), других + exceptions/tracebacks нет. +- `faction_hostility_check` только на DEBUG (фаза 1 подтверждена). +- `action_failed` по дальности логируется на INFO с локализованным сообщением — согласуется с UI. diff --git a/docs/skill-feedback.md b/docs/skill-feedback.md new file mode 100644 index 00000000..380294cf --- /dev/null +++ b/docs/skill-feedback.md @@ -0,0 +1,5 @@ +## [audit] — 2026-07-16 +- **Type**: bug +- **Quote**: "ls src/dnd_simulator/rules/*.py | sed 's|src/dnd_simulator/||;s|\.py||;s|/|_|g' | while read mod; do [ -f \"tests/unit/test_${mod}.py\" ] ..." +- **Problem**: The expected test filename `tests/unit/test_rules_.py` never matches this repo's convention (`tests/unit/test_.py`, e.g. `test_dice.py`), so the loop reports all 30 rules modules as MISSING on every run and the gap check has to be redone by hand. +- **Suggested fix**: Drop the `rules_` prefix from the expected name and treat a module as covered if any `tests/unit/test_**.py` exists or any test file imports it. 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/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/e2e/phase3-report.md b/docs/sprints/024-playtest-quick-wins/e2e/phase3-report.md new file mode 100644 index 00000000..a5aeb871 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/e2e/phase3-report.md @@ -0,0 +1,78 @@ +# E2E Report: sprint024-phase3 + +**Date:** 2026-07-16 +**Flags:** --no-llm +**Sections tested:** 5 (Equipment), 8 (Inventory & Accessories), 9 (Trading) + item-details cards (phase 3 feature) +**Stack:** LOG_LEVEL=DEBUG, LOG_DIR=/tmp/dnd-e2e-logs, world `test_vale`, UI language RU + EN + +## Summary + +- Scenarios: 15 tested, 15 passed +- Quick fixes: 0 +- Blockers: 0 + +## Results + +### Phase 3 functionality (item details cards) + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| A1 | Equipped slot hover — armor (RU) | pass | Chain Mail: «Базовый КД: 16», «Кап ЛОВ: 0», «Тяжёлая броня», «Нажмите, чтобы снять» | +| A2 | Equipped slot hover — weapon (RU) | pass | Longsword: «Урон: 1d8 рубящий», «Досягаемость: 5 фт», «Воинское оружие» | +| A3 | Equipped slot hover — shield (RU) | pass | «+2 КД» | +| A4 | Bag item hover (RU) | pass | Chain Mail in bag: props + «Цена: 75g»; equip click passes through the card | +| A5 | Merchant buy-list hover — potion (RU) | pass | Health Potion: «Лечит 2d4+2 ОЗ», «Цена: 50g» | +| A6 | Trade sell-list hover (RU) | pass | Same card in sell list | +| A7 | EN locale — weapon/armor cards | pass | "Damage: 1d8 slashing", "Reach: 5 ft", "Martial weapon", "Base AC: 16", "Max DEX bonus: 0", "Heavy armor", "Click to unequip" | +| A8 | Accessory card + weapon flags | pass | Ring of Protection: "Ring", "+1 AC", "Price: 3500g"; Dagger: "Ability: DEX", "Finesse, Light" | + +### Section 5: Equipment + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 5.1 | Equip weapon | pass | Dagger equip swaps Longsword to bag (15g), slot updates; attack usage covered by phase 2 combat regress | +| 5.2 | Equip armor and shield | pass | AC 19 = chain mail 16 (DEX cap 0) + shield 2 + Defense 1; ring +1 → 20 | +| 5.3 | Use healing potion | pass | HP 3→10, «Ты используешь Зелье лечения (восстановлено 7 HP)», potion consumed | + +### Section 8: Inventory & Accessories + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 8.1 | View inventory panel | pass | 6 slots + gold; Сумка section appears when bag non-empty | +| 8.2 | Equip accessory | pass | Ring of Protection: AC 19→20, slot filled | +| 8.3 | Unequip accessory | pass | AC 20→19, slot empty, item back in bag | + +### Section 9: Trading + +| # | Scenario | Status | Notes | +|---|----------|--------|-------| +| 9.1 | Open trade with merchant | pass | Пип Торговец 200g, Health Potion 50g with price | +| 9.2 | Buy item | pass | Gold 1000→950, merchant 200→250, potion in bag | +| 9.3 | Sell item | pass | Gold 950→1000, item back at merchant | +| 9.4 | Insufficient gold | pass | «Недостаточно золота (нужно 50, есть 5)», purchase rejected; buy button disabled client-side while gold < price | + +## Quick Fixes + +- None needed. + +## Findings + +### Blockers +- None. Phase 3 deliverables green end to end in both locales. + +### Minor (pre-existing, out of phase scope) + +- **КД (client) vs КЗ (server) for AC.** The new cards use «КД», consistent with all existing frontend keys (`ac_display`, `vs_ac`, `stat_ac`), but server-side combat log lines say «КЗ» («против КЗ 15» comes from `.po`). Client/server terminology mismatch predates phase 3; belongs to the `ui-language-mixing` cluster. +- **Item names unlocalized in lists, localized in log.** Bag/trade show "Health Potion" while the use-message says «Зелье лечения». Known item-name-localization gap (already noted in phase 2 report). +- Ring slot shows only one generic "Ring" label for both the slot name and the accessory-kind line in the card; cosmetic. + +### Observations (correct behavior worth noting) + +- Sell button disables when the merchant can't afford the item (Ring of Protection 3500g vs merchant 200g). +- Buy button disables client-side when player gold < price; server rejection message also correct. +- Master `PATCH /creatures/{id}` silently ignores unknown fields (sent `hp`, expected `current_hp`) — Pydantic default; caused no harm here, but a 422 on unknown keys would have failed faster. + +## Log Analysis + +- Backend log: 0 tracebacks, 0 exceptions. Only flagged line is the intentional 9.4 insufficient-gold `action_failed` warning. +- Console: 0 errors; 1 benign dev-mode WS reconnect warning at page load. 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..c25a8d74 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/sprint.md @@ -0,0 +1,95 @@ +# 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-спам уходят из лога, 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` + +`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) — переформулирован: бюджет **уже** списывается диспетчером (премиса «не тратится» неверна), но учёт раздвоен — `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` + +## 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:** + +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: Панель свойств предметов ✓ + +Закрыта 2026-07-16: integration 166 green (+2 новых теста на `props` в payload торговца и REST-статусе), E2E 15/15 (карточки всех пяти видов предметов в RU и EN, торговля, экипировка, аксессуары). Отчёт: [e2e/phase3-report.md](e2e/phase3-report.md). Найденное вне скоупа: терминологический рассинхрон КД (клиент) / КЗ (сервер) и нелокализованные имена предметов — оба в кластере `ui-language-mixing`. + +`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` + +**Разведка (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:** CLOSED (2026-07-16). + +## Decisions + +- **Ось движения унифицирована на хендлерах.** `MOVE`→`CostType.FREE`, `CostType.MOVEMENT` удалён; `movement_remaining` списывают только movement-хендлеры по факту пройденного. Диспетчер владеет осью действий (action/bonus/reaction). Инвариант зафиксирован в CLAUDE.md. +- **`props` считается на сервере, метки рисует клиент.** Машиночитаемый JSON-safe `props` из типизированных дефов вместо серверной строки `describe_item()`; i18n-метки живут во фронтовом `game.json`. Не завязываемся на серверный язык, пока `ui-language-mixing` открыт. Значения `props` обязаны оставаться JSON-примитивами: payload торговца и лута идут через `dataclasses.asdict` без JSON-прохода. +- **`equipped` расширен до `list[dict[str, object]]`** в `PlayerStatusData` и `PlayerStatusResponse`: `props` — dict, старая str-типизация отбивала создание персонажа 422. +- **Карточка `ItemDetails` на `fixed` вместо `absolute`** — все четыре точки рендера внутри `overflow-y-auto`, absolute клипался бы. Контент всегда в DOM (CSS-hover), поэтому тестируем без реального ховера. +- **RU-описания equip/unequip дописаны в `.po` руками.** `make messages` не годится: гонит `pygettext --keyword=_` без `N_` и падает на f-строках. + +## Deferred + +- `ac-stale-on-unequip` — снятие брони повышает КЗ (`effective_ac` держит устаревший `creature.ac`). Вскрыт E2E фазы 2, предсуществующий, трогает backwards-compat `max()` для stat-block AC существ. +- `use-item-zero-heal` — ветка `healed == 0` для зелий (Second Wind-половина закрыта в phase 1 task 3). +- `ui-language-mixing` — пополнен находками спринта: КД (клиент) против КЗ (сервер), английские имена предметов в локализованном логе. +- Остаток `faction-hostility-check-cost` — кросс-слойный faction-to-faction запрос на пару всё ещё без мемоизации, покрыт `awareness-rebuild-cache`. +- `combat-status-single-source`, `flee-scene-separation`, `combat-pathfinding-avoidance`, `equip-action-collapse` — вне скоупа с планирования. + +## Results + +**Completed:** 2026-07-16 + +Восемь айтемов из живой партии 2026-07-15 закрыты за три фазы: бой стал читаться (единый учёт бюджета движения, чужие отказы не текут в лог игрока, `faction_hostility_check` INFO→DEBUG, Second Wind без «0 ОЗ»), снаряжение получило SRD-цены и локализованные кнопки, свойства предметов видно карточкой до покупки и надевания в RU и EN. + +Метрики: backend 2573 unit (+~30), frontend 299 (+10), integration 166 (+4). Post-audit E2E 26/26, 0 блокеров. Аудит 2026-07-16 — 13 находок, 2 применены quick-fix, остальные уже трекались в BACKLOG. + +Ключевое открытие: премиса `combat-move-budget-not-consumed` («бюджет не тратится, кайтинг бесконечен») оказалась неверной — диспетчер списывал бюджет, speed=30 честно упирался в 30 ft. Разведка кода до правки переформулировала таск в унификацию раздвоенного учёта, где и жили настоящие латентные баги: диагональ списывалась плоскими 5 ft вместо 5/10, частичный шаг списывал запрос, а не пройденное. + +`hide-world-travel-in-combat` при разведке оказался уже закрытым с Sprint 009 (`GameScreen` свапает правую колонку в бою) — помечен superseded, playtest-симптом ушёл в `combat-status-single-source`. + +При закрытии полный `/update-docs` вскрыл дрейф документации шире дельты спринта: `master/` описан как живой LLM-оркестратор (пустой стаб с первого коммита, никем не импортируется), CLI/Telegram-адаптеры как существующие, `xp_for_kill` вместо `xp_for_cr`, 7 сценариев e2e-плейбука расходились с поведением кода. Починен предсуществующий флейк `test_timeout_preserves_lifecycle_until_same_thread_stops` (тест не восстанавливал 10ms-таймаут, teardown гонялся с живым раунд-потоком). 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..c1d0a7e9 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task1-combat-move-budget.md @@ -0,0 +1,84 @@ +# Task: Единый учёт бюджета движения в бою + +**Date:** 2026-07-16 +**Sprint:** 024-playtest-quick-wins +**Phase:** 1 — Читаемость и тактика боя + +## Description + +Переформулировано 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 или мелкая карта — вне этого таска). + +Настоящая проблема — **раздвоенный и неконсистентный учёт стоимости движения**: + +- `MOVE` (компас) — `cost_type=MOVEMENT`, бюджет списывает диспетчер по **запрошенным** `ft`. +- `MOVE_TO` (клик/BFS) — `cost_type=FREE`, бюджет списывает **хендлер** по факту пройденного (`spent`, с диагональю 5/10 через `step_cost`). +- `DASH` — `cost_type=ACTION`, хендлер **добавляет** `+speed` к движению. + +Movement — исполнительная стоимость: сколько реально пройдено, известно только после резолва (стены, обрыв пути на OA-смерти, диагональное чередование). Декларативный `action_cost` этого знать не может — он чистая функция от параметров, без карты. Поэтому `MOVE_TO`/`DASH` правильно отдают учёт движения хендлеру, а `MOVE` — сидит на заборе: списывает **запрошенные** ft, а не фактически пройденные. Латентные баги, замаскированные тем, что RuleBrain ходит ортогонально по 5ft: + +- **Диагональ недосписывается** — диспетчер берёт плоские 5 за диагональный шаг, `grid_distance` даёт ×1.5. +- **Заблокированный/частичный шаг пересписывается** — запрос `ft=30`, стена на 10ft → двигается 10, списывается 30. + +Дополнительно: гейт мозга (`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 бюджет не трогает. + +**B. Внятная отбивка в `handle_move_to`.** +- Различать «стена, пути нет» и «путь есть, но длиннее бюджета». В ветке `if not path`: fallback-пересчёт достижимости без лимита бюджета; если цель достижима в принципе → `_("Not enough movement to reach there")`, иначе `_("No path to target")`. + +**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. + +Новые i18n-строки переводятся в `locale/ru` (проект RU-default). + +## 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` FREE, `CostType.MOVEMENT` удалён, `handle_move` списывает фактический `moved_ft` атомарно +- [ ] Диагональ/частичный компасный шаг списываются по факту, не по запросу +- [ ] `handle_move_to` различает «нет пути» и «не хватает движения» +- [ ] LLM-промпт показывает остаток движения и достижимые цели +- [ ] `handle_move_to` / `handle_dash` числовое поведение бюджета не изменилось + +## Status + +`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/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..a12bcda7 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task2-combat-log-noise.md @@ -0,0 +1,68 @@ +# 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 + +`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/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..50e58cc8 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase1-task3-second-wind-zero-heal.md @@ -0,0 +1,48 @@ +# 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 + +`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/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..10cf7bec --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task1-catalog-item-prices.md @@ -0,0 +1,55 @@ +# 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 + +`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/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..560cae13 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase2-task2-equip-unequip-i18n.md @@ -0,0 +1,58 @@ +# 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 + +`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/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..9e12b5db --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task1-item-props-payload.md @@ -0,0 +1,70 @@ +# 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 + +`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/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..226db0d2 --- /dev/null +++ b/docs/sprints/024-playtest-quick-wins/tasks/phase3-task2-item-details-tooltip.md @@ -0,0 +1,62 @@ +# 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 + +- [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 + +`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 c598c6a2..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 })} @@ -73,6 +75,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) @@ -87,19 +90,21 @@ function BagItem({ item }: { item: ItemInfo }) { return (
- - {item.name} - {item.price != null && ( - {item.price}g - )} - + + + {item.name} + {item.price != null && ( + {item.price}g + )} + + {isConsumable && ( )} {equipSlot && ( @@ -108,7 +113,7 @@ function BagItem({ item }: { item: ItemInfo }) { disabled={waitingForAction} onClick={handleEquip} > - EQUIP + {t("game:equip")} )}
@@ -139,8 +144,8 @@ export function InventoryPanel() {
{/* Equipment slots */}
- {SLOT_ORDER.map((slot) => ( - + {SLOT_ORDER.map((slot, i) => ( + ))}
diff --git a/frontend/src/components/game/ItemDetails.tsx b/frontend/src/components/game/ItemDetails.tsx new file mode 100644 index 00000000..81d654e4 --- /dev/null +++ b/frontend/src/components/game/ItemDetails.tsx @@ -0,0 +1,118 @@ +import { useTranslation } from "react-i18next" +import type { TFunction } from "i18next" +import { cn } from "@/lib/utils" +import type { ItemProps } from "@/types/game" + +interface ItemLike { + props?: ItemProps | null + description?: string + price?: number | null +} + +function formatModifier(t: TFunction, m: { stat: string; op: string; value: number }): string { + const stat = t(`game:stat_${m.stat}`, { defaultValue: m.stat }) + if (m.op === "add") return `${m.value > 0 ? "+" : ""}${m.value} ${stat}` + if (m.op === "override") return `${stat} = ${m.value}` + return `${stat}: ${t(`game:${m.op}`, { defaultValue: m.op })}` +} + +function detailLines(t: TFunction, props: ItemProps | null | undefined): string[] { + if (!props) return [] + switch (props.kind) { + case "weapon": { + const dmg = props.damage + .map((d) => `${d.dice} ${t(`game:dmg_${d.type}`, { defaultValue: d.type })}`) + .join(" + ") + const lines = [ + `${t("game:damage")}: ${dmg}${props.modifier ? ` +${props.modifier}` : ""}`, + t("game:item_reach", { ft: props.reach }), + t(`game:wpn_cat_${props.category}`, { defaultValue: props.category }), + ] + if (props.ability) { + lines.push( + t("game:item_ability", { + ability: t(`game:source_${props.ability}`, { defaultValue: props.ability.toUpperCase() }), + }), + ) + } + const flags: string[] = [] + if (props.is_finesse) flags.push(t("game:prop_finesse")) + if (props.is_two_handed) flags.push(t("game:prop_two_handed")) + if (props.is_light) flags.push(t("game:prop_light")) + if (props.is_heavy) flags.push(t("game:prop_heavy")) + if (props.is_magic) flags.push(t("game:prop_magic")) + if (flags.length > 0) lines.push(flags.join(", ")) + for (const c of props.conditions ?? []) { + lines.push(t(`game:condition_${c}`, { defaultValue: c })) + } + return lines + } + case "armor": { + const lines = [t("game:item_base_ac", { n: props.base_ac })] + if (props.max_dex_bonus != null) lines.push(t("game:item_max_dex", { n: props.max_dex_bonus })) + lines.push(t(`game:armor_cat_${props.category}`, { defaultValue: props.category })) + return lines + } + case "shield": + return [`+${props.ac_bonus} ${t("game:stat_ac")}`] + case "accessory": { + const lines = [t(`game:slot_${props.slot}`, { defaultValue: props.slot })] + for (const m of props.modifiers) lines.push(formatModifier(t, m)) + return lines + } + case "potion": + return [t("game:item_heals", { dice: props.heal_dice })] + } +} + +/** + * CSS-hover details card around an item name. Content is always in the DOM + * (hidden via `invisible`), so tests can assert on it without hover simulation. + * The card uses `fixed` with auto offsets: it renders at its static position + * (under the item row) but is positioned against the viewport, so the + * `overflow-y-auto` panel columns don't clip it. + */ +export function ItemDetails({ + item, + align = "left", + hint, + className, + children, +}: { + item: ItemLike + align?: "left" | "right" + hint?: string + className?: string + children: React.ReactNode +}) { + const { t } = useTranslation(["game"]) + const lines = detailLines(t, item.props) + + return ( + + {children} + + {lines.length > 0 ? ( + lines.map((line, i) => ( + + {line} + + )) + ) : item.description ? ( + {item.description} + ) : null} + {item.price != null && ( + {t("game:item_price", { n: item.price })} + )} + {hint && {hint}} + + + ) +} diff --git a/frontend/src/components/game/TradePanel.tsx b/frontend/src/components/game/TradePanel.tsx index 2c0090e3..e7d01da1 100644 --- a/frontend/src/components/game/TradePanel.tsx +++ b/frontend/src/components/game/TradePanel.tsx @@ -4,6 +4,7 @@ import { useGameStore } from "@/store/gameStore" import { wsClient } from "@/transport/wsClient" import { ChevronDown, ChevronRight, ShoppingBag, Coins } from "lucide-react" import type { MerchantInfo } from "@/types/game" +import { ItemDetails } from "./ItemDetails" function sendAction(name: string, params?: Record) { wsClient.send({ type: "action", name, params }) @@ -36,12 +37,14 @@ export function MerchantView({ merchant }: { merchant: MerchantInfo }) {
{merchant.items.map((item) => (
- - {item.name} - {item.price != null && ( - {item.price}g - )} - + + + {item.name} + {item.price != null && ( + {item.price}g + )} + +