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 }) {