diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..a7023bc --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,17 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "api", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:api"], + "port": 3000 + }, + { + "name": "web", + "runtimeExecutable": "npm", + "runtimeArgs": ["run", "dev:web"], + "port": 4200 + } + ] +} diff --git a/apps/web/public/assets/cursors/bg/click.png b/apps/web/public/assets/cursors/bg/click.png new file mode 100644 index 0000000..665430e Binary files /dev/null and b/apps/web/public/assets/cursors/bg/click.png differ diff --git a/apps/web/public/assets/cursors/bg/cursor.png b/apps/web/public/assets/cursors/bg/cursor.png new file mode 100644 index 0000000..e8b1105 Binary files /dev/null and b/apps/web/public/assets/cursors/bg/cursor.png differ diff --git a/apps/web/public/assets/cursors/bg/disabled.png b/apps/web/public/assets/cursors/bg/disabled.png new file mode 100644 index 0000000..6044057 Binary files /dev/null and b/apps/web/public/assets/cursors/bg/disabled.png differ diff --git a/apps/web/public/assets/cursors/bg/hover.png b/apps/web/public/assets/cursors/bg/hover.png new file mode 100644 index 0000000..d00dd5f Binary files /dev/null and b/apps/web/public/assets/cursors/bg/hover.png differ diff --git a/apps/web/public/assets/cursors/click.png b/apps/web/public/assets/cursors/click.png new file mode 100644 index 0000000..fc836b0 Binary files /dev/null and b/apps/web/public/assets/cursors/click.png differ diff --git a/apps/web/public/assets/cursors/cursor.png b/apps/web/public/assets/cursors/cursor.png new file mode 100644 index 0000000..203ac4b Binary files /dev/null and b/apps/web/public/assets/cursors/cursor.png differ diff --git a/apps/web/public/assets/cursors/disabled.png b/apps/web/public/assets/cursors/disabled.png new file mode 100644 index 0000000..a4be645 Binary files /dev/null and b/apps/web/public/assets/cursors/disabled.png differ diff --git a/apps/web/public/assets/cursors/hover.png b/apps/web/public/assets/cursors/hover.png new file mode 100644 index 0000000..1005b12 Binary files /dev/null and b/apps/web/public/assets/cursors/hover.png differ diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss index 265756a..b20de9e 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss @@ -70,7 +70,6 @@ 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: 1rem; letter-spacing: 0.02em; diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss index 070dff9..84cc962 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss @@ -64,7 +64,6 @@ 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: 1rem; } @@ -78,7 +77,6 @@ .hunt-page__ready button:disabled, .hunt-page__actions button:disabled { - cursor: not-allowed; opacity: 0.52; } @@ -117,7 +115,6 @@ border-radius: var(--ar-radius-sm); color: var(--ar-text); background: #1a2023; - cursor: pointer; } @media (width < 720px) { diff --git a/apps/web/src/app/features/world/location-node.component.scss b/apps/web/src/app/features/world/location-node.component.scss index b6d17ae..ad0c079 100644 --- a/apps/web/src/app/features/world/location-node.component.scss +++ b/apps/web/src/app/features/world/location-node.component.scss @@ -11,14 +11,12 @@ border: 0; color: var(--ar-text); background: transparent; - cursor: pointer; font: inherit; text-align: center; text-shadow: 0 0.15rem 0.55rem rgb(0 0 0 / 0.95); } .location-node:disabled { - cursor: default; opacity: 1; } diff --git a/apps/web/src/app/features/world/travel-panel.component.scss b/apps/web/src/app/features/world/travel-panel.component.scss index 68eed2f..f5ab760 100644 --- a/apps/web/src/app/features/world/travel-panel.component.scss +++ b/apps/web/src/app/features/world/travel-panel.component.scss @@ -61,7 +61,6 @@ 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: 1rem; } @@ -75,7 +74,6 @@ border-color: var(--ar-border); color: var(--ar-text-muted); background: #1a1c1d; - cursor: not-allowed; } .travel-panel__instruction { diff --git a/apps/web/src/app/features/world/world-page.component.scss b/apps/web/src/app/features/world/world-page.component.scss index 2a3db5d..9415233 100644 --- a/apps/web/src/app/features/world/world-page.component.scss +++ b/apps/web/src/app/features/world/world-page.component.scss @@ -139,7 +139,6 @@ border-radius: var(--ar-radius-sm); color: var(--ar-text); background: #1a2023; - cursor: pointer; } @keyframes world-atmosphere-drift { diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.scss b/apps/web/src/app/layout/side-navigation/side-navigation.component.scss index cc0e1c1..9dcfa47 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.scss +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.scss @@ -51,7 +51,6 @@ } .side-navigation__item:disabled { - cursor: not-allowed; opacity: 0.52; } diff --git a/apps/web/src/styles.scss b/apps/web/src/styles.scss index 4204abf..3bf3dae 100644 --- a/apps/web/src/styles.scss +++ b/apps/web/src/styles.scss @@ -33,6 +33,7 @@ html { min-block-size: 100%; color-scheme: dark; background: var(--ar-bg); + cursor: url('/assets/cursors/cursor.png') 2 2, auto; } body { @@ -61,6 +62,23 @@ a:focus-visible { outline-offset: -2px; } +button:not(:disabled), +a, +[role='button'] { + cursor: url('/assets/cursors/hover.png') 2 2, pointer; +} + +button:not(:disabled):active, +a:active, +[role='button']:active { + cursor: url('/assets/cursors/click.png') 2 2, pointer; +} + +button:disabled, +[aria-disabled='true'] { + cursor: url('/assets/cursors/disabled.png') 2 2, not-allowed; +} + img { max-inline-size: 100%; } diff --git a/docs/superpowers/plans/2026-08-19-playable-slice-0.3-first-combat.md b/docs/superpowers/plans/2026-08-19-playable-slice-0.3-first-combat.md new file mode 100644 index 0000000..2ca47a5 --- /dev/null +++ b/docs/superpowers/plans/2026-08-19-playable-slice-0.3-first-combat.md @@ -0,0 +1,3996 @@ +# 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 { + 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 { + 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 { + 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`, `.getCombat(characterId, combatId): Promise`, `.performAction(characterId, combatId, action: CombatAction): Promise` — 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 { + constructor( + private readonly state: FakeState, + private readonly target: EntityTarget, + private readonly inTransaction: boolean, + private readonly dataSource: FakeDataSource, + ) {} + + findOne(options: { where: Partial; lock?: { mode: string } }): Promise { + 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): Promise { + return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null); + } + + find(options: { + where: Partial; + order?: Partial>; + }): Promise { + 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 }): Promise { + return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length); + } + + create(values: Partial): T { + return { ...values } as T; + } + + save(entity: T): Promise { + 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): 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(target: EntityTarget) { + return new FakeRepository(this.state, target, true, this.dataSource); + } +} + +class FakeDataSource { + readonly locks: Array<{ target: EntityTarget; mode: string }> = []; + private readonly idCounters = new Map(); + + constructor(public state: FakeState) {} + + getRepository(target: EntityTarget) { + return new FakeRepository(this.state, target, false, this); + } + + async transaction(work: (manager: EntityManager) => Promise): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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, code: string): Promise { + 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 { + 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 { + 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 { + 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, + characterId: string, + ): Promise { + 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, + ): Promise { + 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, + ): Promise { + 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): Promise { + 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; + const startCombat = jest.fn(); + + beforeEach(async () => { + startCombat.mockReset(); + const module = await Test.createTestingModule({ + controllers: [HuntEncounterAttackController], + providers: [{ provide: CombatService, useValue: { startCombat } }], + }).compile(); + + app = module.createNestApplication(); + 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; + 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(); + 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 1–8. +- 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//attack +# copy the returned combat.id, then: +curl http://localhost:3000/api/combats/ +curl -X POST http://localhost:3000/api/combats//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`, `.getCombat(combatId): Observable`, `.performCombatAction(combatId, action): Observable` — 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 { + return this.http.get('/api/characters/me'); + } + + getCurrentLocation(): Observable { + return this.http.get('/api/world/current-location'); + } + + startTravel(targetLocationId: string): Observable { + return this.http.post('/api/travel', { targetLocationId }); + } + + getCurrentTravel(): Observable { + return this.http.get('/api/travel/current'); + } + + startHunt(): Observable { + return this.http.post('/api/hunts', {}); + } + + startCombat(encounterId: string): Observable { + return this.http.post(`/api/hunt-encounters/${encounterId}/attack`, {}); + } + + getCombat(combatId: string): Observable { + return this.http.get(`/api/combats/${combatId}`); + } + + performCombatAction(combatId: string, action: CombatAction): Observable { + return this.http.post(`/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> = { + '/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(); + + 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`, `loading: Signal`, `actionPending: Signal`, `error: Signal`, `startCombat(encounterId): Promise`, `loadCombat(combatId): Promise`, `attack(): Promise`, `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; + getCombat: ReturnType; + performCombatAction: ReturnType; + }; + 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((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> = { + 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(null); + private readonly loadingState = signal(false); + private readonly actionPendingState = signal(false); + private readonly errorState = signal(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 { + 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 { + 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 { + 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>; + loading: ReturnType>; + actionPending: ReturnType>; + error: ReturnType>; + loadCombat: ReturnType; + attack: ReturnType; + }; + let router: Router; + + async function setup(combat: Combat | null) { + combatStore = { + combat: signal(combat), + loading: signal(false), + actionPending: signal(false), + error: signal(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('[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('[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('[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('[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(); + 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 + +
+ @if (combatStore.combat(); as combat) { +
+
+ +
+ {{ combat.player.name }} +
+ {{ combat.player.currentHp }} / {{ combat.player.maxHp }} + +
+
+
+ +
Runde {{ combat.round }}
+ +
+ + @if (runtimeMonsterArtwork(combat.monster.artworkPath); as runtimeArtwork) { + + } + + +
+ {{ combat.monster.name }} + Stufe {{ combat.monster.level }} +
+ {{ combat.monster.currentHp }} / {{ combat.monster.maxHp }} + +
+
+
+
+ +
+ @if (combat.status === 'ACTIVE') { + + } @else if (combat.status === 'WON') { +
+

Sieg

+

{{ combat.monster.name }} wurde besiegt.

+

Belohnungen werden im nächsten Schritt verarbeitet.

+ +
+ } @else { +
+

Niederlage

+

{{ combat.player.name }} wurde im Kampf besiegt.

+ +
+ } +
+ + + } @else if (combatStore.loading()) { +

Kampf wird geladen…

+ } + + @if (combatStore.error(); as error) { + + } +
+``` + +- [ ] **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>; + load: ReturnType; + }; + let huntingStore: { + currentHunt: ReturnType>; + loading: ReturnType>; + error: ReturnType>; + encounters: () => HuntResult['encounters']; + startHunt: ReturnType; + refreshHunt: ReturnType; + selectEncounter: ReturnType; + }; + let combatStore: { + combat: ReturnType>; + error: ReturnType>; + startCombat: ReturnType; + clearError: ReturnType; + }; + 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(null), + encounters: () => currentHunt()?.encounters ?? [], + startHunt: vi.fn(() => Promise.resolve()), + refreshHunt: vi.fn(() => Promise.resolve()), + selectEncounter: vi.fn(), + }; + combatStore = { + combat: signal(null), + error: signal(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('[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('[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('[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('[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('[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('[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 { + 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 ``, in `apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html`: + +```html + @if (combatStore.error(); as combatError) { + + } +``` + +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.