diff --git a/docs/superpowers/plans/2026-08-20-playable-slice-0.6-full-first-combat.md b/docs/superpowers/plans/2026-08-20-playable-slice-0.6-full-first-combat.md new file mode 100644 index 0000000..ff47231 --- /dev/null +++ b/docs/superpowers/plans/2026-08-20-playable-slice-0.6-full-first-combat.md @@ -0,0 +1,2037 @@ +# Playable Slice 0.6 — Full First Combat Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the technical ATTACK-only fight with five real combat actions (ATTACK, HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION), a deterministic monster telegraph/interrupt mechanic, and a combat UI that shows all of it. + +**Architecture:** The combat engine (`CombatEngineService`) grows from a single `resolveAttack` method into a dispatcher over five resolvers that all funnel through one shared `finishRound`/`resolveMonsterTurn` pair, so every action gets the same win/loss/monster-turn handling. Monster "intent" (a pending Heavy Attack) and the player's potion count are carried as extra fields on the existing `playerState`/`monsterState` JSONB columns — no new columns, only new enum values for `CombatEventType`. The web combat page generalizes its existing swing/riposte animation to replay whatever events the server actually returned instead of assuming attack-then-counterattack. + +**Tech Stack:** NestJS + TypeORM + PostgreSQL (API), Angular (signals, standalone components) + Vitest (Web), Jest (API). + +**Spec:** `docs/playable-slices/Ashen_Realms_Playable_Slice_0.6_Full_First_Combat.md` + +## Global Constraints + +- ATTACK = 100% damage, HEAVY_STRIKE = 160%, SHIELD_BASH = 70% (spec §2). +- DEFEND halves incoming damage for the round it's used (spec §2). +- POTION heals 35% of max HP, capped at max HP, uses the round's action (spec §2). +- Combat bag = exactly 2 potions per combat, tracked server-side (spec §3). +- The monster telegraphs a Heavy Attack instead of attacking normally, then resolves it the following round unless interrupted (spec §6). **Design decision (user-approved):** telegraph fires deterministically when `round % 3 === 0` — not RNG — because spec §10 requires combat to stay deterministic. +- The monster's resolved Heavy Attack uses the same 160% multiplier as the player's HEAVY_STRIKE (user-approved). +- SHIELD_BASH interrupts a prepared (pending) enemy action (spec §2, §6). +- The server is the sole authority on action validity, damage, healing, potion count, defend mitigation, interrupt success, enemy intent, monster action, and combat result — the client only ever sends the chosen action enum (spec §8). +- Do not implement: bleeding, poison, mana, crit, dodge, block chance, cooldowns, sets, boss phases, flee, elemental damage, resistances (spec §9). +- A killed monster never resolves a pending attack (spec §10). +- Icon assets: no bespoke art exists for HEAVY_STRIKE/SHIELD_BASH/DEFEND. This plan reuses `/images/hud/runtime/AttackIcon-96.png` for the two melee actions, `/images/hud/runtime/CharacterIcon-128.png` for SHIELD_BASH/DEFEND, and the existing `/images/items/small-healing-potion.png` for POTION — all already-shipped assets. Swap these later if bespoke art arrives; that's out of scope here. + +--- + +## Task 1: Damage multiplier support + +**Files:** +- Modify: `apps/api/src/combat/combat-damage.ts` +- Test: `apps/api/src/combat/combat-damage.spec.ts` + +**Interfaces:** +- Produces: `calculateDamage(attacker: DamageAttacker, targetArmor: number, multiplier?: number): number` — `multiplier` defaults to `1`, applied to the mitigated damage before the existing round + min-1 floor. All later engine tasks call this with `1`, `1.6`, `0.7`, or a defend-combined multiplier. + +- [ ] **Step 1: Add failing tests for the multiplier** + +Add to the end of `apps/api/src/combat/combat-damage.spec.ts` (inside the existing `describe('calculateDamage', ...)` block, after the last `it`): + +```ts + it('applies a damage multiplier before the minimum-1 floor', () => { + // raw = 27; mitigated = 20.25; *1.6 = 32.4 -> rounds to 32 + expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20, 1.6)).toBe(32); + }); + + it('still floors at 1 damage even with a small multiplier', () => { + expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000, 0.5)).toBe(1); + }); +``` + +- [ ] **Step 2: Run to verify the new tests fail** + +Run: `npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts` +Expected: FAIL — `calculateDamage` doesn't accept a third argument yet (TypeScript will actually fail to compile since the call site passes an extra arg to a function that doesn't declare it). + +- [ ] **Step 3: Implement the multiplier** + +Replace the full contents of `apps/api/src/combat/combat-damage.ts`: + +```ts +export interface DamageAttacker { + attack: number; + weaponDamage?: number; +} + +const ARMOR_MITIGATION_CONSTANT = 60; + +export function calculateDamage( + attacker: DamageAttacker, + targetArmor: number, + multiplier = 1, +): number { + const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0); + const mitigatedDamage = + (rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor); + return Math.max(1, Math.round(mitigatedDamage * multiplier)); +} +``` + +- [ ] **Step 4: Run to verify all combat-damage tests pass** + +Run: `npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts` +Expected: PASS (5 tests) + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/combat/combat-damage.ts apps/api/src/combat/combat-damage.spec.ts +git commit -m "feat(combat): support a damage multiplier in calculateDamage" +``` + +--- + +## Task 2: Combat action & event-type enums + +**Files:** +- Modify: `apps/api/src/combat/combat-action.enum.ts` +- Modify: `apps/api/src/combat/combat-event-type.enum.ts` +- Modify: `apps/api/src/combat/combat.controller.spec.ts:80-87` + +**Interfaces:** +- Produces: `CombatAction` now has members `ATTACK | HEAVY_STRIKE | SHIELD_BASH | DEFEND | POTION`. `CombatEventType` now has members `DAMAGE | HEAL | DEFEND | TELEGRAPH | INTERRUPT | COMBAT_WON | COMBAT_LOST`. Both are consumed by Task 4 (engine types), Task 5 (engine), and Task 7 (service). + +This task has no new tests of its own — extending `CombatAction` immediately changes the behavior of an *existing* test in `combat.controller.spec.ts` (the DTO's `@IsEnum(CombatAction)` validator now accepts `'HEAVY_STRIKE'`), so fixing that existing test is this task's verification step. + +- [ ] **Step 1: Extend `CombatAction`** + +Replace the full contents of `apps/api/src/combat/combat-action.enum.ts`: + +```ts +export enum CombatAction { + ATTACK = 'ATTACK', + HEAVY_STRIKE = 'HEAVY_STRIKE', + SHIELD_BASH = 'SHIELD_BASH', + DEFEND = 'DEFEND', + POTION = 'POTION', +} +``` + +- [ ] **Step 2: Extend `CombatEventType`** + +Replace the full contents of `apps/api/src/combat/combat-event-type.enum.ts`: + +```ts +export enum CombatEventType { + DAMAGE = 'DAMAGE', + HEAL = 'HEAL', + DEFEND = 'DEFEND', + TELEGRAPH = 'TELEGRAPH', + INTERRUPT = 'INTERRUPT', + COMBAT_WON = 'COMBAT_WON', + COMBAT_LOST = 'COMBAT_LOST', +} +``` + +- [ ] **Step 3: Run the full API test suite to see what broke** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: FAIL — `combat.controller.spec.ts`'s `'rejects an unknown action value'` test now gets `201` instead of the expected `400`, because `'HEAVY_STRIKE'` is a real enum member. + +- [ ] **Step 4: Fix the stale controller test** + +In `apps/api/src/combat/combat.controller.spec.ts`, find this test (around line 80): + +```ts + it('rejects an unknown action value', async () => { + await request(app.getHttpServer()) + .post('/api/combats/combat-1/actions') + .send({ action: 'HEAVY_STRIKE' }) + .expect(400); + + expect(performAction).not.toHaveBeenCalled(); + }); +``` + +Replace it with (using `FLEE`, a spec §9 non-goal, so this stays a genuinely-invalid action even as future slices add real ones): + +```ts + it('rejects an unknown action value', async () => { + await request(app.getHttpServer()) + .post('/api/combats/combat-1/actions') + .send({ action: 'FLEE' }) + .expect(400); + + expect(performAction).not.toHaveBeenCalled(); + }); +``` + +- [ ] **Step 5: Run the full API test suite to confirm it's green again** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS (same total count as before Step 1, since Task 1 already passed) + +- [ ] **Step 6: Commit** + +```bash +git add apps/api/src/combat/combat-action.enum.ts apps/api/src/combat/combat-event-type.enum.ts apps/api/src/combat/combat.controller.spec.ts +git commit -m "feat(combat): add the Slice 0.6 action and event-type enum members" +``` + +--- + +## Task 3: Migration — extend `combat_event_type_enum` + +**Files:** +- Create: `apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts` +- Create: `apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts` + +**Interfaces:** +- Consumes: `CombatEventType` from Task 2 (`apps/api/src/combat/combat-event-type.enum.ts`). +- Produces: the Postgres type `combat_event_type_enum` (already created by `1788100000000-CreateCombatSystem.ts`) gains `HEAL`, `DEFEND`, `TELEGRAPH`, `INTERRUPT`. Nothing in later tasks depends on this migration having *run* against a live database — the project's Jest suites use an in-memory fake repository (see `combat.service.spec.ts`), never a real Postgres connection — so this task is verified the same way the existing `equipment.migration.spec.ts` verifies its migration: by asserting the TypeORM entity metadata matches, not by executing SQL against a live database. + +- [ ] **Step 1: Write the failing metadata spec** + +Create `apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts`: + +```ts +import 'reflect-metadata'; +import { getMetadataArgsStorage } from 'typeorm'; +import { CombatEvent } from '../../combat/entities/combat-event.entity'; +import { CombatEventType } from '../../combat/combat-event-type.enum'; + +describe('combat_events.type enum', () => { + it('includes the Playable Slice 0.6 event types', () => { + const metadata = getMetadataArgsStorage(); + const column = metadata.columns.find( + (candidate) => candidate.target === CombatEvent && candidate.propertyName === 'type', + ); + + expect(column).toBeDefined(); + expect(column?.options.enum).toBe(CombatEventType); + expect(Object.values(CombatEventType)).toEqual( + expect.arrayContaining([ + 'DAMAGE', + 'HEAL', + 'DEFEND', + 'TELEGRAPH', + 'INTERRUPT', + 'COMBAT_WON', + 'COMBAT_LOST', + ]), + ); + }); +}); +``` + +- [ ] **Step 2: Run to verify it currently fails** + +Run: `npm run test --workspace=@ashen-realms/api -- extend-combat-event-types.migration.spec.ts` +Expected: PASS already, actually — Task 2 already updated the `CombatEventType` enum object, and the entity decorator (`combat-event.entity.ts`) references that same enum object by reference, so this spec is really confirming Task 2 didn't accidentally diverge the entity from the enum. If it fails, the entity's `enum:` option isn't pointing at the same `CombatEventType` import — that would be a bug to fix in `apps/api/src/combat/entities/combat-event.entity.ts`, not in this migration. + +- [ ] **Step 3: Write the migration** + +Create `apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts`: + +```ts +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class ExtendCombatEventTypes1790000000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'HEAL'`); + await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'DEFEND'`); + await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'TELEGRAPH'`); + await queryRunner.query(`ALTER TYPE "combat_event_type_enum" ADD VALUE 'INTERRUPT'`); + } + + public async down(queryRunner: QueryRunner): Promise { + // Postgres has no "DROP VALUE"; rebuild the type from scratch instead. + // This fails if any row already uses one of the new values -- expected + // for a dev rollback, same tradeoff Postgres migrations always make here. + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE varchar USING "type"::text`, + ); + await queryRunner.query(`DROP TYPE "combat_event_type_enum"`); + await queryRunner.query( + `CREATE TYPE "combat_event_type_enum" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')`, + ); + await queryRunner.query( + `ALTER TABLE "combat_events" ALTER COLUMN "type" TYPE "combat_event_type_enum" USING "type"::"combat_event_type_enum"`, + ); + } +} +``` + +- [ ] **Step 4: Run the migration spec again** + +Run: `npm run test --workspace=@ashen-realms/api -- extend-combat-event-types.migration.spec.ts` +Expected: PASS (1 test) + +- [ ] **Step 5: Commit** + +```bash +git add apps/api/src/database/migrations/1790000000000-ExtendCombatEventTypes.ts apps/api/src/database/migrations/extend-combat-event-types.migration.spec.ts +git commit -m "feat(combat): migrate combat_event_type_enum for Slice 0.6 event types" +``` + +--- + +## Task 4: Engine types & entity state + +**Files:** +- Modify: `apps/api/src/combat/combat-engine.types.ts` +- Modify: `apps/api/src/combat/entities/combat.entity.ts:16-23` + +**Interfaces:** +- Consumes: nothing new (pure type changes). +- Produces: `CombatIntent = 'HEAVY_ATTACK'` (exported from `combat-engine.types.ts`). `CombatEngineCombatantStats` gains optional `potionsRemaining?: number` and `pendingAction?: CombatIntent`. `CombatCombatantState` (entity) gains optional `pendingAction?: CombatIntent`; `CombatPlayerState` gains required `potionsRemaining: number`. Task 5 (engine) and Task 7 (service DTOs) both depend on these exact field names. + +This task is pure types with no runtime behavior, so there's no new test — its correctness is verified by the whole project still compiling and the existing suite staying green. + +- [ ] **Step 1: Extend the engine types** + +Replace the full contents of `apps/api/src/combat/combat-engine.types.ts`: + +```ts +import { Combatant } from './combatant.enum'; +import { CombatAction } from './combat-action.enum'; +import { CombatEventType } from './combat-event-type.enum'; +import { CombatStatus } from './combat-status.enum'; + +// Only HEAVY_ATTACK needs telegraphing today; NORMAL_ATTACK resolves +// immediately and is never held as a pending intent (Playable Slice 0.6 +// spec §5). Add members here as future slices add more prepared actions. +export type CombatIntent = 'HEAVY_ATTACK'; + +export interface CombatEngineCombatantStats { + attack: number; + weaponDamage?: number; + armor: number; + // Player-only: seeded at combat start, decremented by POTION. Optional + // because the monster's stats never carry it. + potionsRemaining?: number; + // Monster-only: set when it telegraphs, cleared when the action resolves + // or is interrupted. Optional because the player's stats never carry it. + pendingAction?: CombatIntent; +} + +export interface CombatEngineCombatant { + currentHp: number; + maxHp: number; + stats: CombatEngineCombatantStats; +} + +export interface CombatEngineState { + status: CombatStatus; + round: number; + player: CombatEngineCombatant; + monster: CombatEngineCombatant; +} + +export interface CombatActionInput { + action: CombatAction; +} + +export interface CombatEngineEvent { + source: Combatant; + target: Combatant; + type: CombatEventType; + amount?: number; +} + +export interface CombatEngineResult { + state: CombatEngineState; + events: CombatEngineEvent[]; +} +``` + +- [ ] **Step 2: Extend the entity state interfaces** + +In `apps/api/src/combat/entities/combat.entity.ts`, replace lines 1-23 (the imports and the two state interfaces): + +```ts +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Character } from '../../characters/entities/character.entity'; +import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { CombatIntent } from '../combat-engine.types'; +import { CombatStatus } from '../combat-status.enum'; + +export interface CombatCombatantState { + attack: number; + armor: number; + pendingAction?: CombatIntent; +} + +export interface CombatPlayerState extends CombatCombatantState { + weaponDamage: number; + potionsRemaining: number; +} +``` + +Leave the rest of the file (the `@Entity` class itself) unchanged. + +- [ ] **Step 3: Run the full API test suite to confirm nothing broke** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS — these are purely additive optional fields plus one new required field (`potionsRemaining`) that nothing constructs yet outside of test fixtures using object literals typed loosely (`as Combat`), which TypeScript doesn't strictly check field-by-field. + +- [ ] **Step 4: Commit** + +```bash +git add apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/entities/combat.entity.ts +git commit -m "feat(combat): model monster intent and potion count in combat state types" +``` + +--- + +## Task 5: Combat engine — implement all five actions + +**Files:** +- Modify: `apps/api/src/combat/combat-engine.service.ts` +- Modify: `apps/api/src/combat/combat-engine.service.spec.ts` + +**Interfaces:** +- Consumes: `calculateDamage(attacker, targetArmor, multiplier?)` (Task 1), `CombatAction` members (Task 2), `CombatEventType` members (Task 2), `CombatEngineCombatant`/`CombatEngineCombatantStats`/`CombatEngineState`/`CombatActionInput`/`CombatEngineEvent`/`CombatEngineResult` (Task 4). +- Produces: `CombatEngineService.resolveAction(state, input)` now handles all 5 `CombatAction` members. `result.state.player.stats.potionsRemaining` and `result.state.monster.stats.pendingAction` are mutated as described below — Task 7 persists these back onto `combat.playerState`/`combat.monsterState`. + +This is the core mechanic of the whole slice. The existing 6 tests in `combat-engine.service.spec.ts` must keep passing unchanged except test 6 (the "unsupported action" test), which needs its example action swapped since `HEAVY_STRIKE` becomes supported. + +- [ ] **Step 1: Fix the "unsupported action" test's example** + +In `apps/api/src/combat/combat-engine.service.spec.ts`, find (around line 102): + +```ts + it('throws UnsupportedCombatActionError for an action it does not implement', () => { + expect(() => + engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }), + ).toThrow(UnsupportedCombatActionError); + }); +``` + +Replace with: + +```ts + it('throws UnsupportedCombatActionError for an action it does not implement', () => { + expect(() => + engine.resolveAction(baseState(), { action: 'FLEE' as CombatAction }), + ).toThrow(UnsupportedCombatActionError); + }); +``` + +- [ ] **Step 2: Add the new failing tests** + +Append to the end of `apps/api/src/combat/combat-engine.service.spec.ts`, just before the final closing `});` of the `describe('CombatEngineService', ...)` block: + +```ts + it('HEAVY_STRIKE deals 160% damage to the monster', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.HEAVY_STRIKE }); + + // raw = 14; 14 * 1.6 = 22.4 -> rounds to 22 + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 22, + }); + expect(result.state.monster.currentHp).toBe(45 - 22); + }); + + it('SHIELD_BASH deals 70% damage and does not emit INTERRUPT when nothing is pending', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.SHIELD_BASH }); + + // raw = 14; 14 * 0.7 = 9.8 -> rounds to 10 + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: 10, + }); + expect(result.events.some((event) => event.type === CombatEventType.INTERRUPT)).toBe(false); + }); + + it('SHIELD_BASH interrupts a pending Heavy Attack and the monster does not act this round', () => { + const state = baseState({ + monster: { + currentHp: 45, + maxHp: 45, + stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.SHIELD_BASH }); + + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 10 }, + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }, + ]); + expect(result.state.monster.stats.pendingAction).toBeUndefined(); + expect(result.state.player.currentHp).toBe(100); + expect(result.state.round).toBe(2); + }); + + it('DEFEND deals no damage and halves the monster normal attack this round', () => { + const result = engine.resolveAction(baseState(), { action: CombatAction.DEFEND }); + + expect(result.state.monster.currentHp).toBe(45); + // raw = 5; mitigated = 4.545...; * 0.5 = 2.27 -> rounds to 2 + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, + { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 2 }, + ]); + expect(result.state.player.currentHp).toBe(98); + }); + + it('POTION heals 35% of max HP, decrements potionsRemaining, and the monster still acts', () => { + const state = baseState({ + player: { + currentHp: 60, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 2 }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.POTION }); + + // 35% of 100 = 35 + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.HEAL, + amount: 35, + }); + expect(result.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 5, + }); + // 60 + 35 healed - 5 monster hit = 90 + expect(result.state.player.currentHp).toBe(90); + expect(result.state.player.stats.potionsRemaining).toBe(1); + }); + + it('caps POTION healing at the maximum HP', () => { + const state = baseState({ + player: { + currentHp: 90, + maxHp: 100, + stats: { attack: 6, weaponDamage: 8, armor: 6, potionsRemaining: 1 }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.POTION }); + + // 35% of 100 = 35, but only 10 HP is missing + expect(result.events[0]).toEqual({ + source: Combatant.PLAYER, + target: Combatant.PLAYER, + type: CombatEventType.HEAL, + amount: 10, + }); + }); + + it('telegraphs a Heavy Attack instead of attacking on the third round, and resolves it the round after', () => { + const round3State = baseState({ round: 3 }); + + const telegraphResult = engine.resolveAction(round3State, { action: CombatAction.ATTACK }); + + expect(telegraphResult.state.player.currentHp).toBe(100); + expect(telegraphResult.state.monster.stats.pendingAction).toBe('HEAVY_ATTACK'); + expect(telegraphResult.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.TELEGRAPH, + }); + expect(telegraphResult.state.round).toBe(4); + + const resolveResult = engine.resolveAction(telegraphResult.state, { action: CombatAction.ATTACK }); + + // raw = 5; mitigated = 4.545...; heavy * 1.6 = 7.27 -> rounds to 7 + expect(resolveResult.events[1]).toEqual({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: 7, + }); + expect(resolveResult.state.monster.stats.pendingAction).toBeUndefined(); + expect(resolveResult.state.player.currentHp).toBe(100 - 7); + }); + + it('does not resolve a pending Heavy Attack when the monster is killed this round', () => { + const state = baseState({ + monster: { + currentHp: 10, + maxHp: 45, + stats: { attack: 5, armor: 0, pendingAction: 'HEAVY_ATTACK' }, + }, + }); + + const result = engine.resolveAction(state, { action: CombatAction.ATTACK }); + + expect(result.state.status).toBe(CombatStatus.WON); + expect(result.state.player.currentHp).toBe(100); + expect(result.events).toEqual([ + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 }, + { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }, + ]); + }); + + it('is deterministic for HEAVY_STRIKE as well', () => { + const state = baseState(); + + const first = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); + const second = engine.resolveAction(state, { action: CombatAction.HEAVY_STRIKE }); + + expect(first).toEqual(second); + }); +``` + +- [ ] **Step 3: Run to verify the new tests fail** + +Run: `npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts` +Expected: FAIL — `HEAVY_STRIKE`/`SHIELD_BASH`/`DEFEND`/`POTION` all still throw `UnsupportedCombatActionError`. + +- [ ] **Step 4: Rewrite the engine** + +Replace the full contents of `apps/api/src/combat/combat-engine.service.ts`: + +```ts +import { Injectable } from '@nestjs/common'; +import { CombatAction } from './combat-action.enum'; +import { calculateDamage } from './combat-damage'; +import { Combatant } from './combatant.enum'; +import { + CombatActionInput, + CombatEngineCombatant, + CombatEngineEvent, + CombatEngineResult, + CombatEngineState, +} from './combat-engine.types'; +import { CombatEventType } from './combat-event-type.enum'; +import { CombatStatus } from './combat-status.enum'; + +export class UnsupportedCombatActionError extends Error { + constructor(action: string) { + super(`Unsupported combat action: ${action}`); + } +} + +// The monster telegraphs a Heavy Attack instead of striking every third +// round it acts, then resolves it the round after unless SHIELD_BASH +// interrupts it. A fixed cadence (not RNG) keeps combat deterministic +// (Playable Slice 0.6 spec §10/§11). +const TELEGRAPH_ROUND_INTERVAL = 3; +const HEAVY_ATTACK_MULTIPLIER = 1.6; +const SHIELD_BASH_MULTIPLIER = 0.7; +const DEFEND_MITIGATION_MULTIPLIER = 0.5; +const POTION_HEAL_FRACTION = 0.35; + +@Injectable() +export class CombatEngineService { + resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult { + switch (input.action) { + case CombatAction.ATTACK: + return this.resolvePlayerStrike(state, 1); + case CombatAction.HEAVY_STRIKE: + return this.resolvePlayerStrike(state, HEAVY_ATTACK_MULTIPLIER); + case CombatAction.SHIELD_BASH: + return this.resolveShieldBash(state); + case CombatAction.DEFEND: + return this.resolveDefend(state); + case CombatAction.POTION: + return this.resolvePotion(state); + default: + throw new UnsupportedCombatActionError(input.action); + } + } + + private resolvePlayerStrike(state: CombatEngineState, multiplier: number): CombatEngineResult { + const player = this.cloneCombatant(state.player); + const monster = this.cloneCombatant(state.monster); + const events: CombatEngineEvent[] = []; + + const damage = calculateDamage(player.stats, monster.stats.armor, multiplier); + monster.currentHp = Math.max(0, monster.currentHp - damage); + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: damage, + }); + + return this.finishRound(state, player, monster, events, false); + } + + private resolveShieldBash(state: CombatEngineState): CombatEngineResult { + const player = this.cloneCombatant(state.player); + const monster = this.cloneCombatant(state.monster); + const events: CombatEngineEvent[] = []; + + const damage = calculateDamage(player.stats, monster.stats.armor, SHIELD_BASH_MULTIPLIER); + monster.currentHp = Math.max(0, monster.currentHp - damage); + events.push({ + source: Combatant.PLAYER, + target: Combatant.MONSTER, + type: CombatEventType.DAMAGE, + amount: damage, + }); + + let interrupted = false; + if (monster.stats.pendingAction) { + monster.stats.pendingAction = undefined; + interrupted = true; + events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.INTERRUPT }); + } + + return this.finishRound(state, player, monster, events, false, interrupted); + } + + private resolveDefend(state: CombatEngineState): CombatEngineResult { + const player = this.cloneCombatant(state.player); + const monster = this.cloneCombatant(state.monster); + const events: CombatEngineEvent[] = [ + { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.DEFEND }, + ]; + + return this.finishRound(state, player, monster, events, true); + } + + private resolvePotion(state: CombatEngineState): CombatEngineResult { + const player = this.cloneCombatant(state.player); + const monster = this.cloneCombatant(state.monster); + + const rawHeal = Math.round(player.maxHp * POTION_HEAL_FRACTION); + const healed = Math.min(rawHeal, player.maxHp - player.currentHp); + player.currentHp += healed; + player.stats.potionsRemaining = (player.stats.potionsRemaining ?? 0) - 1; + + const events: CombatEngineEvent[] = [ + { source: Combatant.PLAYER, target: Combatant.PLAYER, type: CombatEventType.HEAL, amount: healed }, + ]; + + return this.finishRound(state, player, monster, events, false); + } + + private finishRound( + state: CombatEngineState, + player: CombatEngineCombatant, + monster: CombatEngineCombatant, + events: CombatEngineEvent[], + defended: boolean, + interrupted = false, + ): CombatEngineResult { + if (monster.currentHp <= 0) { + events.push({ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON }); + return { state: { ...state, player, monster, status: CombatStatus.WON }, events }; + } + + if (!interrupted) { + this.resolveMonsterTurn(state.round, monster, player, defended, events); + + if (player.currentHp <= 0) { + events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST }); + return { state: { ...state, player, monster, status: CombatStatus.LOST }, events }; + } + } + + return { + state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 }, + events, + }; + } + + private resolveMonsterTurn( + round: number, + monster: CombatEngineCombatant, + player: CombatEngineCombatant, + defended: boolean, + events: CombatEngineEvent[], + ): void { + const defendMultiplier = defended ? DEFEND_MITIGATION_MULTIPLIER : 1; + + if (monster.stats.pendingAction === 'HEAVY_ATTACK') { + monster.stats.pendingAction = undefined; + const damage = calculateDamage(monster.stats, player.stats.armor, HEAVY_ATTACK_MULTIPLIER * defendMultiplier); + player.currentHp = Math.max(0, player.currentHp - damage); + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: damage, + }); + return; + } + + if (round % TELEGRAPH_ROUND_INTERVAL === 0) { + monster.stats.pendingAction = 'HEAVY_ATTACK'; + events.push({ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.TELEGRAPH }); + return; + } + + const damage = calculateDamage(monster.stats, player.stats.armor, defendMultiplier); + player.currentHp = Math.max(0, player.currentHp - damage); + events.push({ + source: Combatant.MONSTER, + target: Combatant.PLAYER, + type: CombatEventType.DAMAGE, + amount: damage, + }); + } + + private cloneCombatant(combatant: CombatEngineCombatant): CombatEngineCombatant { + return { ...combatant, stats: { ...combatant.stats } }; + } +} +``` + +- [ ] **Step 5: Run to verify all engine tests pass** + +Run: `npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts` +Expected: PASS (14 tests: 5 original unchanged + 1 fixed + 8 new) + +- [ ] **Step 6: Run the full API suite to check for regressions** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS + +- [ ] **Step 7: Commit** + +```bash +git add apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts +git commit -m "feat(combat): implement HEAVY_STRIKE, SHIELD_BASH, DEFEND, POTION, and monster telegraphing" +``` + +--- + +## Task 6: Combat error — no potions remaining + +**Files:** +- Modify: `apps/api/src/combat/combat.errors.ts` + +**Interfaces:** +- Produces: `combatNoPotionsRemaining(): CombatDomainError` with `code: 'COMBAT_NO_POTIONS_REMAINING'`, HTTP 409. Consumed by Task 7. + +- [ ] **Step 1: Add the error code and factory** + +In `apps/api/src/combat/combat.errors.ts`, change the `CombatErrorCode` union (near the top): + +```ts +export type CombatErrorCode = + | 'HUNT_ENCOUNTER_NOT_FOUND' + | 'HUNT_ENCOUNTER_ALREADY_CONSUMED' + | 'INVALID_HUNT_ENCOUNTER' + | 'CHARACTER_TRAVELLING' + | 'COMBAT_ALREADY_ACTIVE' + | 'COMBAT_NOT_FOUND' + | 'COMBAT_ALREADY_FINISHED' + | 'COMBAT_STATE_INVALID' + | 'COMBAT_NO_POTIONS_REMAINING'; +``` + +Then add a new factory function, right after `combatStateInvalid()` and before the final `export { characterNotFound } ...` line: + +```ts +export function combatNoPotionsRemaining(): CombatDomainError { + return new CombatDomainError( + 'COMBAT_NO_POTIONS_REMAINING', + HttpStatus.CONFLICT, + 'No potions remain in this combat.', + ); +} +``` + +- [ ] **Step 2: Run the full API suite to confirm it still compiles and passes** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS — this function isn't called yet, so nothing exercises it, but the file must still compile cleanly. + +- [ ] **Step 3: Commit** + +```bash +git add apps/api/src/combat/combat.errors.ts +git commit -m "feat(combat): add the COMBAT_NO_POTIONS_REMAINING domain error" +``` + +--- + +## Task 7: Combat service — potions, telegraph, and the DTO surface + +**Files:** +- Modify: `apps/api/src/combat/combat.service.ts` +- Modify: `apps/api/src/combat/combat.service.spec.ts` + +**Interfaces:** +- Consumes: `CombatIntent` (Task 4), `combatNoPotionsRemaining` (Task 6), `CombatAction.POTION` (Task 2). +- Produces: `CombatPlayerDto` gains `potionsRemaining: number` and `potionsMax: number`. `CombatMonsterDto` gains `pendingIntent: CombatIntent | null`. `startCombat` seeds `playerState.potionsRemaining = 2`. `performAction` rejects `POTION` with `COMBAT_NO_POTIONS_REMAINING` when none remain, and persists the engine's mutated `player.stats`/`monster.stats` back onto `combat.playerState`/`combat.monsterState` (previously never re-saved, since stats used to be immutable for the whole fight). Task 9/10 (web) consume this DTO shape directly. + +- [ ] **Step 1: Update the two existing exact-equality assertions that this DTO change breaks** + +In `apps/api/src/combat/combat.service.spec.ts`, find the `'starts an ACTIVE combat with snapshotted stats and full HP'` test (around line 317) and replace its two `toEqual` blocks: + +```ts + expect(combat.player).toEqual({ + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 100, + }); + expect(combat.monster).toEqual({ + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + maxHp: 45, + currentHp: 45, + artworkPath: '/images/monsters/ash-rat.png', + }); +``` + +with: + +```ts + expect(combat.player).toEqual({ + name: 'Aric Duskwalker', + maxHp: 100, + currentHp: 100, + potionsRemaining: 2, + potionsMax: 2, + }); + expect(combat.monster).toEqual({ + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + maxHp: 45, + currentHp: 45, + artworkPath: '/images/monsters/ash-rat.png', + pendingIntent: null, + }); +``` + +- [ ] **Step 2: Add the new failing tests** + +Add these tests inside `describe('performAction', ...)`, right after the existing `it('locks the combat row for the duration of the action', ...)` test (before its closing and the `describe('performAction', ...)`'s own closing brace): + +```ts + it('resolves POTION, heals the player, and persists the reduced potion count', async () => { + const { service, combatId } = await startedCombat(); + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION); + + expect(result.player.potionsRemaining).toBe(1); + expect(result.player.currentHp).toBe(95); + + const reloaded = await service.getCombat(CHARACTER_ID, combatId); + expect(reloaded.player.potionsRemaining).toBe(1); + }); + + it('rejects POTION once both potions have been used', async () => { + const { service, combatId } = await startedCombat(); + + await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION); + await service.performAction(CHARACTER_ID, combatId, CombatAction.POTION); + + await expectCombatDomainError( + service.performAction(CHARACTER_ID, combatId, CombatAction.POTION), + 'COMBAT_NO_POTIONS_REMAINING', + ); + }); + + it('persists the telegraphed Heavy Attack across a reload', async () => { + const { service, combatId } = await startedCombat(); + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + const telegraphed = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK); + + expect(telegraphed.monster.pendingIntent).toBe('HEAVY_ATTACK'); + + const reloaded = await service.getCombat(CHARACTER_ID, combatId); + expect(reloaded.monster.pendingIntent).toBe('HEAVY_ATTACK'); + }); +``` + +- [ ] **Step 3: Run to verify the new/changed tests fail** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts` +Expected: FAIL — `potionsRemaining`/`potionsMax`/`pendingIntent` aren't on the DTO yet, `POTION` isn't validated, and `playerState`/`monsterState` aren't persisted after `performAction`. + +- [ ] **Step 4: Update the DTO interfaces and imports** + +In `apps/api/src/combat/combat.service.ts`, change the import block (around line 15-16) to also pull in `CombatIntent`: + +```ts +import { CombatEngineService } from './combat-engine.service'; +import { CombatEngineState, CombatIntent } from './combat-engine.types'; +``` + +Then replace the `CombatPlayerDto` and `CombatMonsterDto` interfaces (around lines 32-45): + +```ts +export interface CombatPlayerDto { + name: string; + maxHp: number; + currentHp: number; + potionsRemaining: number; + potionsMax: number; +} + +export interface CombatMonsterDto { + key: string; + name: string; + level: number; + maxHp: number; + currentHp: number; + artworkPath: string; + pendingIntent: CombatIntent | null; +} +``` + +And add a constant near the top of the file, right after the imports and before `export interface CombatPlayerDto`: + +```ts +// Playable Slice 0.6 spec §3: fixed at 2 for V1, not yet backed by the +// persistent consumable inventory. +const STARTING_POTION_COUNT = 2; +``` + +- [ ] **Step 5: Seed `potionsRemaining` in `startCombat`** + +In `startCombat`, find the `playerState` field of the `combats.create({...})` call (around line 141-145): + +```ts + playerState: { + attack: playerStats.attack, + weaponDamage: playerStats.weaponDamage, + armor: playerStats.armor, + }, +``` + +Replace with: + +```ts + playerState: { + attack: playerStats.attack, + weaponDamage: playerStats.weaponDamage, + armor: playerStats.armor, + potionsRemaining: STARTING_POTION_COUNT, + }, +``` + +- [ ] **Step 6: Validate POTION and persist mutated stats in `performAction`** + +In `performAction`, right after the `if (combat.status !== CombatStatus.ACTIVE) { throw combatAlreadyFinished(); }` check (around line 220-222), add: + +```ts + if (action === CombatAction.POTION && (combat.playerState.potionsRemaining ?? 0) <= 0) { + throw combatNoPotionsRemaining(); + } +``` + +Then, right after the existing HP/round/status assignments (around lines 228-231, i.e. after `combat.monsterCurrentHp = result.state.monster.currentHp;` and before the `if (combat.status !== CombatStatus.ACTIVE) {` block), add: + +```ts + combat.playerState = result.state.player.stats as CombatPlayerState; + combat.monsterState = result.state.monster.stats; +``` + +Also add `combatNoPotionsRemaining` to the existing named import from `./combat.errors` (around line 17-27): + +```ts +import { + characterNotFound, + characterTravelling, + combatAlreadyActive, + combatAlreadyFinished, + combatNoPotionsRemaining, + combatNotFound, + combatStateInvalid, + huntEncounterAlreadyConsumed, + huntEncounterNotFound, + invalidHuntEncounter, +} from './combat.errors'; +``` + +And import `CombatPlayerState` from the entity file (it's already importing `Combat` from `./entities/combat.entity`, so extend that import): + +```ts +import { Combat, CombatPlayerState } from './entities/combat.entity'; +``` + +- [ ] **Step 7: Expose the new fields on `toCombatDto`** + +In `toCombatDto` (near the bottom of the file), replace the `player:` and `monster:` blocks: + +```ts + player: { + name: playerName, + maxHp: combat.playerMaxHp, + currentHp: combat.playerCurrentHp, + potionsRemaining: combat.playerState.potionsRemaining, + potionsMax: STARTING_POTION_COUNT, + }, + monster: { + key: monster.key, + name: monster.name, + level: monster.level, + maxHp: combat.monsterMaxHp, + currentHp: combat.monsterCurrentHp, + artworkPath: monster.artworkPath, + pendingIntent: combat.monsterState.pendingAction ?? null, + }, +``` + +- [ ] **Step 8: Run to verify all combat.service tests pass** + +Run: `npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts` +Expected: PASS + +- [ ] **Step 9: Run the full API suite to check for regressions** + +Run: `npm run test --workspace=@ashen-realms/api` +Expected: PASS + +- [ ] **Step 10: Commit** + +```bash +git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts +git commit -m "feat(combat): validate potions, persist telegraph state, and expose both on the combat DTO" +``` + +--- + +## Task 8: Web combat models & store + +**Files:** +- Modify: `apps/web/src/app/core/api/game-api.models.ts:151-188` +- Modify: `apps/web/src/app/features/combat/combat.store.ts` +- Modify: `apps/web/src/app/features/combat/combat.store.spec.ts` + +**Interfaces:** +- Consumes: nothing new outside the web app. +- Produces: `CombatAction` (web) = `'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'`. `CombatEventType` (web) gains `'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT'`. `CombatPlayer` gains `potionsRemaining: number` and `potionsMax: number`. `CombatMonster` gains `pendingIntent: 'HEAVY_ATTACK' | null`. `CombatStore.performAction(action: CombatAction): Promise` replaces `CombatStore.attack(): Promise` — consumed by Task 9 (combat page component). + +- [ ] **Step 1: Extend the web models** + +In `apps/web/src/app/core/api/game-api.models.ts`, replace lines 151-188 (from `export type CombatStatus = ...` through the end of the `Combat` interface): + +```ts +export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST'; +export type CombatEventType = 'DAMAGE' | 'HEAL' | 'DEFEND' | 'TELEGRAPH' | 'INTERRUPT' | 'COMBAT_WON' | 'COMBAT_LOST'; +export type CombatSide = 'PLAYER' | 'MONSTER'; +export type CombatAction = 'ATTACK' | 'HEAVY_STRIKE' | 'SHIELD_BASH' | 'DEFEND' | 'POTION'; +export type CombatMonsterIntent = 'HEAVY_ATTACK'; + +export interface CombatEvent { + round: number; + sequence: number; + type: CombatEventType; + source: CombatSide; + target: CombatSide; + amount?: number; +} + +export interface CombatPlayer { + name: string; + maxHp: number; + currentHp: number; + potionsRemaining: number; + potionsMax: number; +} + +export interface CombatMonster { + key: string; + name: string; + level: number; + maxHp: number; + currentHp: number; + artworkPath: string; + pendingIntent: CombatMonsterIntent | null; +} + +export interface Combat { + id: string; + status: CombatStatus; + round: number; + player: CombatPlayer; + monster: CombatMonster; + events: CombatEvent[]; + rewards: CombatReward | null; +} +``` + +- [ ] **Step 2: Rename `attack()` to `performAction()` in the store, and map the new error code** + +In `apps/web/src/app/features/combat/combat.store.ts`, add the new error message to `COMBAT_ERROR_MESSAGES` (after `COMBAT_ALREADY_FINISHED`): + +```ts + COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.', + COMBAT_NO_POTIONS_REMAINING: 'Du hast keine Tränke mehr.', +}; +``` + +First, add `CombatAction` to the existing import from `../../core/api/game-api.models` at the top of the file (currently `import { Combat } from '../../core/api/game-api.models';`): + +```ts +import { Combat, CombatAction } from '../../core/api/game-api.models'; +``` + +Then replace the `attack()` method: + +```ts + async attack(): Promise { + const combat = this.combatState(); + if (!combat || this.actionPendingState()) { + return; + } + + this.actionPendingState.set(true); + this.clearError(); + + try { + const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK')); + this.combatState.set(updated); + } catch (error) { + this.setError(error); + } finally { + this.actionPendingState.set(false); + } + } +``` + +with: + +```ts + async performAction(action: CombatAction): Promise { + const combat = this.combatState(); + if (!combat || this.actionPendingState()) { + return; + } + + this.actionPendingState.set(true); + this.clearError(); + + try { + const updated = await firstValueFrom(this.api.performCombatAction(combat.id, action)); + this.combatState.set(updated); + } catch (error) { + this.setError(error); + } finally { + this.actionPendingState.set(false); + } + } +``` + +- [ ] **Step 3: Update the store spec's fixtures and rename `attack` call sites** + +In `apps/web/src/app/features/combat/combat.store.spec.ts`, update both `Combat` fixtures (`startedCombat` and `afterAttack`) to include the new required fields: + +```ts +const startedCombat: Combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 1, + player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, + monster: { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + maxHp: 45, + currentHp: 45, + artworkPath: '/images/monsters/ash-rat.png', + pendingIntent: null, + }, + events: [], + rewards: null, +}; + +const afterAttack: Combat = { + ...startedCombat, + round: 2, + player: { ...startedCombat.player, currentHp: 95 }, + monster: { ...startedCombat.monster, currentHp: 31 }, + events: [ + { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, + { round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, + ], +}; +``` + +Then rename every `store.attack()` call to `store.performAction('ATTACK')` and every `combatStore: { ...; attack: ... }` type reference to `performAction`. Concretely, replace these five tests' bodies (they keep the same names and assertions otherwise — only the method name changes): + +```ts + it('sends only the ATTACK action and replaces combat with the server response', async () => { + await store.startCombat('encounter-1'); + + await store.performAction('ATTACK'); + + expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK'); + expect(store.combat()).toEqual(afterAttack); + }); + + it('does nothing when attacking without a loaded combat', async () => { + await store.performAction('ATTACK'); + + expect(api.performCombatAction).not.toHaveBeenCalled(); + }); + + it('ignores a second attack while the first is still pending', async () => { + await store.startCombat('encounter-1'); + let resolveAttack!: (value: Combat) => void; + api.performCombatAction.mockReturnValue( + from( + new Promise((resolve) => { + resolveAttack = resolve; + }), + ), + ); + + const first = store.performAction('ATTACK'); + expect(store.actionPending()).toBe(true); + const second = store.performAction('ATTACK'); + + resolveAttack(afterAttack); + await Promise.all([first, second]); + + expect(api.performCombatAction).toHaveBeenCalledOnce(); + }); + + it('clears actionPending after a failed attack and keeps the previous combat state', async () => { + await store.startCombat('encounter-1'); + api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler'))); + + await store.performAction('ATTACK'); + + expect(store.actionPending()).toBe(false); + expect(store.combat()).toEqual(startedCombat); + expect(store.error()).toBe('Netzwerkfehler'); + }); +``` + +Also add one new test, right after the "sends only the ATTACK action..." test, to prove the method is genuinely generic and not hardcoded to `'ATTACK'`: + +```ts + it('sends whichever action is requested', async () => { + await store.startCombat('encounter-1'); + + await store.performAction('HEAVY_STRIKE'); + + expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'HEAVY_STRIKE'); + }); +``` + +- [ ] **Step 4: Run to verify the store spec passes** + +Run: `npx ng test --watch=false --include='**/combat.store.spec.ts'` (from `apps/web`) +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/features/combat/combat.store.ts apps/web/src/app/features/combat/combat.store.spec.ts +git commit -m "feat(combat): generalize the web combat action and expose potions/monster intent" +``` + +--- + +## Task 9: Web combat page — action bar, telegraph banner, generalized animation + +**Files:** +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.ts` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.html:49-83` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.scss:362-368` +- Modify: `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts` + +**Interfaces:** +- Consumes: `CombatStore.performAction(action: CombatAction)` (Task 8), `Combat`/`CombatPlayer`/`CombatMonster`/`CombatEvent`/`CombatAction` (Task 8). +- Produces: nothing consumed by later tasks — this is the last task. + +This is the biggest single task. Read it in full before starting; the component rewrite, the HTML action bar, the SCSS, and roughly a dozen spec changes all have to land together for the page to render and its tests to pass. + +- [ ] **Step 1: Update the existing fixture and rename `attack`/`performAction` throughout the spec** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts`: + +Replace the `activeCombat` fixture (lines 10-28): + +```ts +const activeCombat: Combat = { + id: 'combat-1', + status: 'ACTIVE', + round: 2, + player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95, potionsRemaining: 2, potionsMax: 2 }, + monster: { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + maxHp: 45, + currentHp: 31, + artworkPath: '/images/monsters/ash-rat.png', + pendingIntent: null, + }, + events: [ + { round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 }, + { round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 }, + ], + rewards: null, +}; +``` + +Replace the `combatStore` type declaration (lines 37-44): + +```ts + let combatStore: { + combat: ReturnType>; + loading: ReturnType>; + actionPending: ReturnType>; + error: ReturnType>; + loadCombat: ReturnType; + performAction: ReturnType; + }; +``` + +Replace the `combatStore = {...}` construction inside `setup` (lines 49-56): + +```ts + combatStore = { + combat: signal(combat), + loading: signal(false), + actionPending: signal(false), + error: signal(null), + loadCombat: vi.fn(() => Promise.resolve()), + performAction: vi.fn(() => Promise.resolve()), + }; +``` + +Then, in every remaining test, rename `combatStore.attack` to `combatStore.performAction` (this affects the mock-implementation assignments in "plays the swing, reveals the monster damage...", "skips the recoil...", and "refreshes the character...") and rename the `'calls combatStore.attack() when Angriff is clicked'` test: + +```ts + it('calls combatStore.performAction("ATTACK") when Angriff is clicked', async () => { + const fixture = await setup(activeCombat); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-combat-attack]')?.click(); + + expect(combatStore.performAction).toHaveBeenCalledWith('ATTACK'); + }); +``` + +(Every other occurrence is just `combatStore.attack.mockImplementation(...)` → `combatStore.performAction.mockImplementation(...)`, three occurrences total, no other changes to those three tests.) + +- [ ] **Step 2: Add new spec coverage for the action bar, potions, and telegraph banner** + +Add these tests right after the `'shows the player, monster, HP bars, round, and the Angriff action'` test: + +```ts + it('shows all five combat actions with their German labels', async () => { + const fixture = await setup(activeCombat); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-attack]')?.textContent).toContain('Angriff'); + expect(element.querySelector('[data-combat-heavy-strike]')?.textContent).toContain('Schwerer Hieb'); + expect(element.querySelector('[data-combat-shield-bash]')?.textContent).toContain('Schildstoß'); + expect(element.querySelector('[data-combat-defend]')?.textContent).toContain('Verteidigen'); + expect(element.querySelector('[data-combat-potion]')?.textContent).toContain('Trank 2/2'); + }); + + it('sends the matching action for each of the four new buttons', async () => { + const fixture = await setup(activeCombat); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-combat-heavy-strike]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE'); + + element.querySelector('[data-combat-shield-bash]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH'); + + element.querySelector('[data-combat-defend]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND'); + + element.querySelector('[data-combat-potion]')?.click(); + expect(combatStore.performAction).toHaveBeenCalledWith('POTION'); + }); + + it('disables the potion button once both potions are used', async () => { + const fixture = await setup({ + ...activeCombat, + player: { ...activeCombat.player, potionsRemaining: 0 }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-potion]')?.disabled).toBe(true); + expect(element.querySelector('[data-combat-attack]')?.disabled).toBe(false); + }); + + it('shows a prominent telegraph banner when the monster has a pending Heavy Attack', async () => { + const fixture = await setup({ + ...activeCombat, + monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' }, + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain( + 'Aschenratte bereitet Schweren Hieb vor.', + ); + }); + + it('shows no telegraph banner when nothing is pending', async () => { + const fixture = await setup(activeCombat); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); + }); + + it('renders HEAL, DEFEND, TELEGRAPH, and INTERRUPT log lines', async () => { + const fixture = await setup({ + ...activeCombat, + events: [ + { round: 1, sequence: 1, type: 'HEAL', source: 'PLAYER', target: 'PLAYER', amount: 35 }, + { round: 1, sequence: 2, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' }, + { round: 1, sequence: 3, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' }, + { round: 1, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' }, + ], + }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.textContent).toContain('Aric Duskwalker trinkt einen Trank und heilt 35 Lebenspunkte.'); + expect(element.textContent).toContain('Aric Duskwalker geht in die Verteidigung.'); + expect(element.textContent).toContain('Aschenratte bereitet Schweren Hieb vor.'); + expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.'); + }); +``` + +Add this test right after the existing `'plays the swing, reveals the monster damage, then the recoil a beat later'` test, to cover the telegraph-specific animation timing (banner appears only after the reply beat, not immediately, and the monster never lunges for a telegraph): + +```ts + it('reveals the telegraph banner only after the reply beat, and never lunges for it', async () => { + const fixture = await setup(activeCombat); + const telegraphed: Combat = { + ...activeCombat, + round: 3, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DEFEND', source: 'PLAYER', target: 'PLAYER' }, + { round: 2, sequence: 4, type: 'TELEGRAPH', source: 'MONSTER', target: 'PLAYER' }, + ], + monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' }, + }; + combatStore.performAction.mockImplementation(async () => { + combatStore.combat.set(telegraphed); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + const monster = element.querySelector('.sprite--monster'); + element.querySelector('[data-combat-defend]')?.click(); + fixture.detectChanges(); + + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); + + await vi.advanceTimersByTimeAsync(1260); + fixture.detectChanges(); + expect(element.querySelector('[data-combat-telegraph]')?.textContent).toContain('bereitet Schweren Hieb vor'); + expect(monster?.classList.contains('sprite--lunge')).toBe(false); + }); +``` + +Add this test right after it, to cover SHIELD_BASH's interrupt path (no riposte delay, no lunge, immediate log line): + +```ts + it('shows the INTERRUPT log line immediately and skips the lunge when SHIELD_BASH interrupts', async () => { + const fixture = await setup({ + ...activeCombat, + monster: { ...activeCombat.monster, pendingIntent: 'HEAVY_ATTACK' }, + }); + const interrupted: Combat = { + ...activeCombat, + round: 3, + monster: { ...activeCombat.monster, currentHp: 21, pendingIntent: null }, + events: [ + ...activeCombat.events, + { round: 2, sequence: 3, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 10 }, + { round: 2, sequence: 4, type: 'INTERRUPT', source: 'PLAYER', target: 'MONSTER' }, + ], + }; + combatStore.performAction.mockImplementation(async () => { + combatStore.combat.set(interrupted); + }); + vi.useFakeTimers(); + + const element = fixture.nativeElement as HTMLElement; + const monster = element.querySelector('.sprite--monster'); + element.querySelector('[data-combat-shield-bash]')?.click(); + + await vi.advanceTimersByTimeAsync(540); + fixture.detectChanges(); + + expect(element.textContent).toContain('Aric Duskwalker unterbricht den vorbereiteten Angriff von Aschenratte.'); + expect(element.querySelector('[data-combat-telegraph]')).toBeNull(); + expect(monster?.classList.contains('sprite--lunge')).toBe(false); + }); +``` + +- [ ] **Step 3: Run to verify all the new/changed tests fail** + +Run: `npx ng test --watch=false --include='**/combat-page.component.spec.ts'` (from `apps/web`) +Expected: FAIL — `performAction` doesn't exist on the component yet, there's only one button, no telegraph banner, and `formatEvent` doesn't handle the new event types. + +- [ ] **Step 4: Rewrite the component** + +Replace the full contents of `apps/web/src/app/features/combat/combat-page/combat-page.component.ts`: + +```ts +import { Component, DestroyRef, OnInit, computed, inject, signal } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; +import type { Combat, CombatAction, CombatEvent } from '../../../core/api/game-api.models'; +import { + combatMonsterSpriteScale, + monsterCutoutPath, + monsterIconPath, + runtimeMonsterArtworkPath, +} from '../../../shared/monster-artwork'; +import { ItemCardComponent } from '../../../shared/item-card/item-card.component'; +import { WorldStore } from '../../world/world.store'; +import { CombatStore } from '../combat.store'; + +interface CombatLogRound { + round: number; + events: CombatEvent[]; +} + +type CombatPhase = 'idle' | 'attacking' | 'hit'; +// The monster is a single cut-out with no sheets, so its beats are pure +// CSS transforms and run offset from the player's: it flinches when the +// player's blow lands and lunges while the player is recoiling. +type MonsterPhase = 'idle' | 'flinch' | 'lunge'; + +const PLAYER_ICON = '/images/hud/runtime/CharacterIcon-128.png'; + +// Must stay in step with the sprite-sheet animations in the stylesheet: the +// swing and the recoil each run six frames over these durations. +const SWING_MS = 540; +const RECOIL_MS = 540; +// Beat between the player's blow landing and the monster striking back. +const RIPOSTE_DELAY_MS = 1260; +// Length of the stage jolt keyframes, see `stage-shake` in the stylesheet. +const STAGE_SHAKE_MS = 200; + +// Actions that land a blow on the monster this round -- everything else +// (DEFEND, POTION) skips the swing wind-up so the player sprite doesn't +// mime an attack it didn't make. +const DAMAGING_ACTIONS: ReadonlySet = new Set(['ATTACK', 'HEAVY_STRIKE', 'SHIELD_BASH']); + +@Component({ + selector: 'app-combat-page', + templateUrl: './combat-page.component.html', + styleUrl: './combat-page.component.scss', + imports: [ItemCardComponent], +}) +export class CombatPageComponent implements OnInit { + protected readonly combatStore = inject(CombatStore); + private readonly worldStore = inject(WorldStore); + private readonly route = inject(ActivatedRoute); + private readonly router = inject(Router); + private readonly destroyRef = inject(DestroyRef); + private destroyed = false; + + // The server resolves a whole round at once. `combat` is what the screen is + // currently showing, so the round can be played back a beat at a time + // instead of both blows landing together. + private readonly displayed = signal(null); + private readonly replaying = signal(false); + + // The stage jolt stays wired up but is no longer fired by an ordinary hit -- + // it was too much for every single round. Call `shakeStage()` to bring it + // back for a specific ability. + private readonly stageShaking = signal(false); + + protected readonly combat = this.displayed.asReadonly(); + protected readonly phase = signal('idle'); + protected readonly monsterPhase = signal('idle'); + protected readonly stageShake = this.stageShaking.asReadonly(); + protected readonly busy = computed(() => this.replaying() || this.combatStore.actionPending()); + protected readonly playerIcon = PLAYER_ICON; + + constructor() { + this.destroyRef.onDestroy(() => { + this.destroyed = true; + }); + } + + ngOnInit(): void { + void this.loadFromRoute(); + } + + protected async performAction(action: CombatAction): Promise { + const before = this.displayed(); + if (!before || this.busy()) { + return; + } + + this.replaying.set(true); + try { + const damaging = DAMAGING_ACTIONS.has(action); + this.phase.set(damaging ? 'attacking' : 'idle'); + this.monsterPhase.set('idle'); + const swing = this.wait(SWING_MS); + await this.combatStore.performAction(action); + await swing; + if (this.destroyed) { + return; + } + this.phase.set('idle'); + + const after = this.combatStore.combat(); + if (!after) { + return; + } + + if (after.status === 'WON') { + // The server already granted XP and silver; pull the authoritative + // character so the HUD matches (spec §35). + void this.worldStore.refreshCharacter(); + } + + const roundEvents = after.events.filter((event) => event.round === before.round); + const dealtDamage = roundEvents.some( + (event) => event.source === 'PLAYER' && event.target === 'MONSTER' && event.type === 'DAMAGE', + ); + this.monsterPhase.set(dealtDamage ? 'flinch' : 'idle'); + + const monsterEvent = roundEvents.find((event) => event.source === 'MONSTER'); + if (!monsterEvent) { + // No reply this round: either the fight just ended, or SHIELD_BASH + // interrupted the monster's turn outright. + this.displayed.set(after); + return; + } + + // Show what the player's own action produced, holding back the + // monster's reply -- including whether it just started telegraphing. + this.displayed.set({ + ...after, + player: before.player, + monster: { + ...(dealtDamage ? after.monster : before.monster), + pendingIntent: before.monster.pendingIntent, + }, + events: after.events.filter((event) => event.sequence < monsterEvent.sequence), + }); + + await this.wait(RIPOSTE_DELAY_MS); + if (this.destroyed) { + return; + } + + if (monsterEvent.type === 'TELEGRAPH') { + this.displayed.set(after); + return; + } + + this.phase.set('hit'); + this.monsterPhase.set('lunge'); + this.displayed.set(after); + await this.wait(RECOIL_MS); + if (this.destroyed) { + return; + } + this.phase.set('idle'); + this.monsterPhase.set('idle'); + } finally { + if (!this.destroyed) { + this.replaying.set(false); + } + } + } + + /** Jolts the whole stage once. Reserved for abilities; no attack triggers it. */ + protected shakeStage(): void { + this.stageShaking.set(true); + setTimeout(() => { + if (!this.destroyed) { + this.stageShaking.set(false); + } + }, STAGE_SHAKE_MS); + } + + protected retry(): void { + void this.loadFromRoute(); + } + + protected goToHunt(): void { + void this.router.navigate(['/hunt']); + } + + // The location is the screen a fight resolves back into. It sits beside + // "Weiter jagen" rather than replacing it, so the hunt loop keeps its + // one-click rhythm. + protected goToLocation(): void { + void this.router.navigate(['/location']); + } + + protected goToInventory(): void { + void this.router.navigate(['/inventory']); + } + + protected monsterSprite(monsterKey: string, artworkPath: string): string { + return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; + } + + protected monsterSpriteScale(monsterKey: string): number { + return combatMonsterSpriteScale(monsterKey); + } + + protected monsterIcon(monsterKey: string, artworkPath: string): string { + return monsterIconPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; + } + + protected playerHpPercent(): number { + const combat = this.displayed(); + return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0; + } + + protected monsterHpPercent(): number { + const combat = this.displayed(); + return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0; + } + + protected monsterIntentLabel(): string | null { + const combat = this.displayed(); + if (!combat || combat.monster.pendingIntent !== 'HEAVY_ATTACK') { + return null; + } + return `${combat.monster.name} bereitet Schweren Hieb vor.`; + } + + protected logRounds(): CombatLogRound[] { + const combat = this.displayed(); + if (!combat) { + return []; + } + + const rounds = new Map(); + for (const event of combat.events) { + const events = rounds.get(event.round) ?? []; + events.push(event); + rounds.set(event.round, events); + } + + return [...rounds.entries()].sort(([a], [b]) => a - b).map(([round, events]) => ({ round, events })); + } + + protected formatEvent(event: CombatEvent): string { + const combat = this.displayed(); + const playerName = combat?.player.name ?? 'Du'; + const monsterName = combat?.monster.name ?? 'Der Gegner'; + + if (event.type === 'DAMAGE') { + const attacker = event.source === 'PLAYER' ? playerName : monsterName; + const defender = event.target === 'PLAYER' ? playerName : monsterName; + return `${attacker} trifft ${defender} für ${event.amount} Schaden.`; + } + + if (event.type === 'HEAL') { + return `${playerName} trinkt einen Trank und heilt ${event.amount} Lebenspunkte.`; + } + + if (event.type === 'DEFEND') { + return `${playerName} geht in die Verteidigung.`; + } + + if (event.type === 'TELEGRAPH') { + return `${monsterName} bereitet Schweren Hieb vor.`; + } + + if (event.type === 'INTERRUPT') { + return `${playerName} unterbricht den vorbereiteten Angriff von ${monsterName}.`; + } + + if (event.type === 'COMBAT_WON') { + return `${monsterName} wurde besiegt.`; + } + + return `${playerName} wurde im Kampf besiegt.`; + } + + private wait(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } + + private async loadFromRoute(): Promise { + const combatId = this.route.snapshot.paramMap.get('combatId'); + if (!combatId) { + return; + } + + await this.combatStore.loadCombat(combatId); + if (!this.destroyed) { + this.phase.set('idle'); + this.monsterPhase.set('idle'); + this.displayed.set(this.combatStore.combat()); + } + } +} +``` + +- [ ] **Step 5: Rewrite the action bar and add the telegraph banner in the template** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.html`, insert the telegraph banner right after the closing `` (line 49) and before `
` (line 51): + +```html + + + @if (monsterIntentLabel(); as intent) { +

{{ intent }}

+ } + +
+``` + +Then replace the whole `
...
` block (originally lines 69-83): + +```html + +``` + +- [ ] **Step 6: Style the telegraph banner and let the action bar wrap on narrow screens** + +In `apps/web/src/app/features/combat/combat-page/combat-page.component.scss`, replace the `.combat__actions` rule (lines 362-368): + +```scss +.combat__actions { + position: relative; + z-index: 2; + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: var(--ar-space-3); + min-block-size: clamp(6.5rem, 11vw, 8.5rem); +} +``` + +Then add a new rule right after it (before `.action {`): + +```scss +.combat__telegraph { + position: relative; + z-index: 2; + margin: 0; + padding: var(--ar-space-2) var(--ar-space-4); + border: 1px solid var(--ar-gold); + border-radius: var(--ar-radius-sm); + background: rgb(9 11 13 / 0.85); + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1rem, 1.6vw, 1.15rem); + letter-spacing: 0.03em; + text-align: center; +} +``` + +- [ ] **Step 7: Run to verify all combat-page tests pass** + +Run: `npx ng test --watch=false --include='**/combat-page.component.spec.ts'` (from `apps/web`) +Expected: PASS + +- [ ] **Step 8: Run the full web test suite to check for regressions** + +Run: `npm run test --workspace=@ashen-realms/web` +Expected: PASS + +- [ ] **Step 9: Commit** + +```bash +git add apps/web/src/app/features/combat/combat-page/combat-page.component.ts apps/web/src/app/features/combat/combat-page/combat-page.component.html apps/web/src/app/features/combat/combat-page/combat-page.component.scss apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +git commit -m "feat(combat): add the five-action bar, telegraph banner, and generalized round animation" +``` + +--- + +## Final Verification + +- [ ] **Step 1: Run both full test suites one more time** + +```bash +npm run test --workspace=@ashen-realms/api +npm run test --workspace=@ashen-realms/web +``` + +Expected: PASS for both (API: 31+ suites; Web: 22+ suites, ~215+ tests total including the ~25 new/changed ones this plan adds). + +- [ ] **Step 2: Manually sanity-check the Definition of Done from the spec (§11)** + +Start the dev servers (`npm run dev` or the project's usual two-terminal `start:api` / `start:web`), start a fight against a Straßenräuber (`road-bandit`, level 2, 75 HP), and play three ATTACK rounds to trigger the round-3 telegraph. Confirm: +- The action bar shows all five buttons with the right labels and icons. +- On the telegraph round, a prominent banner reads "Straßenräuber bereitet Schweren Hieb vor." and no damage lands that round. +- SHIELD_BASH on the following round clears the banner and logs the interrupt, without a monster hit. +- DEFEND on the following round (start a fresh fight, let it re-telegraph) noticeably reduces the resolved Heavy Attack's damage versus taking it undefended. +- POTION heals visibly, decrements the counter to "Trank 1/2", and is disabled once both are used.