# 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.