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

3997 lines
136 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.ts``ACTIVE | WON | LOST`
- `apps/api/src/combat/combat-action.enum.ts``ATTACK` (extensible)
- `apps/api/src/combat/combat-event-type.enum.ts``DAMAGE | COMBAT_WON | COMBAT_LOST`
- `apps/api/src/combat/combatant.enum.ts``PLAYER | 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.ts``Combat*` 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)**
```ts
// 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**
```ts
// apps/api/src/combat/combat-status.enum.ts
export enum CombatStatus {
ACTIVE = 'ACTIVE',
WON = 'WON',
LOST = 'LOST',
}
```
```ts
// 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',
}
```
```ts
// apps/api/src/combat/combat-event-type.enum.ts
export enum CombatEventType {
DAMAGE = 'DAMAGE',
COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST',
}
```
```ts
// apps/api/src/combat/combatant.enum.ts
export enum Combatant {
PLAYER = 'PLAYER',
MONSTER = 'MONSTER',
}
```
- [ ] **Step 4: Create the Combat entity**
```ts
// 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**
```ts
// 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:
```ts
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**
```bash
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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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);
});
});
```
```ts
// 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**
```ts
// 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**
```ts
// 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);
}
}
```
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```ts
// 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`:
```bash
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**
```bash
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:
```ts
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`:
```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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```bash
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**
```ts
// 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**
```ts
// 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**
```html
<!-- 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**
```scss
// 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**
```ts
// 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**
```bash
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:
```ts
// 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**
```ts
// 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`:
```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**
```bash
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.