Files
ashen-realms/docs/superpowers/plans/2026-08-20-playable-slice-0.6-full-first-combat.md
Bastian Wagner 9d396b7d96 Add implementation plan for Playable Slice 0.6 (Full First Combat)
Covers the engine rewrite for HEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION,
deterministic monster telegraphing/interrupt, the DB migration for the
new combat event types, and the web action bar + telegraph banner.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 20:51:17 +02:00

2038 lines
74 KiB
Markdown

# 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<void> {
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<void> {
// 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<void>` replaces `CombatStore.attack(): Promise<void>` — 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<void> {
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<void> {
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<Combat>((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<typeof signal<Combat | null>>;
loading: ReturnType<typeof signal<boolean>>;
actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>;
performAction: ReturnType<typeof vi.fn>;
};
```
Replace the `combatStore = {...}` construction inside `setup` (lines 49-56):
```ts
combatStore = {
combat: signal(combat),
loading: signal(false),
actionPending: signal(false),
error: signal<string | null>(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<HTMLButtonElement>('[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<HTMLButtonElement>('[data-combat-heavy-strike]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('HEAVY_STRIKE');
element.querySelector<HTMLButtonElement>('[data-combat-shield-bash]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('SHIELD_BASH');
element.querySelector<HTMLButtonElement>('[data-combat-defend]')?.click();
expect(combatStore.performAction).toHaveBeenCalledWith('DEFEND');
element.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[data-combat-potion]')?.disabled).toBe(true);
expect(element.querySelector<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<CombatAction> = 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<Combat | null>(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<CombatPhase>('idle');
protected readonly monsterPhase = signal<MonsterPhase>('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<void> {
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<number, CombatEvent[]>();
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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
private async loadFromRoute(): Promise<void> {
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 `</header>` (line 49) and before `<div class="combat__field">` (line 51):
```html
</header>
@if (monsterIntentLabel(); as intent) {
<p class="combat__telegraph" data-combat-telegraph role="status">{{ intent }}</p>
}
<div class="combat__field">
```
Then replace the whole `<footer class="combat__actions">...</footer>` block (originally lines 69-83):
```html
<footer class="combat__actions">
@if (combat.status === 'ACTIVE') {
<button
type="button"
class="action"
data-combat-attack
[disabled]="busy()"
(click)="performAction('ATTACK')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Angriff</span>
<span class="action__key">1</span>
</button>
<button
type="button"
class="action"
data-combat-heavy-strike
[disabled]="busy()"
(click)="performAction('HEAVY_STRIKE')"
>
<img class="action__icon" src="/images/hud/runtime/AttackIcon-96.png" alt="" />
<span class="action__label">Schwerer Hieb</span>
<span class="action__key">2</span>
</button>
<button
type="button"
class="action"
data-combat-shield-bash
[disabled]="busy()"
(click)="performAction('SHIELD_BASH')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Schildstoß</span>
<span class="action__key">3</span>
</button>
<button
type="button"
class="action"
data-combat-defend
[disabled]="busy()"
(click)="performAction('DEFEND')"
>
<img class="action__icon" src="/images/hud/runtime/CharacterIcon-128.png" alt="" />
<span class="action__label">Verteidigen</span>
<span class="action__key">4</span>
</button>
<button
type="button"
class="action"
data-combat-potion
[disabled]="busy() || combat.player.potionsRemaining <= 0"
(click)="performAction('POTION')"
>
<img class="action__icon" src="/images/items/small-healing-potion.png" alt="" />
<span class="action__label">Trank {{ combat.player.potionsRemaining }}/{{ combat.player.potionsMax }}</span>
<span class="action__key">5</span>
</button>
}
</footer>
```
- [ ] **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.