# 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 dedicated new test — its correctness is verified by the whole project still compiling and the existing suite staying green. (Pre-flight check: the pre-existing `{ ... } as Combat` fixtures in `combat.service.spec.ts` that build `playerState` without `potionsRemaining` were checked against this change with an isolated `tsc --strict` probe — a type assertion's structural-widening direction accepts them since `Combat` is assignable to the fixture's narrower inferred type, so nothing there needs touching. Direct type-annotated literals elsewhere are a different story — see Task 8's pre-flight ruling.) - [ ] **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 in the existing suite constructs directly: the only `Combat`-typed test fixtures use `{ ... } as Combat`, and a type assertion accepts this because `Combat` structurally widens into the fixture's narrower inferred type. - [ ] **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` - Modify: `apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts:50-65` (fixture only, see Step 4) - Modify: `apps/web/src/app/core/resume-combat.spec.ts:8-23` (fixture only, see Step 4) **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). **Pre-flight ruling:** making `potionsRemaining`/`potionsMax`/`pendingIntent` required breaks two more `const x: Combat = {...}` fixtures outside the combat feature — `hunt-page.component.spec.ts` and `resume-combat.spec.ts` both build a full `Combat` literal for their own unrelated tests (hunt resumption, not combat mechanics). A direct type-annotated object literal missing required properties is a guaranteed TypeScript error (stricter than the `as Combat` cast fixed in Task 4). Step 4 below patches both. - [ ] **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: Patch the two out-of-feature fixtures** These two files build their own `Combat` fixture for unrelated tests (hunt resumption) and will fail to compile once `CombatPlayer`/`CombatMonster` gain required fields. In `apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts`, change the `startedCombat` fixture (around lines 50-65): ```ts const startedCombat: Combat = { id: 'combat-2', status: 'ACTIVE', round: 1, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100, potionsRemaining: 2, potionsMax: 2 }, monster: { key: 'road-bandit', name: 'Straßenräuber', level: 3, maxHp: 75, currentHp: 75, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, }, events: [], rewards: null, }; ``` In `apps/web/src/app/core/resume-combat.spec.ts`, change the `runningCombat` fixture (around lines 8-23): ```ts const runningCombat: Combat = { id: 'combat-running', status: 'ACTIVE', round: 4, player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 62, potionsRemaining: 2, potionsMax: 2 }, monster: { key: 'road-bandit', name: 'Straßenräuber', level: 3, maxHp: 75, currentHp: 30, artworkPath: '/images/enemies/RoadBandit.png', pendingIntent: null, }, events: [], rewards: null, }; ``` - [ ] **Step 5: Run to verify everything this task touches compiles and passes** Run: `npx ng test --watch=false --include='**/combat.store.spec.ts' --include='**/hunt-page.component.spec.ts' --include='**/resume-combat.spec.ts'` (from `apps/web`) Expected: PASS — this also proves the Step 4 fixture patches compile, since `ng test` type-checks the whole project before running any spec. - [ ] **Step 6: 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 apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts apps/web/src/app/core/resume-combat.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.