Files
ashen-realms/docs/superpowers/plans/2026-08-19-playable-slice-0.3-first-combat.md
Bastian Wagner 85184e6e53 Cursor
2026-08-19 16:06:51 +02:00

136 KiB
Raw Permalink Blame History

Playable Slice 0.3: 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: Ship the first persistent, server-authoritative turn-based combat: Angreifen on a HuntEncounter creates a Combat, the player fights round-by-round from /combat/:combatId, and the fight ends in WON or LOST with no rewards granted yet.

Architecture: A framework-light CombatEngineService resolves one round of ATTACK deterministically (pure functions, no TypeORM). CombatService owns persistence/orchestration: it validates the HuntEncounter boundary, snapshots player/monster stats into a new Combat, and persists the engine's resulting HP/round/status/events inside transactions with pessimistic locks — mirroring the existing TravelService/HuntingService patterns. Angular gets a CombatStore (signals, same shape as HuntingStore/WorldStore) and a CombatPageComponent that only ever sends { action: 'ATTACK' } and renders whatever the server returns.

Tech Stack: NestJS 11 + TypeORM (raw-SQL migrations, synchronize: false) + PostgreSQL on the API; Angular 22 standalone components + signals + Vitest on the web app. Jest + supertest for API tests.

Spec: docs/playable-slices/Ashen Realms Playable Slice 0.3_ First Combat.md

Global Constraints

  • Combat is deterministic: no crits, dodge, block chance, accuracy, variance, elements, or resistances (spec §12). The same CombatEngineState + ATTACK must always produce the same result.
  • Damage formula: raw = attack + weaponDamage; damage = round(raw * 60 / (60 + targetArmor)); minimum 1 (spec §11).
  • Only ATTACK is implemented. The CombatAction enum, CombatEvent type, and engine switch must be structured so HEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION/FLEE can be added later without reshaping the API (spec §4, §14).
  • Combat may only be created from a persisted HuntEncounter.id, never from a client-supplied monsterDefinitionId (spec §6).
  • One HuntEncounter → at most one Combat. One character → at most one ACTIVE combat. Both enforced in the service AND via a DB constraint (spec §7, §18, §26).
  • The client never sends HP, damage, armor, attack, round, or combat status — only { action: 'ATTACK' } to progress, and an encounter id to start (spec §22, §49). The API's global ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }) (apps/api/src/app.config.ts) already rejects unknown body fields with 400.
  • No XP, silver, loot, inventory, or equipment system (spec §4). The starting weapon damage (8) and armor (6) are temporary constants isolated in one service, not real equipment (spec §10).
  • Migrations use raw SQL, UUID PKs, explicit FKs/indexes, and never touch synchronize (spec §28). Entities are auto-loaded via autoLoadEntities: true; no manual entity-list registration needed beyond a module's own TypeOrmModule.forFeature.
  • All demo-character endpoints use DEMO_CHARACTER_ID from apps/api/src/demo/demo-character.constants.ts — no auth exists yet.
  • Every task must leave npm run build and the relevant test suite green before moving to the next task.

File Structure

Backend — new:

  • apps/api/src/combat/combat-status.enum.tsACTIVE | WON | LOST
  • apps/api/src/combat/combat-action.enum.tsATTACK (extensible)
  • apps/api/src/combat/combat-event-type.enum.tsDAMAGE | COMBAT_WON | COMBAT_LOST
  • apps/api/src/combat/combatant.enum.tsPLAYER | MONSTER
  • apps/api/src/combat/entities/combat.entity.ts
  • apps/api/src/combat/entities/combat-event.entity.ts
  • apps/api/src/combat/combat-damage.ts + combat-damage.spec.ts — pure damage formula
  • apps/api/src/combat/combat-engine.types.ts — engine state/result types
  • apps/api/src/combat/combat-engine.service.ts + spec — pure round resolution
  • apps/api/src/combat/combat.errors.ts — domain errors
  • apps/api/src/combat/combat.service.ts + spec — orchestration/persistence
  • apps/api/src/combat/dto/combat-action.dto.ts
  • apps/api/src/combat/combat.controller.ts + spec — /combats/:id, /combats/:id/actions
  • apps/api/src/combat/hunt-encounter-attack.controller.ts + spec — /hunt-encounters/:id/attack
  • apps/api/src/combat/combat.module.ts
  • apps/api/src/characters/character-combat-stats.service.ts + spec — temporary equipment stand-in
  • apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts
  • apps/api/src/database/migrations/combat-system.migration.spec.ts

Backend — modified:

  • apps/api/src/hunting/entities/hunt-encounter.entity.ts — add nullable consumedAt
  • apps/api/src/characters/characters.module.ts — provide + export CharacterCombatStatsService
  • apps/api/src/app.module.ts — import CombatModule

Frontend — new:

  • apps/web/src/app/shared/monster-artwork.ts + spec — extracted runtime-artwork lookup
  • apps/web/src/app/features/combat/combat.store.ts + spec
  • apps/web/src/app/features/combat/combat-page/combat-page.component.ts + .html + .scss + spec

Frontend — modified:

  • apps/web/src/app/core/api/game-api.models.tsCombat* types
  • apps/web/src/app/core/api/game-api.service.ts + spec — startCombat/getCombat/performCombatAction
  • apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts — use shared artwork helper
  • apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts + .html + spec — start combat, navigate, show errors
  • apps/web/src/app/app.routes.ts/combat/:combatId replaces /combat/new

Frontend — deleted:

  • apps/web/src/app/features/combat/combat-placeholder-page.component.ts

Task 1: Combat domain enums, entities, and the HuntEncounter consumption field

Files:

  • Create: apps/api/src/combat/combat-status.enum.ts
  • Create: apps/api/src/combat/combat-action.enum.ts
  • Create: apps/api/src/combat/combat-event-type.enum.ts
  • Create: apps/api/src/combat/combatant.enum.ts
  • Create: apps/api/src/combat/entities/combat.entity.ts
  • Create: apps/api/src/combat/entities/combat-event.entity.ts
  • Modify: apps/api/src/hunting/entities/hunt-encounter.entity.ts
  • Test: apps/api/src/database/migrations/combat-system.migration.spec.ts

Interfaces:

  • Produces: CombatStatus, CombatAction, CombatEventType, Combatant enums; Combat entity (fields: id, characterId, huntEncounterId, monsterDefinitionId, status, round, playerMaxHp, playerCurrentHp, monsterMaxHp, monsterCurrentHp, playerState: {attack, weaponDamage, armor}, monsterState: {attack, armor}, createdAt, updatedAt, completedAt: Date|null); CombatEvent entity (fields: id, combatId, round, sequence, type, source, target, amount: number|null, createdAt); HuntEncounter.consumedAt: Date | null.

  • Step 1: Write the schema spec (fails: modules don't exist yet)

// apps/api/src/database/migrations/combat-system.migration.spec.ts
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';

describe('combat system schema', () => {
  it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
    const metadata = getMetadataArgsStorage();

    const relations = metadata.relations.filter(
      (relation) => relation.target === Combat || relation.target === CombatEvent,
    );

    expect(
      relations.map((relation) => ({
        onDelete: relation.options.onDelete,
        propertyName: relation.propertyName,
        target: relation.target,
      })),
    ).toEqual(
      expect.arrayContaining([
        expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }),
        expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }),
        expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }),
        expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }),
      ]),
    );
  });

  it('enforces one combat per hunt encounter via a unique index', () => {
    const metadata = getMetadataArgsStorage();
    const index = metadata.indices.find(
      (candidate) => candidate.target === Combat && candidate.columns?.includes('huntEncounterId'),
    );

    expect(index).toBeDefined();
    const options = index as typeof index & { options?: { unique?: boolean } };
    expect(options?.options?.unique).toBe(true);
  });

  it('enforces ordered, unique event sequencing per combat', () => {
    const metadata = getMetadataArgsStorage();
    const index = metadata.indices.find(
      (candidate) =>
        candidate.target === CombatEvent &&
        candidate.columns?.includes('combatId') &&
        candidate.columns?.includes('sequence'),
    );

    expect(index).toBeDefined();
    const options = index as typeof index & { options?: { unique?: boolean } };
    expect(options?.options?.unique).toBe(true);
  });

  it('adds a nullable consumedAt column to hunt_encounters to prevent reuse', () => {
    const metadata = getMetadataArgsStorage();
    const column = metadata.columns.find(
      (candidate) => candidate.target === HuntEncounter && candidate.propertyName === 'consumedAt',
    );

    expect(column).toBeDefined();
    expect(column?.options.nullable).toBe(true);
  });
});
  • Step 2: Run it to confirm it fails

Run: npm run test --workspace=@ashen-realms/api -- combat-system.migration.spec.ts Expected: FAIL — cannot find module ../../combat/entities/combat.entity.

  • Step 3: Create the four enums
// apps/api/src/combat/combat-status.enum.ts
export enum CombatStatus {
  ACTIVE = 'ACTIVE',
  WON = 'WON',
  LOST = 'LOST',
}
// apps/api/src/combat/combat-action.enum.ts
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
// CombatEngineService cases — do not add them here until their behavior ships.
export enum CombatAction {
  ATTACK = 'ATTACK',
}
// apps/api/src/combat/combat-event-type.enum.ts
export enum CombatEventType {
  DAMAGE = 'DAMAGE',
  COMBAT_WON = 'COMBAT_WON',
  COMBAT_LOST = 'COMBAT_LOST',
}
// apps/api/src/combat/combatant.enum.ts
export enum Combatant {
  PLAYER = 'PLAYER',
  MONSTER = 'MONSTER',
}
  • Step 4: Create the Combat entity
// apps/api/src/combat/entities/combat.entity.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 { CombatStatus } from '../combat-status.enum';

export interface CombatCombatantState {
  attack: number;
  armor: number;
}

export interface CombatPlayerState extends CombatCombatantState {
  weaponDamage: number;
}

@Entity({ name: 'combats' })
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true })
export class Combat {
  @PrimaryGeneratedColumn('uuid', { name: 'id' })
  id!: string;

  @Column({ name: 'character_id', type: 'uuid' })
  characterId!: string;

  @Column({ name: 'hunt_encounter_id', type: 'uuid' })
  huntEncounterId!: string;

  @Column({ name: 'monster_definition_id', type: 'uuid' })
  monsterDefinitionId!: string;

  @Column({
    name: 'status',
    type: 'enum',
    enum: CombatStatus,
    enumName: 'combat_status_enum',
  })
  status!: CombatStatus;

  @Column({ name: 'round', type: 'integer' })
  round!: number;

  @Column({ name: 'player_max_hp', type: 'integer' })
  playerMaxHp!: number;

  @Column({ name: 'player_current_hp', type: 'integer' })
  playerCurrentHp!: number;

  @Column({ name: 'monster_max_hp', type: 'integer' })
  monsterMaxHp!: number;

  @Column({ name: 'monster_current_hp', type: 'integer' })
  monsterCurrentHp!: number;

  @Column({ name: 'player_state', type: 'jsonb' })
  playerState!: CombatPlayerState;

  @Column({ name: 'monster_state', type: 'jsonb' })
  monsterState!: CombatCombatantState;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;

  @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
  updatedAt!: Date;

  @Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
  completedAt!: Date | null;

  @ManyToOne(() => Character, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'character_id' })
  character!: Character;

  @ManyToOne(() => HuntEncounter, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'hunt_encounter_id' })
  huntEncounter!: HuntEncounter;

  @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'monster_definition_id' })
  monster!: MonsterDefinition;
}
  • Step 5: Create the CombatEvent entity
// apps/api/src/combat/entities/combat-event.entity.ts
import {
  Column,
  CreateDateColumn,
  Entity,
  Index,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { Combatant } from '../combatant.enum';
import { CombatEventType } from '../combat-event-type.enum';
import { Combat } from './combat.entity';

@Entity({ name: 'combat_events' })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true })
export class CombatEvent {
  @PrimaryGeneratedColumn('uuid', { name: 'id' })
  id!: string;

  @Column({ name: 'combat_id', type: 'uuid' })
  combatId!: string;

  @Column({ name: 'round', type: 'integer' })
  round!: number;

  @Column({ name: 'sequence', type: 'integer' })
  sequence!: number;

  @Column({
    name: 'type',
    type: 'enum',
    enum: CombatEventType,
    enumName: 'combat_event_type_enum',
  })
  type!: CombatEventType;

  @Column({
    name: 'source',
    type: 'enum',
    enum: Combatant,
    enumName: 'combatant_enum',
  })
  source!: Combatant;

  @Column({
    name: 'target',
    type: 'enum',
    enum: Combatant,
    enumName: 'combatant_enum',
  })
  target!: Combatant;

  @Column({ name: 'amount', type: 'integer', nullable: true })
  amount!: number | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;

  @ManyToOne(() => Combat, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'combat_id' })
  combat!: Combat;
}
  • Step 6: Add consumedAt to HuntEncounter

In apps/api/src/hunting/entities/hunt-encounter.entity.ts, add the import and column:

import {
  Column,
  CreateDateColumn,
  Entity,
  JoinColumn,
  ManyToOne,
  PrimaryGeneratedColumn,
} from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { Hunt } from './hunt.entity';

@Entity({ name: 'hunt_encounters' })
export class HuntEncounter {
  @PrimaryGeneratedColumn('uuid', { name: 'id' })
  id!: string;

  @Column({ name: 'hunt_id', type: 'uuid' })
  huntId!: string;

  @Column({ name: 'monster_definition_id', type: 'uuid' })
  monsterDefinitionId!: string;

  @Column({ name: 'position', type: 'integer' })
  position!: number;

  // Set when a Combat is successfully created from this encounter. Prevents
  // one HuntEncounter from spawning more than one Combat (spec §7).
  @Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
  consumedAt!: Date | null;

  @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
  createdAt!: Date;

  // CASCADE (unlike the other FKs in this file, which use RESTRICT): a
  // HuntEncounter is owned/composed by its parent Hunt and has no
  // independent lifecycle, so it should be removed along with its Hunt.
  @ManyToOne(() => Hunt, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'hunt_id' })
  hunt!: Hunt;

  @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
  @JoinColumn({ name: 'monster_definition_id' })
  monster!: MonsterDefinition;
}
  • Step 7: Run the schema spec and the full existing suite

Run: npm run test --workspace=@ashen-realms/api -- combat-system.migration.spec.ts Expected: PASS (4/4).

Run: npm run test --workspace=@ashen-realms/api Expected: PASS — the hunting-system.migration.spec.ts and hunting.service.spec.ts fixtures build HuntEncounter object literals; adding an optional-looking (but required-by-type) consumedAt field only breaks compilation if TypeScript strictness demands it on literals. If tsc/Jest reports missing-property errors on existing HuntEncounter literals in hunting.service.spec.ts or hunting.controller.spec.ts, add consumedAt: null to those fixture literals.

  • Step 8: Commit
git add apps/api/src/combat apps/api/src/hunting/entities/hunt-encounter.entity.ts apps/api/src/database/migrations/combat-system.migration.spec.ts
git commit -m "feat(combat): add combat domain enums, entities, and encounter consumption field"

Task 2: Database migration for the combat schema

Files:

  • Create: apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts

Interfaces:

  • Consumes: table/column names from Task 1's entities (combats, combat_events, hunt_encounters.consumed_at).

  • Produces: combats, combat_events tables and the three new Postgres enum types (combat_status_enum, combat_event_type_enum, combatant_enum), matching an already-running Postgres via npm run db:migrate.

  • Step 1: Write the migration

// apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts
import { MigrationInterface, QueryRunner } from 'typeorm';

export class CreateCombatSystem1788100000000 implements MigrationInterface {
  public async up(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query(
      'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
    );

    await queryRunner.query(
      "CREATE TYPE \"combat_status_enum\" AS ENUM ('ACTIVE', 'WON', 'LOST')",
    );
    await queryRunner.query(
      "CREATE TYPE \"combat_event_type_enum\" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')",
    );
    await queryRunner.query(
      "CREATE TYPE \"combatant_enum\" AS ENUM ('PLAYER', 'MONSTER')",
    );

    await queryRunner.query(`CREATE TABLE "combats" (
      "id" uuid NOT NULL DEFAULT gen_random_uuid(),
      "character_id" uuid NOT NULL,
      "hunt_encounter_id" uuid NOT NULL,
      "monster_definition_id" uuid NOT NULL,
      "status" "combat_status_enum" NOT NULL,
      "round" integer NOT NULL,
      "player_max_hp" integer NOT NULL,
      "player_current_hp" integer NOT NULL,
      "monster_max_hp" integer NOT NULL,
      "monster_current_hp" integer NOT NULL,
      "player_state" jsonb NOT NULL,
      "monster_state" jsonb NOT NULL,
      "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
      "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
      "completed_at" TIMESTAMP WITH TIME ZONE,
      CONSTRAINT "PK_combats" PRIMARY KEY ("id"),
      CONSTRAINT "FK_combats_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
      CONSTRAINT "FK_combats_hunt_encounter" FOREIGN KEY ("hunt_encounter_id") REFERENCES "hunt_encounters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
      CONSTRAINT "FK_combats_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
    )`);
    await queryRunner.query(
      'CREATE INDEX "IDX_combats_character" ON "combats" ("character_id")',
    );
    await queryRunner.query(
      'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
    );
    await queryRunner.query(
      'CREATE INDEX "IDX_combats_monster_definition" ON "combats" ("monster_definition_id")',
    );
    await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_combat_per_character"
      ON "combats" ("character_id")
      WHERE "status" = 'ACTIVE'`);

    await queryRunner.query(`CREATE TABLE "combat_events" (
      "id" uuid NOT NULL DEFAULT gen_random_uuid(),
      "combat_id" uuid NOT NULL,
      "round" integer NOT NULL,
      "sequence" integer NOT NULL,
      "type" "combat_event_type_enum" NOT NULL,
      "source" "combatant_enum" NOT NULL,
      "target" "combatant_enum" NOT NULL,
      "amount" integer,
      "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
      CONSTRAINT "PK_combat_events" PRIMARY KEY ("id"),
      CONSTRAINT "FK_combat_events_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION
    )`);
    await queryRunner.query(
      'CREATE INDEX "IDX_combat_events_combat" ON "combat_events" ("combat_id")',
    );
    await queryRunner.query(`CREATE UNIQUE INDEX "IDX_combat_events_combat_sequence"
      ON "combat_events" ("combat_id", "sequence")`);
  }

  public async down(queryRunner: QueryRunner): Promise<void> {
    await queryRunner.query('DROP INDEX "IDX_combat_events_combat_sequence"');
    await queryRunner.query('DROP INDEX "IDX_combat_events_combat"');
    await queryRunner.query('DROP TABLE "combat_events"');
    await queryRunner.query('DROP INDEX "IDX_active_combat_per_character"');
    await queryRunner.query('DROP INDEX "IDX_combats_monster_definition"');
    await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
    await queryRunner.query('DROP INDEX "IDX_combats_character"');
    await queryRunner.query('DROP TABLE "combats"');
    await queryRunner.query('DROP TYPE "combatant_enum"');
    await queryRunner.query('DROP TYPE "combat_event_type_enum"');
    await queryRunner.query('DROP TYPE "combat_status_enum"');
    await queryRunner.query(
      'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
    );
  }
}
  • Step 2: Compile it

Run: npm run build --workspace=@ashen-realms/api Expected: succeeds with no TypeScript errors.

  • Step 3: Run it against the dev database and verify it reverts cleanly

Run: npm run db:migrate Expected: CreateCombatSystem1788100000000 reported as executed successfully; no errors.

Run: npm run db:revert Expected: reverts cleanly, dropping combat_events, combats, the three enum types, and the hunt_encounters.consumed_at column with no leftover objects.

Run: npm run db:migrate again to leave the database in the post-migration state for later manual testing.

  • Step 4: Commit
git add apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts
git commit -m "feat(combat): add CreateCombatSystem migration"

Task 3: Pure damage formula

Files:

  • Create: apps/api/src/combat/combat-damage.ts
  • Test: apps/api/src/combat/combat-damage.spec.ts

Interfaces:

  • Produces: calculateDamage(attacker: { attack: number; weaponDamage?: number }, targetArmor: number): number — used by Task 4's CombatEngineService.

  • Step 1: Write the failing tests

// apps/api/src/combat/combat-damage.spec.ts
import { calculateDamage } from './combat-damage';

describe('calculateDamage', () => {
  it('applies the established armor mitigation formula', () => {
    // raw = 12 + 15 = 27; 27 * 60 / (60 + 20) = 20.25 -> rounds to 20
    expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20)).toBe(20);
  });

  it('never returns less than 1 damage, even against extreme armor', () => {
    expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000)).toBe(1);
  });

  it('treats an attacker with no weaponDamage as having attack alone as its raw damage', () => {
    // raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
    expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts Expected: FAIL — cannot find module ./combat-damage.

  • Step 3: Implement it
// apps/api/src/combat/combat-damage.ts
export interface DamageAttacker {
  attack: number;
  weaponDamage?: number;
}

const ARMOR_MITIGATION_CONSTANT = 60;

export function calculateDamage(attacker: DamageAttacker, targetArmor: number): 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));
}
  • Step 4: Run to confirm it passes

Run: npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts Expected: PASS (3/3).

  • Step 5: Commit
git add apps/api/src/combat/combat-damage.ts apps/api/src/combat/combat-damage.spec.ts
git commit -m "feat(combat): add deterministic damage formula"

Task 4: Combat engine (pure round resolution)

Files:

  • Create: apps/api/src/combat/combat-engine.types.ts
  • Create: apps/api/src/combat/combat-engine.service.ts
  • Test: apps/api/src/combat/combat-engine.service.spec.ts

Interfaces:

  • Consumes: calculateDamage from Task 3; CombatStatus, CombatAction, CombatEventType, Combatant from Task 1.

  • Produces: CombatEngineState { status, round, player: CombatEngineCombatant, monster: CombatEngineCombatant }, CombatEngineCombatant { currentHp, maxHp, stats: { attack, weaponDamage?, armor } }, CombatActionInput { action: CombatAction }, CombatEngineEvent { source, target, type, amount? }, CombatEngineResult { state, events }; CombatEngineService.resolveAction(state, input): CombatEngineResult — consumed by Task 7's CombatService.

  • Step 1: Write the failing tests

// apps/api/src/combat/combat-engine.service.spec.ts
import { CombatAction } from './combat-action.enum';
import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum';

function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState {
  return {
    status: CombatStatus.ACTIVE,
    round: 1,
    player: {
      currentHp: 100,
      maxHp: 100,
      stats: { attack: 6, weaponDamage: 8, armor: 6 },
    },
    monster: {
      currentHp: 45,
      maxHp: 45,
      stats: { attack: 5, armor: 0 },
    },
    ...overrides,
  };
}

describe('CombatEngineService', () => {
  let engine: CombatEngineService;

  beforeEach(() => {
    engine = new CombatEngineService();
  });

  it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
    const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });

    // raw = 6 + 8 = 14; armor 0 -> 14 mitigated
    expect(result.state.monster.currentHp).toBe(45 - 14);
    expect(result.events[0]).toEqual({
      source: Combatant.PLAYER,
      target: Combatant.MONSTER,
      type: CombatEventType.DAMAGE,
      amount: 14,
    });
  });

  it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
    const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });

    // raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
    expect(result.state.player.currentHp).toBe(100 - 5);
    expect(result.events[1]).toEqual({
      source: Combatant.MONSTER,
      target: Combatant.PLAYER,
      type: CombatEventType.DAMAGE,
      amount: 5,
    });
    expect(result.state.status).toBe(CombatStatus.ACTIVE);
    expect(result.state.round).toBe(2);
  });

  it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => {
    const state = baseState({
      monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } },
    });

    const result = engine.resolveAction(state, { action: CombatAction.ATTACK });

    expect(result.state.monster.currentHp).toBe(0);
    expect(result.state.status).toBe(CombatStatus.WON);
    expect(result.state.round).toBe(1);
    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('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
    const state = baseState({
      player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } },
    });

    const result = engine.resolveAction(state, { action: CombatAction.ATTACK });

    expect(result.state.player.currentHp).toBe(0);
    expect(result.state.status).toBe(CombatStatus.LOST);
    expect(result.events).toEqual([
      { source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
      { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 },
      { source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST },
    ]);
  });

  it('produces the exact same result for the same state and action (determinism)', () => {
    const state = baseState();

    const first = engine.resolveAction(state, { action: CombatAction.ATTACK });
    const second = engine.resolveAction(state, { action: CombatAction.ATTACK });

    expect(first).toEqual(second);
  });

  it('throws UnsupportedCombatActionError for an action it does not implement', () => {
    expect(() =>
      engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
    ).toThrow(UnsupportedCombatActionError);
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts Expected: FAIL — cannot find module ./combat-engine.service.

  • Step 3: Write the engine types
// apps/api/src/combat/combat-engine.types.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';

export interface CombatEngineCombatantStats {
  attack: number;
  weaponDamage?: number;
  armor: number;
}

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 4: Implement the engine
// apps/api/src/combat/combat-engine.service.ts
import { Injectable } from '@nestjs/common';
import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
  CombatActionInput,
  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}`);
  }
}

@Injectable()
export class CombatEngineService {
  resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
    switch (input.action) {
      case CombatAction.ATTACK:
        return this.resolveAttack(state);
      default:
        throw new UnsupportedCombatActionError(input.action);
    }
  }

  private resolveAttack(state: CombatEngineState): CombatEngineResult {
    const events: CombatEngineEvent[] = [];
    const player = { ...state.player };
    const monster = { ...state.monster };

    const playerDamage = calculateDamage(player.stats, monster.stats.armor);
    monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
    events.push({
      source: Combatant.PLAYER,
      target: Combatant.MONSTER,
      type: CombatEventType.DAMAGE,
      amount: playerDamage,
    });

    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,
      };
    }

    const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
    player.currentHp = Math.max(0, player.currentHp - monsterDamage);
    events.push({
      source: Combatant.MONSTER,
      target: Combatant.PLAYER,
      type: CombatEventType.DAMAGE,
      amount: monsterDamage,
    });

    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,
    };
  }
}
  • Step 5: Run to confirm it passes

Run: npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts Expected: PASS (6/6).

  • Step 6: Commit
git add apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts
git commit -m "feat(combat): add deterministic combat engine for ATTACK resolution"

Task 5: CharacterCombatStatsService (temporary equipment stand-in)

Files:

  • Create: apps/api/src/characters/character-combat-stats.service.ts
  • Test: apps/api/src/characters/character-combat-stats.service.spec.ts

Interfaces:

  • Consumes: Character entity (baseHp, baseAttack).

  • Produces: CharacterCombatStats { maxHp, attack, weaponDamage, armor }; CharacterCombatStatsService.getStats(character: Character): CharacterCombatStats — consumed by Task 7's CombatService.

  • Step 1: Write the failing test

// apps/api/src/characters/character-combat-stats.service.spec.ts
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { Character } from './entities/character.entity';

describe('CharacterCombatStatsService', () => {
  it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
    const service = new CharacterCombatStatsService();
    const character = { baseHp: 100, baseAttack: 6 } as Character;

    expect(service.getStats(character)).toEqual({
      maxHp: 100,
      attack: 6,
      weaponDamage: 8,
      armor: 6,
    });
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/api -- character-combat-stats.service.spec.ts Expected: FAIL — cannot find module ./character-combat-stats.service.

  • Step 3: Implement it
// apps/api/src/characters/character-combat-stats.service.ts
import { Injectable } from '@nestjs/common';
import { Character } from './entities/character.entity';

export interface CharacterCombatStats {
  maxHp: number;
  attack: number;
  weaponDamage: number;
  armor: number;
}

// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
// stand in for the starting weapon/armor until Slice 0.5 introduces real
// equipment. Replacing them there must not change this method's signature
// or the combat API it feeds (spec §10).
const TEMPORARY_WEAPON_DAMAGE = 8;
const TEMPORARY_ARMOR = 6;

@Injectable()
export class CharacterCombatStatsService {
  getStats(character: Character): CharacterCombatStats {
    return {
      maxHp: character.baseHp,
      attack: character.baseAttack,
      weaponDamage: TEMPORARY_WEAPON_DAMAGE,
      armor: TEMPORARY_ARMOR,
    };
  }
}
  • Step 4: Run to confirm it passes

Run: npm run test --workspace=@ashen-realms/api -- character-combat-stats.service.spec.ts Expected: PASS (1/1).

  • Step 5: Commit
git add apps/api/src/characters/character-combat-stats.service.ts apps/api/src/characters/character-combat-stats.service.spec.ts
git commit -m "feat(characters): add temporary combat-stats stand-in for equipment"

Task 6: Combat domain errors

Files:

  • Create: apps/api/src/combat/combat.errors.ts

Interfaces:

  • Produces: CombatDomainError (extends HttpException, has .code: CombatErrorCode) and factories huntEncounterNotFound(), huntEncounterAlreadyConsumed(), invalidHuntEncounter(), characterTravelling(), combatAlreadyActive(), combatNotFound(), combatAlreadyFinished(), combatStateInvalid(); re-exports characterNotFound from ../travel/travel.errors — all consumed by Task 7's CombatService.

This module mirrors apps/api/src/travel/travel.errors.ts and apps/api/src/hunting/hunting.errors.ts exactly, so it has no dedicated spec file (neither of those does) — its codes/statuses are exercised through CombatService's tests in Task 7.

  • Step 1: Implement it
// apps/api/src/combat/combat.errors.ts
import { HttpException, HttpStatus } from '@nestjs/common';

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';

export class CombatDomainError extends HttpException {
  constructor(
    public readonly code: CombatErrorCode,
    status: HttpStatus,
    message: string,
  ) {
    super({ statusCode: status, code, message }, status);
  }
}

export function huntEncounterNotFound(): CombatDomainError {
  return new CombatDomainError(
    'HUNT_ENCOUNTER_NOT_FOUND',
    HttpStatus.NOT_FOUND,
    'This encounter could not be found.',
  );
}

export function huntEncounterAlreadyConsumed(): CombatDomainError {
  return new CombatDomainError(
    'HUNT_ENCOUNTER_ALREADY_CONSUMED',
    HttpStatus.CONFLICT,
    'This encounter has already been used to start a combat.',
  );
}

export function invalidHuntEncounter(): CombatDomainError {
  return new CombatDomainError(
    'INVALID_HUNT_ENCOUNTER',
    HttpStatus.BAD_REQUEST,
    'This encounter is not valid for the current character.',
  );
}

export function characterTravelling(): CombatDomainError {
  return new CombatDomainError(
    'CHARACTER_TRAVELLING',
    HttpStatus.CONFLICT,
    'The character cannot fight while travelling.',
  );
}

export function combatAlreadyActive(): CombatDomainError {
  return new CombatDomainError(
    'COMBAT_ALREADY_ACTIVE',
    HttpStatus.CONFLICT,
    'The character already has an active combat.',
  );
}

export function combatNotFound(): CombatDomainError {
  return new CombatDomainError(
    'COMBAT_NOT_FOUND',
    HttpStatus.NOT_FOUND,
    'This combat could not be found.',
  );
}

export function combatAlreadyFinished(): CombatDomainError {
  return new CombatDomainError(
    'COMBAT_ALREADY_FINISHED',
    HttpStatus.CONFLICT,
    'This combat has already finished.',
  );
}

export function combatStateInvalid(): CombatDomainError {
  return new CombatDomainError(
    'COMBAT_STATE_INVALID',
    HttpStatus.INTERNAL_SERVER_ERROR,
    'The persisted combat references unavailable data.',
  );
}

export { characterNotFound } from '../travel/travel.errors';
  • Step 2: Compile it

Run: npm run build --workspace=@ashen-realms/api Expected: succeeds (this file has no consumers yet, but must type-check standalone).

  • Step 3: Commit
git add apps/api/src/combat/combat.errors.ts
git commit -m "feat(combat): add combat domain errors"

Task 7: CombatService — create, read, and act on combats

Files:

  • Create: apps/api/src/combat/combat.service.ts
  • Test: apps/api/src/combat/combat.service.spec.ts

Interfaces:

  • Consumes: CombatEngineService.resolveAction (Task 4), CharacterCombatStatsService.getStats (Task 5), error factories (Task 6), TravelService.completeTravelIfDue (existing), entities from Task 1.

  • Produces: CombatDto { id, status, round, player: {name, maxHp, currentHp}, monster: {key, name, level, maxHp, currentHp, artworkPath}, events: CombatEventDto[] }; CombatService.startCombat(characterId, encounterId): Promise<CombatDto>, .getCombat(characterId, combatId): Promise<CombatDto>, .performAction(characterId, combatId, action: CombatAction): Promise<CombatDto> — consumed by Task 8's controllers.

  • Step 1: Write the failing test suite

// apps/api/src/combat/combat.service.spec.ts
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatDomainError } from './combat.errors';
import { CombatService } from './combat.service';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';

const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';

interface FakeState {
  characters: Character[];
  hunts: Hunt[];
  huntEncounters: HuntEncounter[];
  monsters: MonsterDefinition[];
  combats: Combat[];
  combatEvents: CombatEvent[];
}

class FakeRepository<T extends { id: string }> {
  constructor(
    private readonly state: FakeState,
    private readonly target: EntityTarget<T>,
    private readonly inTransaction: boolean,
    private readonly dataSource: FakeDataSource,
  ) {}

  findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> {
    if (options.lock) {
      if (!this.inTransaction) {
        throw new Error('Pessimistic locks require a transaction');
      }
      this.dataSource.locks.push({ target: this.target, mode: options.lock.mode });
    }
    return Promise.resolve(this.rows().find((row) => this.matches(row, options.where)) ?? null);
  }

  findOneBy(where: Partial<T>): Promise<T | null> {
    return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
  }

  find(options: {
    where: Partial<T>;
    order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
  }): Promise<T[]> {
    const matched = this.rows().filter((row) => this.matches(row, options.where));
    const orderKey = options.order ? (Object.keys(options.order)[0] as keyof T) : undefined;
    if (orderKey) {
      const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
      matched.sort((a, b) => {
        if (a[orderKey] === b[orderKey]) return 0;
        return a[orderKey] > b[orderKey] ? direction : -direction;
      });
    }
    return Promise.resolve(matched);
  }

  count(options: { where: Partial<T> }): Promise<number> {
    return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
  }

  create(values: Partial<T>): T {
    return { ...values } as T;
  }

  save(entity: T): Promise<T> {
    if (!entity.id) {
      entity.id = this.dataSource.nextId(this.targetName());
    }
    const rows = this.rows();
    const index = rows.findIndex((row) => row.id === entity.id);
    if (index === -1) {
      rows.push(entity);
    } else {
      rows[index] = entity;
    }
    return Promise.resolve(entity);
  }

  private rows(): T[] {
    if (this.target === Character) return this.state.characters as T[];
    if (this.target === Hunt) return this.state.hunts as T[];
    if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
    if (this.target === MonsterDefinition) return this.state.monsters as T[];
    if (this.target === Combat) return this.state.combats as T[];
    if (this.target === CombatEvent) return this.state.combatEvents as T[];
    throw new Error(`Unsupported repository ${this.targetName()}`);
  }

  private matches(row: T, where: Partial<T>): boolean {
    return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
  }

  private targetName(): string {
    return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
  }
}

class FakeEntityManager {
  constructor(
    private readonly state: FakeState,
    private readonly dataSource: FakeDataSource,
  ) {}

  getRepository<T extends { id: string }>(target: EntityTarget<T>) {
    return new FakeRepository(this.state, target, true, this.dataSource);
  }
}

class FakeDataSource {
  readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
  private readonly idCounters = new Map<string, number>();

  constructor(public state: FakeState) {}

  getRepository<T extends { id: string }>(target: EntityTarget<T>) {
    return new FakeRepository(this.state, target, false, this);
  }

  async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
    const draft = structuredClone(this.state);
    const result = await work(new FakeEntityManager(draft, this) as unknown as EntityManager);
    this.state = draft;
    return result;
  }

  nextId(targetName: string): string {
    const next = (this.idCounters.get(targetName) ?? 0) + 1;
    this.idCounters.set(targetName, next);
    return `${targetName.toLowerCase()}-generated-${next}`;
  }
}

function character(overrides: Partial<Character> = {}): Character {
  return {
    id: CHARACTER_ID,
    name: 'Aric Duskwalker',
    level: 1,
    experience: 0,
    baseHp: 100,
    baseAttack: 6,
    currentHp: 100,
    currentLocationId: 'location-1',
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as Character;
}

function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
  return {
    id: MONSTER_ID,
    key: 'ash-rat',
    name: 'Aschenratte',
    level: 1,
    maxHp: 45,
    attack: 5,
    armor: 0,
    experienceReward: 8,
    silverMin: 4,
    silverMax: 7,
    artworkPath: '/images/monsters/ash-rat.png',
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    updatedAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as MonsterDefinition;
}

function hunt(overrides: Partial<Hunt> = {}): Hunt {
  return {
    id: HUNT_ID,
    characterId: CHARACTER_ID,
    locationId: 'location-1',
    status: HuntStatus.ACTIVE,
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as Hunt;
}

function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
  return {
    id: ENCOUNTER_ID,
    huntId: HUNT_ID,
    monsterDefinitionId: MONSTER_ID,
    position: 0,
    consumedAt: null,
    createdAt: new Date('2026-08-18T09:00:00.000Z'),
    ...overrides,
  } as HuntEncounter;
}

function createState(overrides: Partial<FakeState> = {}): FakeState {
  return {
    characters: [character()],
    hunts: [hunt()],
    huntEncounters: [huntEncounter()],
    monsters: [monster()],
    combats: [],
    combatEvents: [],
    ...overrides,
  };
}

function fakeTravelService(status: 'IDLE' | 'TRAVELLING' = 'IDLE'): TravelService {
  return {
    completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
  } as unknown as TravelService;
}

function createService(options: { state?: FakeState; travelService?: TravelService } = {}) {
  const state = options.state ?? createState();
  const dataSource = new FakeDataSource(state);
  const travelService = options.travelService ?? fakeTravelService();
  const combatEngine = new CombatEngineService();
  const characterCombatStats = new CharacterCombatStatsService();
  const service = new CombatService(
    dataSource as unknown as DataSource,
    travelService,
    combatEngine,
    characterCombatStats,
  );
  return { dataSource, service, travelService };
}

async function expectCombatDomainError(promise: Promise<unknown>, code: string): Promise<void> {
  let error: unknown;
  try {
    await promise;
  } catch (cause) {
    error = cause;
  }
  expect(error).toBeInstanceOf(CombatDomainError);
  if (!(error instanceof CombatDomainError)) {
    throw new Error('Expected CombatDomainError');
  }
  expect(error.code).toBe(code);
}

describe('CombatService', () => {
  describe('startCombat', () => {
    it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
      const { dataSource, service } = createService();

      const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);

      expect(combat.status).toBe('ACTIVE');
      expect(combat.round).toBe(1);
      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',
      });
      expect(combat.events).toEqual([]);
      expect(dataSource.state.combats).toHaveLength(1);
      expect(dataSource.state.combats[0]).toMatchObject({
        characterId: CHARACTER_ID,
        huntEncounterId: ENCOUNTER_ID,
        monsterDefinitionId: MONSTER_ID,
        status: CombatStatus.ACTIVE,
        playerState: { attack: 6, weaponDamage: 8, armor: 6 },
        monsterState: { attack: 5, armor: 0 },
      });
    });

    it('marks the encounter as consumed', async () => {
      const { dataSource, service } = createService();

      await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);

      expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
    });

    it('rejects an unknown encounter id', async () => {
      const { service } = createService();

      await expectCombatDomainError(
        service.startCombat(CHARACTER_ID, 'unknown-id'),
        'HUNT_ENCOUNTER_NOT_FOUND',
      );
    });

    it('rejects an already-consumed encounter, and does not create a second combat', async () => {
      const state = createState({
        huntEncounters: [huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') })],
      });
      const { dataSource, service } = createService({ state });

      await expectCombatDomainError(
        service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
        'HUNT_ENCOUNTER_ALREADY_CONSUMED',
      );
      expect(dataSource.state.combats).toHaveLength(0);
    });

    it('rejects an encounter belonging to a different character', async () => {
      const state = createState({
        characters: [character(), character({ id: OTHER_CHARACTER_ID })],
      });
      const { service } = createService({ state });

      await expectCombatDomainError(
        service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
        'INVALID_HUNT_ENCOUNTER',
      );
    });

    it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
      const state = createState({ hunts: [hunt({ status: HuntStatus.SUPERSEDED })] });
      const { service } = createService({ state });

      await expectCombatDomainError(
        service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
        'INVALID_HUNT_ENCOUNTER',
      );
    });

    it('rejects starting combat while the character is travelling', async () => {
      const { service } = createService({ travelService: fakeTravelService('TRAVELLING') });

      await expectCombatDomainError(
        service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
        'CHARACTER_TRAVELLING',
      );
    });

    it('rejects starting a second combat while one is already ACTIVE', async () => {
      const state = createState({
        combats: [
          {
            id: 'combat-existing',
            characterId: CHARACTER_ID,
            huntEncounterId: 'other-encounter',
            monsterDefinitionId: MONSTER_ID,
            status: CombatStatus.ACTIVE,
            round: 1,
            playerMaxHp: 100,
            playerCurrentHp: 100,
            monsterMaxHp: 45,
            monsterCurrentHp: 45,
            playerState: { attack: 6, weaponDamage: 8, armor: 6 },
            monsterState: { attack: 5, armor: 0 },
            createdAt: new Date(),
            updatedAt: new Date(),
            completedAt: null,
          } as Combat,
        ],
      });
      const { service } = createService({ state });

      await expectCombatDomainError(
        service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
        'COMBAT_ALREADY_ACTIVE',
      );
    });

    it('locks the character and the active-combat lookup', async () => {
      const { dataSource, service } = createService();

      await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);

      expect(dataSource.locks).toEqual(
        expect.arrayContaining([
          { target: Character, mode: 'pessimistic_write' },
          { target: Combat, mode: 'pessimistic_write' },
        ]),
      );
    });
  });

  describe('performAction', () => {
    async function startedCombat(state = createState()) {
      const context = createService({ state });
      const combat = await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
      return { ...context, combatId: combat.id };
    }

    it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
      const { dataSource, service, combatId } = await startedCombat();

      const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);

      expect(result.status).toBe('ACTIVE');
      expect(result.round).toBe(2);
      expect(result.monster.currentHp).toBe(45 - 14);
      expect(result.player.currentHp).toBe(100 - 5);
      expect(dataSource.state.combats[0].round).toBe(2);
      expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
      expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
    });

    it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
      const { dataSource, service, combatId } = await startedCombat();

      await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
      await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);

      const events = dataSource.state.combatEvents
        .filter((event) => event.combatId === combatId)
        .sort((a, b) => a.sequence - b.sequence);
      expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
      expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
      expect(events[0]).toMatchObject({ source: 'PLAYER', target: 'MONSTER', type: 'DAMAGE', amount: 14 });
      expect(events[1]).toMatchObject({ source: 'MONSTER', target: 'PLAYER', type: 'DAMAGE', amount: 5 });
    });

    it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
      const state = createState({ monsters: [monster({ maxHp: 10 })] });
      const { dataSource, service, combatId } = await startedCombat(state);

      const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);

      expect(result.status).toBe('WON');
      expect(dataSource.state.combats[0].completedAt).not.toBeNull();
      await expectCombatDomainError(
        service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
        'COMBAT_ALREADY_FINISHED',
      );
    });

    it('rejects actions on an unknown combat id', async () => {
      const { service } = createService();

      await expectCombatDomainError(
        service.performAction(CHARACTER_ID, 'unknown-combat', CombatAction.ATTACK),
        'COMBAT_NOT_FOUND',
      );
    });

    it('rejects actions from a character who does not own the combat', async () => {
      const { service, combatId } = await startedCombat();

      await expectCombatDomainError(
        service.performAction(OTHER_CHARACTER_ID, combatId, CombatAction.ATTACK),
        'COMBAT_NOT_FOUND',
      );
    });

    it('locks the combat row for the duration of the action', async () => {
      const { dataSource, service, combatId } = await startedCombat();

      await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);

      expect(dataSource.locks).toEqual(
        expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
      );
    });
  });

  describe('getCombat', () => {
    it('returns the persisted state and ordered events after a refresh', async () => {
      const context = createService();
      const started = await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
      await context.service.performAction(CHARACTER_ID, started.id, CombatAction.ATTACK);

      const reloaded = await context.service.getCombat(CHARACTER_ID, started.id);

      expect(reloaded.round).toBe(2);
      expect(reloaded.monster.currentHp).toBe(31);
      expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
    });

    it('rejects an unknown combat id', async () => {
      const { service } = createService();

      await expectCombatDomainError(service.getCombat(CHARACTER_ID, 'unknown'), 'COMBAT_NOT_FOUND');
    });
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts Expected: FAIL — cannot find module ./combat.service.

  • Step 3: Implement CombatService
// apps/api/src/combat/combat.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import {
  characterNotFound,
  characterTravelling,
  combatAlreadyActive,
  combatAlreadyFinished,
  combatNotFound,
  combatStateInvalid,
  huntEncounterAlreadyConsumed,
  huntEncounterNotFound,
  invalidHuntEncounter,
} from './combat.errors';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';

export interface CombatPlayerDto {
  name: string;
  maxHp: number;
  currentHp: number;
}

export interface CombatMonsterDto {
  key: string;
  name: string;
  level: number;
  maxHp: number;
  currentHp: number;
  artworkPath: string;
}

export interface CombatEventDto {
  round: number;
  sequence: number;
  type: string;
  source: string;
  target: string;
  amount?: number;
}

export interface CombatDto {
  id: string;
  status: CombatStatus;
  round: number;
  player: CombatPlayerDto;
  monster: CombatMonsterDto;
  events: CombatEventDto[];
}

@Injectable()
export class CombatService {
  constructor(
    private readonly dataSource: DataSource,
    private readonly travelService: TravelService,
    private readonly combatEngine: CombatEngineService,
    private readonly characterCombatStats: CharacterCombatStatsService,
  ) {}

  async startCombat(characterId: string, encounterId: string): Promise<CombatDto> {
    const travel = await this.travelService.completeTravelIfDue(characterId);
    if (travel.status === TravelStatus.TRAVELLING) {
      throw characterTravelling();
    }

    return this.dataSource.transaction(async (manager) => {
      const characters = manager.getRepository(Character);
      const encounters = manager.getRepository(HuntEncounter);
      const hunts = manager.getRepository(Hunt);
      const monsters = manager.getRepository(MonsterDefinition);
      const combats = manager.getRepository(Combat);

      const character = await this.lockCharacter(characters, characterId);

      const encounter = await encounters.findOne({
        where: { id: encounterId },
        lock: { mode: 'pessimistic_write' },
      });
      if (!encounter) {
        throw huntEncounterNotFound();
      }
      if (encounter.consumedAt) {
        throw huntEncounterAlreadyConsumed();
      }

      const hunt = await hunts.findOneBy({ id: encounter.huntId });
      if (!hunt || hunt.characterId !== characterId || hunt.status !== HuntStatus.ACTIVE) {
        throw invalidHuntEncounter();
      }

      const existingActiveCombat = await combats.findOne({
        where: { characterId, status: CombatStatus.ACTIVE },
        lock: { mode: 'pessimistic_write' },
      });
      if (existingActiveCombat) {
        throw combatAlreadyActive();
      }

      const monster = await monsters.findOneBy({ id: encounter.monsterDefinitionId });
      if (!monster) {
        throw invalidHuntEncounter();
      }

      const playerStats = this.characterCombatStats.getStats(character);

      const combat = combats.create({
        characterId,
        huntEncounterId: encounter.id,
        monsterDefinitionId: monster.id,
        status: CombatStatus.ACTIVE,
        round: 1,
        playerMaxHp: playerStats.maxHp,
        playerCurrentHp: playerStats.maxHp,
        monsterMaxHp: monster.maxHp,
        monsterCurrentHp: monster.maxHp,
        playerState: {
          attack: playerStats.attack,
          weaponDamage: playerStats.weaponDamage,
          armor: playerStats.armor,
        },
        monsterState: { attack: monster.attack, armor: monster.armor },
        completedAt: null,
      });
      await combats.save(combat);

      encounter.consumedAt = new Date();
      await encounters.save(encounter);

      return this.toCombatDto(combat, character.name, monster, []);
    });
  }

  async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
    const combats = this.dataSource.getRepository(Combat);
    const combat = await combats.findOne({ where: { id: combatId, characterId } });
    if (!combat) {
      throw combatNotFound();
    }

    const [character, monster, events] = await Promise.all([
      this.loadCharacter(combat.characterId),
      this.loadMonster(combat.monsterDefinitionId),
      this.loadEvents(combat.id),
    ]);

    return this.toCombatDto(combat, character.name, monster, events);
  }

  async performAction(
    characterId: string,
    combatId: string,
    action: CombatAction,
  ): Promise<CombatDto> {
    return this.dataSource.transaction(async (manager) => {
      const combats = manager.getRepository(Combat);
      const combatEvents = manager.getRepository(CombatEvent);

      const combat = await combats.findOne({
        where: { id: combatId, characterId },
        lock: { mode: 'pessimistic_write' },
      });
      if (!combat) {
        throw combatNotFound();
      }
      if (combat.status !== CombatStatus.ACTIVE) {
        throw combatAlreadyFinished();
      }

      const actionRound = combat.round;
      const engineState = this.toEngineState(combat);
      const result = this.combatEngine.resolveAction(engineState, { action });

      combat.round = result.state.round;
      combat.status = result.state.status;
      combat.playerCurrentHp = result.state.player.currentHp;
      combat.monsterCurrentHp = result.state.monster.currentHp;
      if (combat.status !== CombatStatus.ACTIVE) {
        combat.completedAt = new Date();
      }
      await combats.save(combat);

      const startingSequence = await combatEvents.count({ where: { combatId: combat.id } });
      for (let index = 0; index < result.events.length; index += 1) {
        const event = result.events[index];
        const entity = combatEvents.create({
          combatId: combat.id,
          round: actionRound,
          sequence: startingSequence + index + 1,
          type: event.type,
          source: event.source,
          target: event.target,
          amount: event.amount ?? null,
        });
        await combatEvents.save(entity);
      }

      const [character, monster, events] = await Promise.all([
        this.loadCharacter(combat.characterId, manager.getRepository(Character)),
        this.loadMonster(combat.monsterDefinitionId, manager.getRepository(MonsterDefinition)),
        this.loadEvents(combat.id, combatEvents),
      ]);

      return this.toCombatDto(combat, character.name, monster, events);
    });
  }

  private async lockCharacter(
    characters: Repository<Character>,
    characterId: string,
  ): Promise<Character> {
    const character = await characters.findOne({
      where: { id: characterId },
      lock: { mode: 'pessimistic_write' },
    });
    if (!character) {
      throw characterNotFound();
    }
    return character;
  }

  private async loadCharacter(
    characterId: string,
    repo?: Repository<Character>,
  ): Promise<Character> {
    const characters = repo ?? this.dataSource.getRepository(Character);
    const character = await characters.findOneBy({ id: characterId });
    if (!character) {
      // combats.character_id is a RESTRICT FK; a persisted combat's
      // character is guaranteed to exist.
      throw combatStateInvalid();
    }
    return character;
  }

  private async loadMonster(
    monsterId: string,
    repo?: Repository<MonsterDefinition>,
  ): Promise<MonsterDefinition> {
    const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
    const monster = await monsters.findOneBy({ id: monsterId });
    if (!monster) {
      // combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
      throw combatStateInvalid();
    }
    return monster;
  }

  private loadEvents(combatId: string, repo?: Repository<CombatEvent>): Promise<CombatEvent[]> {
    const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
    return combatEvents.find({ where: { combatId }, order: { sequence: 'ASC' } });
  }

  private toEngineState(combat: Combat): CombatEngineState {
    return {
      status: combat.status,
      round: combat.round,
      player: {
        currentHp: combat.playerCurrentHp,
        maxHp: combat.playerMaxHp,
        stats: combat.playerState,
      },
      monster: {
        currentHp: combat.monsterCurrentHp,
        maxHp: combat.monsterMaxHp,
        stats: combat.monsterState,
      },
    };
  }

  private toCombatDto(
    combat: Combat,
    playerName: string,
    monster: MonsterDefinition,
    events: CombatEvent[],
  ): CombatDto {
    return {
      id: combat.id,
      status: combat.status,
      round: combat.round,
      player: {
        name: playerName,
        maxHp: combat.playerMaxHp,
        currentHp: combat.playerCurrentHp,
      },
      monster: {
        key: monster.key,
        name: monster.name,
        level: monster.level,
        maxHp: combat.monsterMaxHp,
        currentHp: combat.monsterCurrentHp,
        artworkPath: monster.artworkPath,
      },
      events: events.map((event) => ({
        round: event.round,
        sequence: event.sequence,
        type: event.type,
        source: event.source,
        target: event.target,
        amount: event.amount ?? undefined,
      })),
    };
  }
}
  • Step 4: Run to confirm it passes

Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts Expected: PASS (all cases). If any HuntEncounter/Hunt/Character/MonsterDefinition object-literal fixture elsewhere in the repo now fails to type-check because of the new consumedAt field, add consumedAt: null there too (see Task 1, Step 7).

  • Step 5: Run the full backend suite

Run: npm run test --workspace=@ashen-realms/api Expected: PASS.

  • Step 6: Commit
git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts
git commit -m "feat(combat): add CombatService orchestration and persistence"

Task 8: Combat controllers

Files:

  • Create: apps/api/src/combat/dto/combat-action.dto.ts
  • Create: apps/api/src/combat/combat.controller.ts
  • Create: apps/api/src/combat/hunt-encounter-attack.controller.ts
  • Test: apps/api/src/combat/combat.controller.spec.ts
  • Test: apps/api/src/combat/hunt-encounter-attack.controller.spec.ts

Interfaces:

  • Consumes: CombatService (Task 7).

  • Produces: POST /api/hunt-encounters/:encounterId/attack, GET /api/combats/:combatId, POST /api/combats/:combatId/actions — consumed by Task 9's CombatModule and Task 10's frontend GameApiService.

  • Step 1: Write the failing controller tests

// apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';

describe('HuntEncounterAttackController', () => {
  let app: INestApplication<App>;
  const startCombat = jest.fn();

  beforeEach(async () => {
    startCombat.mockReset();
    const module = await Test.createTestingModule({
      controllers: [HuntEncounterAttackController],
      providers: [{ provide: CombatService, useValue: { startCombat } }],
    }).compile();

    app = module.createNestApplication<App>();
    configureApplication(app);
    await app.init();
  });

  afterEach(async () => {
    await app.close();
  });

  it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => {
    const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
    startCombat.mockResolvedValue(combat);

    const response = await request(app.getHttpServer())
      .post('/api/hunt-encounters/encounter-1/attack')
      .expect(201);

    expect(startCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'encounter-1');
    expect(response.body).toEqual(combat);
  });
});
// apps/api/src/combat/combat.controller.spec.ts
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';

describe('CombatController', () => {
  let app: INestApplication<App>;
  const getCombat = jest.fn();
  const performAction = jest.fn();

  beforeEach(async () => {
    getCombat.mockReset();
    performAction.mockReset();
    const module = await Test.createTestingModule({
      controllers: [CombatController],
      providers: [{ provide: CombatService, useValue: { getCombat, performAction } }],
    }).compile();

    app = module.createNestApplication<App>();
    configureApplication(app);
    await app.init();
  });

  afterEach(async () => {
    await app.close();
  });

  it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
    const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
    getCombat.mockResolvedValue(combat);

    const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);

    expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1');
    expect(response.body).toEqual(combat);
  });

  it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
    const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] };
    performAction.mockResolvedValue(combat);

    const response = await request(app.getHttpServer())
      .post('/api/combats/combat-1/actions')
      .send({ action: 'ATTACK' })
      .expect(201);

    expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK');
    expect(response.body).toEqual(combat);
  });

  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();
  });

  it('rejects server-owned combat fields the client must never send', async () => {
    await request(app.getHttpServer())
      .post('/api/combats/combat-1/actions')
      .send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 })
      .expect(400);

    expect(performAction).not.toHaveBeenCalled();
  });
});
  • Step 2: Run to confirm they fail

Run: npm run test --workspace=@ashen-realms/api -- combat.controller.spec.ts hunt-encounter-attack.controller.spec.ts Expected: FAIL — cannot find modules ./combat.controller, ./hunt-encounter-attack.controller.

  • Step 3: Write the action DTO
// apps/api/src/combat/dto/combat-action.dto.ts
import { IsEnum } from 'class-validator';
import { CombatAction } from '../combat-action.enum';

export class CombatActionDto {
  @IsEnum(CombatAction)
  action!: CombatAction;
}
  • Step 4: Write the two controllers
// apps/api/src/combat/hunt-encounter-attack.controller.ts
import { Controller, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';

@Controller('hunt-encounters')
export class HuntEncounterAttackController {
  constructor(private readonly combatService: CombatService) {}

  @Post(':encounterId/attack')
  attack(@Param('encounterId') encounterId: string) {
    return this.combatService.startCombat(DEMO_CHARACTER_ID, encounterId);
  }
}
// apps/api/src/combat/combat.controller.ts
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatActionDto } from './dto/combat-action.dto';
import { CombatService } from './combat.service';

@Controller('combats')
export class CombatController {
  constructor(private readonly combatService: CombatService) {}

  @Get(':combatId')
  getCombat(@Param('combatId') combatId: string) {
    return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);
  }

  @Post(':combatId/actions')
  performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) {
    return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action);
  }
}
  • Step 5: Run to confirm they pass

Run: npm run test --workspace=@ashen-realms/api -- combat.controller.spec.ts hunt-encounter-attack.controller.spec.ts Expected: PASS (all cases).

  • Step 6: Commit
git add apps/api/src/combat/dto apps/api/src/combat/combat.controller.ts apps/api/src/combat/combat.controller.spec.ts apps/api/src/combat/hunt-encounter-attack.controller.ts apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
git commit -m "feat(combat): add HTTP controllers for starting, reading, and acting on combats"

Task 9: Wire the Combat module into the application

Files:

  • Create: apps/api/src/combat/combat.module.ts
  • Modify: apps/api/src/characters/characters.module.ts
  • Modify: apps/api/src/app.module.ts

Interfaces:

  • Consumes: everything from Tasks 18.

  • Produces: a fully wired CombatModule reachable from the running app.

  • Step 1: Export CharacterCombatStatsService from CharactersModule

// apps/api/src/characters/characters.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Character])],
  controllers: [CharactersController],
  providers: [CharactersService, CharacterCombatStatsService],
  exports: [CharacterCombatStatsService],
})
export class CharactersModule {}
  • Step 2: Create CombatModule
// apps/api/src/combat/combat.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelModule } from '../travel/travel.module';
import { CombatEngineService } from './combat-engine.service';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
import { Combat } from './entities/combat.entity';
import { CombatEvent } from './entities/combat-event.entity';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';

@Module({
  imports: [
    TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
    TravelModule,
    CharactersModule,
  ],
  controllers: [CombatController, HuntEncounterAttackController],
  providers: [CombatService, CombatEngineService],
})
export class CombatModule {}
  • Step 3: Register CombatModule in AppModule
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';

@Module({
  imports: [
    DatabaseModule,
    HealthModule,
    CharactersModule,
    TravelModule,
    WorldModule,
    HuntingModule,
    CombatModule,
  ],
})
export class AppModule {}
  • Step 4: Build and run the full backend test suite

Run: npm run build --workspace=@ashen-realms/api Expected: succeeds with no errors (confirms module wiring compiles — circular-import or missing-export mistakes surface here).

Run: npm run test --workspace=@ashen-realms/api Expected: PASS — full suite green.

  • Step 5: Manually verify the wired endpoints against the running dev database

Run: npm run dev:api (in one terminal), then in another terminal, using the seeded demo hunt/encounter ids from npm run db:seed:

curl -X POST http://localhost:3000/api/hunts
# copy an encounters[].id from the response, then:
curl -X POST http://localhost:3000/api/hunt-encounters/<encounterId>/attack
# copy the returned combat.id, then:
curl http://localhost:3000/api/combats/<combatId>
curl -X POST http://localhost:3000/api/combats/<combatId>/actions -H "Content-Type: application/json" -d '{"action":"ATTACK"}'

Expected: the attack endpoint returns an ACTIVE combat with full HP; the actions endpoint reduces monster/player HP per the damage formula and returns updated events; repeating the attack on the same encounter id returns 409 HUNT_ENCOUNTER_ALREADY_CONSUMED; starting a second combat while the first is active returns 409 COMBAT_ALREADY_ACTIVE.

  • Step 6: Commit
git add apps/api/src/combat/combat.module.ts apps/api/src/characters/characters.module.ts apps/api/src/app.module.ts
git commit -m "feat(combat): wire CombatModule into the application"

Task 10: Frontend Combat models and API methods

Files:

  • Modify: apps/web/src/app/core/api/game-api.models.ts
  • Modify: apps/web/src/app/core/api/game-api.service.ts
  • Test: apps/web/src/app/core/api/game-api.service.spec.ts

Interfaces:

  • Produces: Combat, CombatPlayer, CombatMonster, CombatEvent, CombatStatus, CombatEventType, CombatSide, CombatAction types; GameApiService.startCombat(encounterId): Observable<Combat>, .getCombat(combatId): Observable<Combat>, .performCombatAction(combatId, action): Observable<Combat> — consumed by Task 12's CombatStore.

  • Step 1: Write the failing tests (append to the existing spec)

Add to apps/web/src/app/core/api/game-api.service.spec.ts, inside the existing describe('GameApiService', ...) block:

it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
  service.startCombat('encounter-uuid').subscribe();

  const request = http.expectOne('/api/hunt-encounters/encounter-uuid/attack');
  expect(request.request.method).toBe('POST');
  expect(request.request.body).toEqual({});
  request.flush({});
});

it('gets a combat by id', () => {
  service.getCombat('combat-uuid').subscribe();

  const request = http.expectOne('/api/combats/combat-uuid');
  expect(request.request.method).toBe('GET');
  request.flush({});
});

it('posts only the action enum when performing a combat action', () => {
  service.performCombatAction('combat-uuid', 'ATTACK').subscribe();

  const request = http.expectOne('/api/combats/combat-uuid/actions');
  expect(request.request.method).toBe('POST');
  expect(request.request.body).toEqual({ action: 'ATTACK' });
  request.flush({});
});
  • Step 2: Run to confirm they fail

Run: npm run test --workspace=@ashen-realms/web -- game-api.service.spec.ts Expected: FAIL — service.startCombat is not a function.

  • Step 3: Add the Combat models

Append to apps/web/src/app/core/api/game-api.models.ts:

export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = '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;
}

export interface CombatMonster {
  key: string;
  name: string;
  level: number;
  maxHp: number;
  currentHp: number;
  artworkPath: string;
}

export interface Combat {
  id: string;
  status: CombatStatus;
  round: number;
  player: CombatPlayer;
  monster: CombatMonster;
  events: CombatEvent[];
}
  • Step 4: Add the API methods
// apps/web/src/app/core/api/game-api.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
  CharacterResponse,
  Combat,
  CombatAction,
  CurrentLocationResponse,
  CurrentTravel,
  HuntResult,
} from './game-api.models';

@Injectable({ providedIn: 'root' })
export class GameApiService {
  constructor(private readonly http: HttpClient) {}

  getCharacter(): Observable<CharacterResponse> {
    return this.http.get<CharacterResponse>('/api/characters/me');
  }

  getCurrentLocation(): Observable<CurrentLocationResponse> {
    return this.http.get<CurrentLocationResponse>('/api/world/current-location');
  }

  startTravel(targetLocationId: string): Observable<CurrentTravel> {
    return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
  }

  getCurrentTravel(): Observable<CurrentTravel> {
    return this.http.get<CurrentTravel>('/api/travel/current');
  }

  startHunt(): Observable<HuntResult> {
    return this.http.post<HuntResult>('/api/hunts', {});
  }

  startCombat(encounterId: string): Observable<Combat> {
    return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
  }

  getCombat(combatId: string): Observable<Combat> {
    return this.http.get<Combat>(`/api/combats/${combatId}`);
  }

  performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
    return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
  }
}
  • Step 5: Run to confirm they pass

Run: npm run test --workspace=@ashen-realms/web -- game-api.service.spec.ts Expected: PASS (all cases).

  • Step 6: Commit
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/core/api/game-api.service.ts apps/web/src/app/core/api/game-api.service.spec.ts
git commit -m "feat(combat): add frontend Combat models and API methods"

Task 11: Extract the shared monster-artwork helper

Files:

  • Create: apps/web/src/app/shared/monster-artwork.ts
  • Test: apps/web/src/app/shared/monster-artwork.spec.ts
  • Modify: apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts

Interfaces:

  • Produces: runtimeMonsterArtworkPath(artworkPath: string): string | undefined — consumed by EncounterCardComponent (existing) and Task 13's CombatPageComponent.

EncounterCardComponent currently hardcodes a small runtimeArtworkPaths lookup for the optimized JPEG derivatives (see apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts). CombatPageComponent needs the exact same lookup for the monster portrait in combat, so it is extracted once here rather than duplicated.

  • Step 1: Write the failing test
// apps/web/src/app/shared/monster-artwork.spec.ts
import { runtimeMonsterArtworkPath } from './monster-artwork';

describe('runtimeMonsterArtworkPath', () => {
  it('returns the optimized JPEG derivative for a known monster artwork path', () => {
    expect(runtimeMonsterArtworkPath('/images/monsters/ash-rat.png')).toBe(
      '/images/monsters/runtime/ash-rat-560.jpg',
    );
  });

  it('returns undefined for an artwork path with no runtime derivative', () => {
    expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined();
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/web -- monster-artwork.spec.ts Expected: FAIL — cannot find module ./monster-artwork.

  • Step 3: Implement it
// apps/web/src/app/shared/monster-artwork.ts
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
  '/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
  '/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};

export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
  return RUNTIME_MONSTER_ARTWORK[artworkPath];
}
  • Step 4: Refactor EncounterCardComponent to use it
// apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { HuntEncounter } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';

@Component({
  selector: 'app-encounter-card',
  imports: [DangerBadgeComponent],
  templateUrl: './encounter-card.component.html',
  styleUrl: './encounter-card.component.scss',
})
export class EncounterCardComponent {
  @Input({ required: true }) encounter!: HuntEncounter;
  @Output() readonly attack = new EventEmitter<string>();

  protected onAttack(): void {
    this.attack.emit(this.encounter.id);
  }

  protected runtimeArtworkPath(artworkPath: string): string | undefined {
    return runtimeMonsterArtworkPath(artworkPath);
  }
}
  • Step 5: Run to confirm everything still passes

Run: npm run test --workspace=@ashen-realms/web -- monster-artwork.spec.ts encounter-card.component.spec.ts Expected: PASS (all cases) — EncounterCardComponent's existing behavior is unchanged.

  • Step 6: Commit
git add apps/web/src/app/shared/monster-artwork.ts apps/web/src/app/shared/monster-artwork.spec.ts apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts
git commit -m "refactor(web): extract shared runtime monster-artwork lookup"

Task 12: CombatStore

Files:

  • Create: apps/web/src/app/features/combat/combat.store.ts
  • Test: apps/web/src/app/features/combat/combat.store.spec.ts

Interfaces:

  • Consumes: GameApiService.startCombat/getCombat/performCombatAction (Task 10).

  • Produces: CombatStore with combat: Signal<Combat | null>, loading: Signal<boolean>, actionPending: Signal<boolean>, error: Signal<string | null>, startCombat(encounterId): Promise<void>, loadCombat(combatId): Promise<void>, attack(): Promise<void>, clearError(): void — consumed by Task 13's CombatPageComponent and Task 14's HuntPageComponent.

  • Step 1: Write the failing tests

// apps/web/src/app/features/combat/combat.store.spec.ts
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { from, of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { CombatStore } from './combat.store';

const startedCombat: Combat = {
  id: 'combat-1',
  status: 'ACTIVE',
  round: 1,
  player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
  monster: {
    key: 'ash-rat',
    name: 'Aschenratte',
    level: 1,
    maxHp: 45,
    currentHp: 45,
    artworkPath: '/images/monsters/ash-rat.png',
  },
  events: [],
};

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 },
  ],
};

describe('CombatStore', () => {
  let api: {
    startCombat: ReturnType<typeof vi.fn>;
    getCombat: ReturnType<typeof vi.fn>;
    performCombatAction: ReturnType<typeof vi.fn>;
  };
  let store: CombatStore;

  beforeEach(() => {
    api = {
      startCombat: vi.fn(() => of(startedCombat)),
      getCombat: vi.fn(() => of(startedCombat)),
      performCombatAction: vi.fn(() => of(afterAttack)),
    };

    TestBed.configureTestingModule({
      providers: [CombatStore, { provide: GameApiService, useValue: api }],
    });
    store = TestBed.inject(CombatStore);
  });

  it('starts a combat and stores it', async () => {
    await store.startCombat('encounter-1');

    expect(api.startCombat).toHaveBeenCalledWith('encounter-1');
    expect(store.combat()).toEqual(startedCombat);
    expect(store.error()).toBeNull();
  });

  it('clears any previous combat and reports the mapped error when starting fails', async () => {
    api.startCombat.mockReturnValue(
      throwError(
        () =>
          new HttpErrorResponse({
            status: 409,
            error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
          }),
      ),
    );

    await store.startCombat('encounter-1');

    expect(store.combat()).toBeNull();
    expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
  });

  it('loads a combat by id', async () => {
    await store.loadCombat('combat-1');

    expect(api.getCombat).toHaveBeenCalledWith('combat-1');
    expect(store.combat()).toEqual(startedCombat);
  });

  it('reports the mapped error when loading an unknown combat', async () => {
    api.getCombat.mockReturnValue(
      throwError(
        () =>
          new HttpErrorResponse({
            status: 404,
            error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' },
          }),
      ),
    );

    await store.loadCombat('unknown');

    expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.');
  });

  it('sends only the ATTACK action and replaces combat with the server response', async () => {
    await store.startCombat('encounter-1');

    await store.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.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.attack();
    expect(store.actionPending()).toBe(true);
    const second = store.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.attack();

    expect(store.actionPending()).toBe(false);
    expect(store.combat()).toEqual(startedCombat);
    expect(store.error()).toBe('Netzwerkfehler');
  });

  it('clears the error message', async () => {
    api.startCombat.mockReturnValue(throwError(() => new Error('x')));
    await store.startCombat('encounter-1');
    expect(store.error()).not.toBeNull();

    store.clearError();

    expect(store.error()).toBeNull();
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/web -- combat.store.spec.ts Expected: FAIL — cannot find module ./combat.store.

  • Step 3: Implement CombatStore
// apps/web/src/app/features/combat/combat.store.ts
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';

const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';

// Mirrors the combat error codes returned by the combat endpoints.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
  HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.',
  HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
  INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
  CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
  COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
  COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
  COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
};

@Injectable({ providedIn: 'root' })
export class CombatStore {
  private readonly combatState = signal<Combat | null>(null);
  private readonly loadingState = signal(false);
  private readonly actionPendingState = signal(false);
  private readonly errorState = signal<string | null>(null);

  readonly combat = this.combatState.asReadonly();
  readonly loading = this.loadingState.asReadonly();
  readonly actionPending = this.actionPendingState.asReadonly();
  readonly error = this.errorState.asReadonly();

  constructor(private readonly api: GameApiService) {}

  async startCombat(encounterId: string): Promise<void> {
    this.loadingState.set(true);
    this.errorState.set(null);

    try {
      const combat = await firstValueFrom(this.api.startCombat(encounterId));
      this.combatState.set(combat);
    } catch (error) {
      this.combatState.set(null);
      this.errorState.set(this.toErrorMessage(error));
    } finally {
      this.loadingState.set(false);
    }
  }

  async loadCombat(combatId: string): Promise<void> {
    this.loadingState.set(true);
    this.errorState.set(null);

    try {
      const combat = await firstValueFrom(this.api.getCombat(combatId));
      this.combatState.set(combat);
    } catch (error) {
      this.errorState.set(this.toErrorMessage(error));
    } finally {
      this.loadingState.set(false);
    }
  }

  async attack(): Promise<void> {
    const combat = this.combatState();
    if (!combat || this.actionPendingState()) {
      return;
    }

    this.actionPendingState.set(true);
    this.errorState.set(null);

    try {
      const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
      this.combatState.set(updated);
    } catch (error) {
      this.errorState.set(this.toErrorMessage(error));
    } finally {
      this.actionPendingState.set(false);
    }
  }

  clearError(): void {
    this.errorState.set(null);
  }

  private toErrorMessage(error: unknown): string {
    if (error instanceof HttpErrorResponse) {
      const code = (error.error as { code?: string } | null)?.code;
      return (code && COMBAT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
    }

    return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
  }
}
  • Step 4: Run to confirm it passes

Run: npm run test --workspace=@ashen-realms/web -- combat.store.spec.ts Expected: PASS (all cases).

  • Step 5: Commit
git add apps/web/src/app/features/combat/combat.store.ts apps/web/src/app/features/combat/combat.store.spec.ts
git commit -m "feat(combat): add CombatStore"

Task 13: CombatPageComponent and route wiring

Files:

  • Create: apps/web/src/app/features/combat/combat-page/combat-page.component.ts
  • Create: apps/web/src/app/features/combat/combat-page/combat-page.component.html
  • Create: apps/web/src/app/features/combat/combat-page/combat-page.component.scss
  • Test: apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
  • Modify: apps/web/src/app/app.routes.ts
  • Delete: apps/web/src/app/features/combat/combat-placeholder-page.component.ts

Interfaces:

  • Consumes: CombatStore (Task 12), runtimeMonsterArtworkPath (Task 11).

  • Produces: the /combat/:combatId route.

  • Step 1: Write the failing component test

// apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from '../../../core/api/game-api.models';
import { CombatStore } from '../combat.store';
import { CombatPageComponent } from './combat-page.component';

const activeCombat: Combat = {
  id: 'combat-1',
  status: 'ACTIVE',
  round: 2,
  player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 },
  monster: {
    key: 'ash-rat',
    name: 'Aschenratte',
    level: 1,
    maxHp: 45,
    currentHp: 31,
    artworkPath: '/images/monsters/ash-rat.png',
  },
  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 },
  ],
};

describe('CombatPageComponent', () => {
  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>;
    attack: ReturnType<typeof vi.fn>;
  };
  let router: Router;

  async function setup(combat: Combat | null) {
    combatStore = {
      combat: signal(combat),
      loading: signal(false),
      actionPending: signal(false),
      error: signal<string | null>(null),
      loadCombat: vi.fn(() => Promise.resolve()),
      attack: vi.fn(() => Promise.resolve()),
    };

    await TestBed.configureTestingModule({
      imports: [CombatPageComponent],
      providers: [
        provideRouter([]),
        { provide: CombatStore, useValue: combatStore },
        {
          provide: ActivatedRoute,
          useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
        },
      ],
    }).compileComponents();

    router = TestBed.inject(Router);
    vi.spyOn(router, 'navigate').mockResolvedValue(true);

    const fixture = TestBed.createComponent(CombatPageComponent);
    fixture.detectChanges();
    return fixture;
  }

  it('loads the combat from the route param on init', async () => {
    await setup(activeCombat);

    expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
  });

  it('shows the player, monster, HP bars, round, and the Angriff action', async () => {
    const fixture = await setup(activeCombat);
    const element = fixture.nativeElement as HTMLElement;

    expect(element.textContent).toContain('Aric Duskwalker');
    expect(element.textContent).toContain('95 / 100');
    expect(element.textContent).toContain('Aschenratte');
    expect(element.textContent).toContain('31 / 45');
    expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2');
    expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
  });

  it('renders the structured events as readable German combat-log entries', async () => {
    const fixture = await setup(activeCombat);
    const element = fixture.nativeElement as HTMLElement;

    expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.');
    expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
  });

  it('calls combatStore.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.attack).toHaveBeenCalledOnce();
  });

  it('disables Angriff while an action is pending', async () => {
    const fixture = await setup(activeCombat);
    combatStore.actionPending.set(true);
    fixture.detectChanges();

    const element = fixture.nativeElement as HTMLElement;
    expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
  });

  it('shows the victory state and hides Angriff when the combat is WON', async () => {
    const fixture = await setup({ ...activeCombat, status: 'WON' });
    const element = fixture.nativeElement as HTMLElement;

    expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
    expect(element.textContent).toContain('Sieg');
    expect(element.querySelector('[data-combat-attack]')).toBeNull();
  });

  it('shows the defeat state and hides Angriff when the combat is LOST', async () => {
    const fixture = await setup({ ...activeCombat, status: 'LOST' });
    const element = fixture.nativeElement as HTMLElement;

    expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
    expect(element.textContent).toContain('Niederlage');
    expect(element.querySelector('[data-combat-attack]')).toBeNull();
  });

  it('navigates to /hunt from the victory screen', async () => {
    const fixture = await setup({ ...activeCombat, status: 'WON' });
    const element = fixture.nativeElement as HTMLElement;

    element.querySelector<HTMLButtonElement>('[data-combat-to-hunt]')?.click();

    expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
  });

  it('shows an error and retries loading the combat', async () => {
    const fixture = await setup(null);
    combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
    fixture.detectChanges();

    const element = fixture.nativeElement as HTMLElement;
    expect(element.querySelector('[role="alert"]')?.textContent).toContain(
      'Dieser Kampf wurde nicht gefunden.',
    );
    element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();

    expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
  });
});
  • Step 2: Run to confirm it fails

Run: npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts Expected: FAIL — cannot find module ./combat-page.component.

  • Step 3: Implement the component
// apps/web/src/app/features/combat/combat-page/combat-page.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { CombatEvent } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store';

interface CombatLogRound {
  round: number;
  events: CombatEvent[];
}

@Component({
  selector: 'app-combat-page',
  templateUrl: './combat-page.component.html',
  styleUrl: './combat-page.component.scss',
})
export class CombatPageComponent implements OnInit {
  protected readonly combatStore = inject(CombatStore);
  private readonly route = inject(ActivatedRoute);
  private readonly router = inject(Router);

  ngOnInit(): void {
    this.loadFromRoute();
  }

  protected attack(): void {
    void this.combatStore.attack();
  }

  protected retry(): void {
    this.loadFromRoute();
  }

  protected goToHunt(): void {
    void this.router.navigate(['/hunt']);
  }

  protected runtimeMonsterArtwork(artworkPath: string): string | undefined {
    return runtimeMonsterArtworkPath(artworkPath);
  }

  protected playerHpPercent(): number {
    const combat = this.combatStore.combat();
    return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
  }

  protected monsterHpPercent(): number {
    const combat = this.combatStore.combat();
    return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
  }

  protected logRounds(): CombatLogRound[] {
    const combat = this.combatStore.combat();
    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.combatStore.combat();
    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 === 'COMBAT_WON') {
      return `${monsterName} wurde besiegt.`;
    }

    return `${playerName} wurde im Kampf besiegt.`;
  }

  private loadFromRoute(): void {
    const combatId = this.route.snapshot.paramMap.get('combatId');
    if (combatId) {
      void this.combatStore.loadCombat(combatId);
    }
  }
}
  • Step 4: Write the template
<!-- apps/web/src/app/features/combat/combat-page/combat-page.component.html -->
<section class="combat-page" aria-label="Kampf">
  @if (combatStore.combat(); as combat) {
    <div class="combat-page__scene">
      <div class="combat-page__combatant combat-page__combatant--player">
        <img
          class="combat-page__portrait"
          src="/images/hud/runtime/CharacterIcon-128.png"
          [alt]="combat.player.name"
        />
        <div class="combat-page__info">
          <span class="combat-page__name">{{ combat.player.name }}</span>
          <div class="combat-page__hp" aria-label="Lebenspunkte">
            <span>{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
            <span class="combat-page__hp-track" aria-hidden="true">
              <span class="combat-page__hp-value" [style.inline-size.%]="playerHpPercent()"></span>
            </span>
          </div>
        </div>
      </div>

      <div class="combat-page__round" data-combat-round>Runde {{ combat.round }}</div>

      <div class="combat-page__combatant combat-page__combatant--monster">
        <picture>
          @if (runtimeMonsterArtwork(combat.monster.artworkPath); as runtimeArtwork) {
            <source [srcset]="runtimeArtwork" type="image/jpeg" />
          }
          <img
            class="combat-page__portrait"
            [src]="combat.monster.artworkPath"
            [alt]="combat.monster.name"
          />
        </picture>
        <div class="combat-page__info">
          <span class="combat-page__name">{{ combat.monster.name }}</span>
          <span class="combat-page__level">Stufe {{ combat.monster.level }}</span>
          <div class="combat-page__hp" aria-label="Lebenspunkte des Gegners">
            <span>{{ combat.monster.currentHp }} / {{ combat.monster.maxHp }}</span>
            <span class="combat-page__hp-track" aria-hidden="true">
              <span
                class="combat-page__hp-value combat-page__hp-value--monster"
                [style.inline-size.%]="monsterHpPercent()"
              ></span>
            </span>
          </div>
        </div>
      </div>
    </div>

    <div class="combat-page__actions">
      @if (combat.status === 'ACTIVE') {
        <button
          type="button"
          class="combat-page__attack"
          data-combat-attack
          [disabled]="combatStore.actionPending()"
          (click)="attack()"
        >
          Angriff
        </button>
      } @else if (combat.status === 'WON') {
        <section class="combat-page__result combat-page__result--won" data-combat-result="WON">
          <h2>Sieg</h2>
          <p>{{ combat.monster.name }} wurde besiegt.</p>
          <p class="combat-page__result-hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
          <button type="button" data-combat-to-hunt (click)="goToHunt()">Zur Jagd</button>
        </section>
      } @else {
        <section class="combat-page__result combat-page__result--lost" data-combat-result="LOST">
          <h2>Niederlage</h2>
          <p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
          <button type="button" data-combat-to-hunt (click)="goToHunt()">Zur Jagd</button>
        </section>
      }
    </div>

    <aside class="combat-page__log" aria-label="Kampfprotokoll">
      @for (round of logRounds(); track round.round) {
        <p class="combat-page__log-round">Runde {{ round.round }}</p>
        @for (event of round.events; track event.sequence) {
          <p class="combat-page__log-entry">{{ formatEvent(event) }}</p>
        }
      }
    </aside>
  } @else if (combatStore.loading()) {
    <p class="combat-page__loading" role="status">Kampf wird geladen…</p>
  }

  @if (combatStore.error(); as error) {
    <section class="combat-page__error" role="alert">
      <p>{{ error }}</p>
      <button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
    </section>
  }
</section>
  • Step 5: Write the stylesheet
// apps/web/src/app/features/combat/combat-page/combat-page.component.scss
:host {
  display: block;
  min-block-size: 100%;
}

.combat-page {
  display: grid;
  gap: var(--ar-space-4);
}

.combat-page__scene {
  position: relative;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: end;
  gap: var(--ar-space-4);
  min-block-size: clamp(18rem, 40vh, 26rem);
  padding: var(--ar-space-5);
  overflow: hidden;
  border: 1px solid var(--ar-border);
  background-color: #151718;
  background-image: url('/images/backgrounds/Aschestrasse.png');
  background-position: center;
  background-repeat: no-repeat;
  background-size: cover;
  box-shadow: var(--ar-shadow-raised);
}

@supports (
  background-image: image-set(
      url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
    )
) {
  .combat-page__scene {
    background-image: image-set(
      url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
    );
  }
}

.combat-page__scene::before {
  position: absolute;
  z-index: 0;
  inset: 0;
  content: '';
  background: linear-gradient(180deg, rgb(4 6 8 / 0.15), rgb(4 6 8 / 0.6));
  pointer-events: none;
}

.combat-page__combatant {
  position: relative;
  z-index: 1;
  display: grid;
  gap: var(--ar-space-2);
  justify-items: center;
  text-align: center;
}

.combat-page__combatant--player {
  justify-self: start;
}

.combat-page__combatant--monster {
  justify-self: end;
}

.combat-page__portrait {
  inline-size: clamp(6rem, 14vw, 10rem);
  block-size: clamp(6rem, 14vw, 10rem);
  object-fit: cover;
  border: 2px solid var(--ar-border-highlight);
  border-radius: var(--ar-radius-md);
  box-shadow: var(--ar-shadow-raised);
}

.combat-page__info {
  display: grid;
  gap: var(--ar-space-1);
  min-inline-size: 10rem;
  color: var(--ar-text);
  text-shadow: 0 0.1rem 0.4rem rgb(0 0 0 / 0.85);
}

.combat-page__name {
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 1.15rem;
}

.combat-page__level {
  color: var(--ar-gold);
  font-size: var(--ar-font-sm);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.combat-page__hp {
  display: grid;
  gap: var(--ar-space-1);
  font-size: var(--ar-font-sm);
}

.combat-page__hp-track {
  display: block;
  inline-size: 100%;
  block-size: 0.5rem;
  overflow: hidden;
  border: 1px solid var(--ar-border);
  border-radius: var(--ar-radius-sm);
  background: rgb(0 0 0 / 0.5);
}

.combat-page__hp-value {
  display: block;
  block-size: 100%;
  background: var(--ar-success);
}

.combat-page__hp-value--monster {
  background: var(--ar-danger);
}

.combat-page__round {
  position: relative;
  z-index: 1;
  align-self: start;
  justify-self: center;
  padding: var(--ar-space-1) var(--ar-space-3);
  border: 1px solid var(--ar-border-highlight);
  border-radius: var(--ar-radius-sm);
  color: var(--ar-gold);
  background: rgb(9 11 13 / 0.75);
  font-family: Georgia, 'Times New Roman', serif;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.combat-page__actions {
  display: grid;
  justify-items: center;
}

.combat-page__attack {
  padding: var(--ar-space-3) var(--ar-space-6);
  border: 1px solid var(--ar-border-highlight);
  border-radius: var(--ar-radius-sm);
  color: var(--ar-text);
  background: linear-gradient(180deg, #263b4b, #17232d);
  cursor: pointer;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 1.1rem;
  letter-spacing: 0.03em;
}

.combat-page__attack:hover:not(:disabled) {
  border-color: #d6b26b;
  background: linear-gradient(180deg, #315067, #1a2c3a);
}

.combat-page__attack:disabled {
  cursor: not-allowed;
  opacity: 0.6;
}

.combat-page__result {
  display: grid;
  gap: var(--ar-space-2);
  max-inline-size: 30rem;
  padding: var(--ar-space-5);
  border: 1px solid var(--ar-border-highlight);
  background: var(--ar-panel);
  box-shadow: var(--ar-shadow-raised);
  text-align: center;
}

.combat-page__result h2 {
  margin: 0;
  font-family: Georgia, 'Times New Roman', serif;
  font-size: 1.6rem;
  font-weight: 400;
}

.combat-page__result--won h2 {
  color: var(--ar-success);
}

.combat-page__result--lost h2 {
  color: var(--ar-danger);
}

.combat-page__result-hint {
  color: var(--ar-text-muted);
  font-size: var(--ar-font-sm);
  font-style: italic;
}

.combat-page__log {
  display: grid;
  gap: var(--ar-space-1);
  max-block-size: 16rem;
  padding: var(--ar-space-4);
  overflow-y: auto;
  border: 1px solid var(--ar-border);
  background: var(--ar-panel-muted);
  box-shadow: var(--ar-shadow-raised);
}

.combat-page__log-round {
  margin: var(--ar-space-2) 0 0;
  color: var(--ar-gold);
  font-size: var(--ar-font-sm);
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.combat-page__log-entry {
  margin: 0;
  color: var(--ar-text-muted);
}

.combat-page__loading,
.combat-page__error {
  padding: var(--ar-space-4);
  border: 1px solid var(--ar-border);
  background: var(--ar-panel);
  box-shadow: var(--ar-shadow-raised);
}

.combat-page__error {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--ar-space-4);
  border-color: var(--ar-danger);
}

.combat-page__error button {
  flex: 0 0 auto;
  padding: var(--ar-space-2) var(--ar-space-3);
  border: 1px solid var(--ar-border-highlight);
  border-radius: var(--ar-radius-sm);
  color: var(--ar-text);
  background: #1a2023;
  cursor: pointer;
}

@media (prefers-reduced-motion: no-preference) {
  .combat-page__hp-value {
    transition: inline-size var(--ar-motion-base);
  }
}

@media (width < 720px) {
  .combat-page__scene {
    grid-template-columns: 1fr;
    justify-items: center;
    text-align: center;
  }

  .combat-page__combatant--player,
  .combat-page__combatant--monster {
    justify-self: center;
  }
}
  • Step 6: Run the component test to confirm it passes

Run: npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts Expected: PASS (all cases).

  • Step 7: Wire the route and delete the placeholder
// apps/web/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component';

export const routes: Routes = [
  { path: '', pathMatch: 'full', redirectTo: 'world' },
  {
    path: '',
    component: AppShellComponent,
    children: [
      {
        path: 'world',
        loadComponent: () =>
          import('./features/world/world-page.component').then((module) => module.WorldPageComponent),
      },
      {
        path: 'hunt',
        loadComponent: () =>
          import('./features/hunting/hunt-page/hunt-page.component').then(
            (module) => module.HuntPageComponent,
          ),
      },
      {
        path: 'combat/:combatId',
        loadComponent: () =>
          import('./features/combat/combat-page/combat-page.component').then(
            (module) => module.CombatPageComponent,
          ),
      },
    ],
  },
  { path: '**', redirectTo: 'world' },
];

Delete apps/web/src/app/features/combat/combat-placeholder-page.component.ts (superseded by CombatPageComponent; it had no dedicated spec file).

  • Step 8: Run the full frontend build and test suite

Run: npm run build --workspace=@ashen-realms/web Expected: succeeds — confirms the deleted placeholder has no remaining references and the new route compiles.

Run: npm run test --workspace=@ashen-realms/web Expected: PASS — HuntPageComponent still uses its Slice 0.2 /combat/new placeholder flow at this point (Task 14 rewires it), so nothing here is broken yet.

  • Step 9: Commit
git add apps/web/src/app/features/combat/combat-page apps/web/src/app/app.routes.ts
git rm apps/web/src/app/features/combat/combat-placeholder-page.component.ts
git commit -m "feat(combat): add CombatPageComponent and replace the combat/new placeholder route"

Task 14: Wire HuntPageComponent to start real combats

Files:

  • Modify: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
  • Modify: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html
  • Modify: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts

Interfaces:

  • Consumes: CombatStore.startCombat/combat/error/clearError (Task 12).

  • Produces: Angreifen on the hunt page starts a real combat and navigates to /combat/:combatId, replacing the Slice 0.2 /combat/new placeholder flow.

  • Step 1: Update the failing/changed tests in hunt-page.component.spec.ts

Replace the whole file with:

// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';

const southGate: CurrentLocationResponse = {
  id: 'south-gate-id',
  key: 'south-gate',
  name: 'Südtor von Graufurt',
  description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
  regionKey: 'ashen-fields',
  minRecommendedLevel: 1,
  maxRecommendedLevel: 1,
  dangerLevel: 0,
  isSafe: true,
  huntingEnabled: false,
  artworkPath: '/images/backgrounds/Suedtor.png',
  connections: [],
  possibleMonsters: [],
};

const burnedRoad: CurrentLocationResponse = {
  ...southGate,
  id: 'burned-road-id',
  key: 'burned-road',
  name: 'Verbrannte Straße',
  description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
  isSafe: false,
  huntingEnabled: true,
  artworkPath: '/images/backgrounds/Aschestrasse.png',
  possibleMonsters: ['Aschenratte', 'Straßenräuber'],
  connections: [],
};

const threeEncounterHunt: HuntResult = {
  id: 'hunt-id',
  location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' },
  encounters: [
    {
      id: 'encounter-1',
      monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
      dangerRating: 'WEAK',
    },
    {
      id: 'encounter-2',
      monster: {
        key: 'road-bandit',
        name: 'Straßenräuber',
        level: 3,
        artworkPath: '/images/enemies/RoadBandit.png',
      },
      dangerRating: 'MATCH',
    },
    {
      id: 'encounter-3',
      monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
      dangerRating: 'WEAK',
    },
  ],
};

const startedCombat: Combat = {
  id: 'combat-2',
  status: 'ACTIVE',
  round: 1,
  player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
  monster: {
    key: 'road-bandit',
    name: 'Straßenräuber',
    level: 3,
    maxHp: 75,
    currentHp: 75,
    artworkPath: '/images/enemies/RoadBandit.png',
  },
  events: [],
};

describe('HuntPageComponent', () => {
  let worldStore: {
    currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
    load: ReturnType<typeof vi.fn>;
  };
  let huntingStore: {
    currentHunt: ReturnType<typeof signal<HuntResult | null>>;
    loading: ReturnType<typeof signal<boolean>>;
    error: ReturnType<typeof signal<string | null>>;
    encounters: () => HuntResult['encounters'];
    startHunt: ReturnType<typeof vi.fn>;
    refreshHunt: ReturnType<typeof vi.fn>;
    selectEncounter: ReturnType<typeof vi.fn>;
  };
  let combatStore: {
    combat: ReturnType<typeof signal<Combat | null>>;
    error: ReturnType<typeof signal<string | null>>;
    startCombat: ReturnType<typeof vi.fn>;
    clearError: ReturnType<typeof vi.fn>;
  };
  let router: Router;

  async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
    worldStore = { currentLocation: signal(location), load: vi.fn(() => Promise.resolve()) };
    const currentHunt = signal(hunt);
    huntingStore = {
      currentHunt,
      loading: signal(false),
      error: signal<string | null>(null),
      encounters: () => currentHunt()?.encounters ?? [],
      startHunt: vi.fn(() => Promise.resolve()),
      refreshHunt: vi.fn(() => Promise.resolve()),
      selectEncounter: vi.fn(),
    };
    combatStore = {
      combat: signal<Combat | null>(null),
      error: signal<string | null>(null),
      startCombat: vi.fn(() => Promise.resolve()),
      clearError: vi.fn(),
    };

    await TestBed.configureTestingModule({
      imports: [HuntPageComponent],
      providers: [
        provideRouter([]),
        { provide: WorldStore, useValue: worldStore },
        { provide: HuntingStore, useValue: huntingStore },
        { provide: CombatStore, useValue: combatStore },
      ],
    }).compileComponents();

    router = TestBed.inject(Router);
    vi.spyOn(router, 'navigate').mockResolvedValue(true);

    const fixture = TestBed.createComponent(HuntPageComponent);
    fixture.detectChanges();
    return fixture;
  }

  it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
    const fixture = await setup(southGate);
    const element = fixture.nativeElement as HTMLElement;

    expect(element.textContent).toContain('Keine Jagd verfügbar');
    expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
    expect(
      Array.from(element.querySelectorAll('button')).some(
        (button) => button.textContent?.trim() === 'Jagd beginnen',
      ),
    ).toBe(false);

    const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]');
    expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
    toWorldButton?.click();

    expect(router.navigate).toHaveBeenCalledWith(['/world']);
  });

  it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
    const fixture = await setup(burnedRoad);
    const element = fixture.nativeElement as HTMLElement;

    element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.click();

    expect(huntingStore.startHunt).toHaveBeenCalledOnce();
  });

  it('renders 3 encounter cards, duplicates included, with the correct data', async () => {
    const fixture = await setup(burnedRoad, threeEncounterHunt);
    const element = fixture.nativeElement as HTMLElement;

    const cards = element.querySelectorAll('app-encounter-card');
    expect(cards.length).toBe(3);
    expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/);
    expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2);
    expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1);
    expect(element.textContent).toContain('Stufe 1');
    expect(element.textContent).toContain('Stufe 3');
  });

  it('calls refreshHunt when Neu suchen is clicked', async () => {
    const fixture = await setup(burnedRoad, threeEncounterHunt);
    const element = fixture.nativeElement as HTMLElement;

    element.querySelector<HTMLButtonElement>('[data-hunt-refresh]')?.click();

    expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
  });

  it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
    combatStore.startCombat.mockImplementation(async () => {
      combatStore.combat.set(startedCombat);
    });
    const fixture = await setup(burnedRoad, threeEncounterHunt);
    const element = fixture.nativeElement as HTMLElement;

    const attackButtons = Array.from(element.querySelectorAll('button')).filter(
      (button) => button.textContent?.trim() === 'Angreifen',
    );
    expect(attackButtons.length).toBe(3);

    attackButtons[1].click();
    await Promise.resolve();
    await Promise.resolve();

    expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
    expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
    expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
  });

  it('does not navigate when starting the combat fails', async () => {
    const fixture = await setup(burnedRoad, threeEncounterHunt);
    const element = fixture.nativeElement as HTMLElement;

    const attackButtons = Array.from(element.querySelectorAll('button')).filter(
      (button) => button.textContent?.trim() === 'Angreifen',
    );
    attackButtons[0].click();
    await Promise.resolve();
    await Promise.resolve();

    expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
    expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
  });

  it('shows a combat-start error and dismisses it', async () => {
    const fixture = await setup(burnedRoad, threeEncounterHunt);
    combatStore.error.set('Du befindest dich bereits in einem Kampf.');
    fixture.detectChanges();

    const element = fixture.nativeElement as HTMLElement;
    const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
    expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
      true,
    );

    element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();

    expect(combatStore.clearError).toHaveBeenCalledOnce();
  });

  it('does not trigger a hunt automatically on page entry', async () => {
    await setup(burnedRoad);

    expect(huntingStore.startHunt).not.toHaveBeenCalled();
  });

  it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
    await setup(null);

    expect(worldStore.load).toHaveBeenCalledOnce();
  });

  it('does not call load again when a location is already present', async () => {
    await setup(burnedRoad);

    expect(worldStore.load).not.toHaveBeenCalled();
  });

  it('shows a loading state and disables the triggering action', async () => {
    const fixture = await setup(burnedRoad);
    huntingStore.loading.set(true);
    fixture.detectChanges();

    const element = fixture.nativeElement as HTMLElement;
    expect(element.textContent).toContain('Du suchst nach Spuren...');
    expect(element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.disabled).toBe(true);
  });

  it('displays a hunting error and retries via startHunt when there is no current hunt', async () => {
    const fixture = await setup(burnedRoad);
    huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.');
    fixture.detectChanges();

    const element = fixture.nativeElement as HTMLElement;
    expect(element.querySelector('[role="alert"]')?.textContent).toContain(
      'An diesem Ort gibt es keine Jagdgebiete.',
    );
    element.querySelector<HTMLButtonElement>('[data-hunt-retry]')?.click();

    expect(huntingStore.startHunt).toHaveBeenCalledOnce();
  });
});
  • Step 2: Run to confirm the new/changed cases fail

Run: npm run test --workspace=@ashen-realms/web -- hunt-page.component.spec.ts Expected: FAIL — No provider for CombatStore! and related errors, since HuntPageComponent doesn't inject it yet.

  • Step 3: Update the component
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';

@Component({
  selector: 'app-hunt-page',
  imports: [EncounterCardComponent],
  templateUrl: './hunt-page.component.html',
  styleUrl: './hunt-page.component.scss',
})
export class HuntPageComponent implements OnInit {
  protected readonly worldStore = inject(WorldStore);
  protected readonly huntingStore = inject(HuntingStore);
  protected readonly combatStore = inject(CombatStore);
  private readonly router = inject(Router);

  ngOnInit(): void {
    if (this.worldStore.currentLocation() === null) {
      void this.worldStore.load();
    }
  }

  protected startHunt(): void {
    void this.huntingStore.startHunt();
  }

  protected refreshHunt(): void {
    void this.huntingStore.refreshHunt();
  }

  protected retry(): void {
    if (this.huntingStore.currentHunt() === null) {
      void this.huntingStore.startHunt();
    } else {
      void this.huntingStore.refreshHunt();
    }
  }

  protected goToWorld(): void {
    void this.router.navigate(['/world']);
  }

  protected async onAttack(encounterId: string): Promise<void> {
    await this.combatStore.startCombat(encounterId);
    const combat = this.combatStore.combat();
    if (combat) {
      void this.router.navigate(['/combat', combat.id]);
    }
  }

  protected dismissCombatError(): void {
    this.combatStore.clearError();
  }
}
  • Step 4: Add the combat-error banner to the template

Add this block right after the existing huntingStore.error() block, before the closing </section>, in apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html:

  @if (combatStore.error(); as combatError) {
    <section class="hunt-page__error" role="alert">
      <p>{{ combatError }}</p>
      <button type="button" data-hunt-combat-dismiss (click)="dismissCombatError()">Schließen</button>
    </section>
  }

The (attack)="onAttack($event)" binding on app-encounter-card in the existing template already passes the encounter id straight into the updated onAttack, so no other template changes are needed.

  • Step 5: Run to confirm everything passes

Run: npm run test --workspace=@ashen-realms/web -- hunt-page.component.spec.ts Expected: PASS (all cases).

Run: npm run test --workspace=@ashen-realms/web Expected: PASS — full frontend suite green.

  • Step 6: Commit
git add apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
git commit -m "feat(combat): start real combats from the hunt page and navigate to /combat/:combatId"

Task 15: Full verification pass

Files: none (verification only).

  • Step 1: Build and test both apps from the repo root

Run: npm run build Expected: both apps/web and apps/api build with no errors.

Run: npm run test Expected: all workspaces pass.

  • Step 2: Confirm the migration is applied and reversible on the dev database

Run: npm run db:revert then npm run db:migrate then npm run db:seed Expected: CreateCombatSystem1788100000000 runs cleanly on top of the existing seeded data; the demo character, locations, and monsters from vertical-slice.seed.ts are intact afterward (npm run db:seed is idempotent — see its findOneBy guards).

  • Step 3: Browser walkthrough — Aschenratte victory

Run: npm run dev:api and npm run dev:web. In the browser:

  1. Navigate to /world, travel to Verbrannte Straße, open /hunt, click Jagd beginnen.
  2. Click Angreifen on an Aschenratte card.
  3. Confirm navigation to /combat/:combatId, with player (left) and monster (right) portraits, both HP bars, and Runde 1 visible.
  4. Click Angriff repeatedly.
  5. Confirm HP decreases each round, the combat log fills with German DAMAGE lines, and the round counter advances.
  6. Confirm the fight ends in Sieg once the Aschenratte's HP reaches 0, the Angriff button disappears, and the hint about rewards being processed later is shown.
  7. Click Zur Jagd and confirm it returns to /hunt without starting a new hunt automatically.
  • Step 4: Browser walkthrough — Straßenräuber, refresh, and re-use/second-combat rejections
  1. Start a new hunt, attack a Straßenräuber encounter.
  2. Mid-fight (after at least one round), refresh the browser on /combat/:combatId.
  3. Confirm the combat reloads from the server with the same round, HP, status, and combat log — nothing resets.
  4. Finish the fight to WON or LOST; refresh again and confirm the finished state (Sieg/Niederlage) still displays correctly and Angriff stays hidden.
  5. Go back to /hunt, start a fresh hunt, and attempt to POST the attack endpoint twice for the same encounter id (e.g. via the browser devtools network tab replaying the request, or curl) — confirm the second attempt returns 409 HUNT_ENCOUNTER_ALREADY_CONSUMED and the hunt page surfaces the mapped German error.
  6. While a combat is still ACTIVE, attempt to attack a different encounter from the same hunt (or replay the attack request for another encounter id) — confirm it returns 409 COMBAT_ALREADY_ACTIVE and the hunt page surfaces the mapped German error with a working Schließen button.
  • Step 5: Browser walkthrough — deterministic forced defeat

Using curl or devtools against the running dev API (bypassing the UI's inherent difficulty in losing to a weak Aschenratte): repeatedly POST /api/combats/:combatId/actions with {"action":"ATTACK"} against a Straßenräuber combat until the player's HP reaches 0. Confirm the response's status becomes LOST, GET /api/combats/:combatId continues to return LOST afterward, and the browser at /combat/:combatId shows Niederlage with Angriff hidden.

  • Step 6: Final sign-off

Confirm every item in spec §52 "Acceptance criteria" holds based on the runs above, then report the plan as complete. No commit is needed for this task (verification only); if any step above surfaces a defect, fix it as a new commit on top of the relevant earlier task before considering the slice done.


Handoff to Playable Slice 0.4

This plan deliberately stops at Combat.status = WON / LOST with no rewards. Slice 0.4 (loot, XP, silver) should read CombatService.getCombat/performAction in apps/api/src/combat/combat.service.ts and hook reward resolution off a WON combat without modifying CombatEngineService's pure round-resolution logic.