136 KiB
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+ATTACKmust always produce the same result. - Damage formula:
raw = attack + weaponDamage;damage = round(raw * 60 / (60 + targetArmor)); minimum1(spec §11). - Only
ATTACKis implemented. TheCombatActionenum,CombatEventtype, and engineswitchmust be structured soHEAVY_STRIKE/SHIELD_BASH/DEFEND/POTION/FLEEcan be added later without reshaping the API (spec §4, §14). - Combat may only be created from a persisted
HuntEncounter.id, never from a client-suppliedmonsterDefinitionId(spec §6). - One
HuntEncounter→ at most oneCombat. One character → at most oneACTIVEcombat. 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 globalValidationPipe({ 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 viaautoLoadEntities: true; no manual entity-list registration needed beyond a module's ownTypeOrmModule.forFeature. - All demo-character endpoints use
DEMO_CHARACTER_IDfromapps/api/src/demo/demo-character.constants.ts— no auth exists yet. - Every task must leave
npm run buildand 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 | LOSTapps/api/src/combat/combat-action.enum.ts—ATTACK(extensible)apps/api/src/combat/combat-event-type.enum.ts—DAMAGE | COMBAT_WON | COMBAT_LOSTapps/api/src/combat/combatant.enum.ts—PLAYER | MONSTERapps/api/src/combat/entities/combat.entity.tsapps/api/src/combat/entities/combat-event.entity.tsapps/api/src/combat/combat-damage.ts+combat-damage.spec.ts— pure damage formulaapps/api/src/combat/combat-engine.types.ts— engine state/result typesapps/api/src/combat/combat-engine.service.ts+ spec — pure round resolutionapps/api/src/combat/combat.errors.ts— domain errorsapps/api/src/combat/combat.service.ts+ spec — orchestration/persistenceapps/api/src/combat/dto/combat-action.dto.tsapps/api/src/combat/combat.controller.ts+ spec —/combats/:id,/combats/:id/actionsapps/api/src/combat/hunt-encounter-attack.controller.ts+ spec —/hunt-encounters/:id/attackapps/api/src/combat/combat.module.tsapps/api/src/characters/character-combat-stats.service.ts+ spec — temporary equipment stand-inapps/api/src/database/migrations/1788100000000-CreateCombatSystem.tsapps/api/src/database/migrations/combat-system.migration.spec.ts
Backend — modified:
apps/api/src/hunting/entities/hunt-encounter.entity.ts— add nullableconsumedAtapps/api/src/characters/characters.module.ts— provide + exportCharacterCombatStatsServiceapps/api/src/app.module.ts— importCombatModule
Frontend — new:
apps/web/src/app/shared/monster-artwork.ts+ spec — extracted runtime-artwork lookupapps/web/src/app/features/combat/combat.store.ts+ specapps/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*typesapps/web/src/app/core/api/game-api.service.ts+ spec —startCombat/getCombat/performCombatActionapps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts— use shared artwork helperapps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts+.html+ spec — start combat, navigate, show errorsapps/web/src/app/app.routes.ts—/combat/:combatIdreplaces/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,Combatantenums;Combatentity (fields:id, characterId, huntEncounterId, monsterDefinitionId, status, round, playerMaxHp, playerCurrentHp, monsterMaxHp, monsterCurrentHp, playerState: {attack, weaponDamage, armor}, monsterState: {attack, armor}, createdAt, updatedAt, completedAt: Date|null);CombatEvententity (fields:id, combatId, round, sequence, type, source, target, amount: number|null, createdAt);HuntEncounter.consumedAt: Date | null. -
Step 1: Write the schema spec (fails: modules don't exist yet)
// apps/api/src/database/migrations/combat-system.migration.spec.ts
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { Combat } from '../../combat/entities/combat.entity';
import { CombatEvent } from '../../combat/entities/combat-event.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
describe('combat system schema', () => {
it('maps Combat and CombatEvent relations with the documented onDelete behavior', () => {
const metadata = getMetadataArgsStorage();
const relations = metadata.relations.filter(
(relation) => relation.target === Combat || relation.target === CombatEvent,
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'character', target: Combat }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'huntEncounter', target: Combat }),
expect.objectContaining({ onDelete: 'RESTRICT', propertyName: 'monster', target: Combat }),
expect.objectContaining({ onDelete: 'CASCADE', propertyName: 'combat', target: CombatEvent }),
]),
);
});
it('enforces one combat per hunt encounter via a unique index', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) => candidate.target === Combat && candidate.columns?.includes('huntEncounterId'),
);
expect(index).toBeDefined();
const options = index as typeof index & { options?: { unique?: boolean } };
expect(options?.options?.unique).toBe(true);
});
it('enforces ordered, unique event sequencing per combat', () => {
const metadata = getMetadataArgsStorage();
const index = metadata.indices.find(
(candidate) =>
candidate.target === CombatEvent &&
candidate.columns?.includes('combatId') &&
candidate.columns?.includes('sequence'),
);
expect(index).toBeDefined();
const options = index as typeof index & { options?: { unique?: boolean } };
expect(options?.options?.unique).toBe(true);
});
it('adds a nullable consumedAt column to hunt_encounters to prevent reuse', () => {
const metadata = getMetadataArgsStorage();
const column = metadata.columns.find(
(candidate) => candidate.target === HuntEncounter && candidate.propertyName === 'consumedAt',
);
expect(column).toBeDefined();
expect(column?.options.nullable).toBe(true);
});
});
- Step 2: Run it to confirm it fails
Run: npm run test --workspace=@ashen-realms/api -- combat-system.migration.spec.ts
Expected: FAIL — cannot find module ../../combat/entities/combat.entity.
- Step 3: Create the four enums
// apps/api/src/combat/combat-status.enum.ts
export enum CombatStatus {
ACTIVE = 'ACTIVE',
WON = 'WON',
LOST = 'LOST',
}
// apps/api/src/combat/combat-action.enum.ts
// Only ATTACK is implemented in Slice 0.3. Future slices add HEAVY_STRIKE,
// SHIELD_BASH, DEFEND, POTION, FLEE as real members with their own
// CombatEngineService cases — do not add them here until their behavior ships.
export enum CombatAction {
ATTACK = 'ATTACK',
}
// apps/api/src/combat/combat-event-type.enum.ts
export enum CombatEventType {
DAMAGE = 'DAMAGE',
COMBAT_WON = 'COMBAT_WON',
COMBAT_LOST = 'COMBAT_LOST',
}
// apps/api/src/combat/combatant.enum.ts
export enum Combatant {
PLAYER = 'PLAYER',
MONSTER = 'MONSTER',
}
- Step 4: Create the Combat entity
// apps/api/src/combat/entities/combat.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { CombatStatus } from '../combat-status.enum';
export interface CombatCombatantState {
attack: number;
armor: number;
}
export interface CombatPlayerState extends CombatCombatantState {
weaponDamage: number;
}
@Entity({ name: 'combats' })
@Index('IDX_combats_hunt_encounter', ['huntEncounterId'], { unique: true })
export class Combat {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'hunt_encounter_id', type: 'uuid' })
huntEncounterId!: string;
@Column({ name: 'monster_definition_id', type: 'uuid' })
monsterDefinitionId!: string;
@Column({
name: 'status',
type: 'enum',
enum: CombatStatus,
enumName: 'combat_status_enum',
})
status!: CombatStatus;
@Column({ name: 'round', type: 'integer' })
round!: number;
@Column({ name: 'player_max_hp', type: 'integer' })
playerMaxHp!: number;
@Column({ name: 'player_current_hp', type: 'integer' })
playerCurrentHp!: number;
@Column({ name: 'monster_max_hp', type: 'integer' })
monsterMaxHp!: number;
@Column({ name: 'monster_current_hp', type: 'integer' })
monsterCurrentHp!: number;
@Column({ name: 'player_state', type: 'jsonb' })
playerState!: CombatPlayerState;
@Column({ name: 'monster_state', type: 'jsonb' })
monsterState!: CombatCombatantState;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
@Column({ name: 'completed_at', type: 'timestamptz', nullable: true })
completedAt!: Date | null;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => HuntEncounter, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'hunt_encounter_id' })
huntEncounter!: HuntEncounter;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_definition_id' })
monster!: MonsterDefinition;
}
- Step 5: Create the CombatEvent entity
// apps/api/src/combat/entities/combat-event.entity.ts
import {
Column,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Combatant } from '../combatant.enum';
import { CombatEventType } from '../combat-event-type.enum';
import { Combat } from './combat.entity';
@Entity({ name: 'combat_events' })
@Index('IDX_combat_events_combat_sequence', ['combatId', 'sequence'], { unique: true })
export class CombatEvent {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'combat_id', type: 'uuid' })
combatId!: string;
@Column({ name: 'round', type: 'integer' })
round!: number;
@Column({ name: 'sequence', type: 'integer' })
sequence!: number;
@Column({
name: 'type',
type: 'enum',
enum: CombatEventType,
enumName: 'combat_event_type_enum',
})
type!: CombatEventType;
@Column({
name: 'source',
type: 'enum',
enum: Combatant,
enumName: 'combatant_enum',
})
source!: Combatant;
@Column({
name: 'target',
type: 'enum',
enum: Combatant,
enumName: 'combatant_enum',
})
target!: Combatant;
@Column({ name: 'amount', type: 'integer', nullable: true })
amount!: number | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Combat, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'combat_id' })
combat!: Combat;
}
- Step 6: Add
consumedAtto HuntEncounter
In apps/api/src/hunting/entities/hunt-encounter.entity.ts, add the import and column:
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { Hunt } from './hunt.entity';
@Entity({ name: 'hunt_encounters' })
export class HuntEncounter {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'hunt_id', type: 'uuid' })
huntId!: string;
@Column({ name: 'monster_definition_id', type: 'uuid' })
monsterDefinitionId!: string;
@Column({ name: 'position', type: 'integer' })
position!: number;
// Set when a Combat is successfully created from this encounter. Prevents
// one HuntEncounter from spawning more than one Combat (spec §7).
@Column({ name: 'consumed_at', type: 'timestamptz', nullable: true })
consumedAt!: Date | null;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
// CASCADE (unlike the other FKs in this file, which use RESTRICT): a
// HuntEncounter is owned/composed by its parent Hunt and has no
// independent lifecycle, so it should be removed along with its Hunt.
@ManyToOne(() => Hunt, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'hunt_id' })
hunt!: Hunt;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_definition_id' })
monster!: MonsterDefinition;
}
- Step 7: Run the schema spec and the full existing suite
Run: npm run test --workspace=@ashen-realms/api -- combat-system.migration.spec.ts
Expected: PASS (4/4).
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS — the hunting-system.migration.spec.ts and hunting.service.spec.ts fixtures build HuntEncounter object literals; adding an optional-looking (but required-by-type) consumedAt field only breaks compilation if TypeScript strictness demands it on literals. If tsc/Jest reports missing-property errors on existing HuntEncounter literals in hunting.service.spec.ts or hunting.controller.spec.ts, add consumedAt: null to those fixture literals.
- Step 8: Commit
git add apps/api/src/combat apps/api/src/hunting/entities/hunt-encounter.entity.ts apps/api/src/database/migrations/combat-system.migration.spec.ts
git commit -m "feat(combat): add combat domain enums, entities, and encounter consumption field"
Task 2: Database migration for the combat schema
Files:
- Create:
apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts
Interfaces:
-
Consumes: table/column names from Task 1's entities (
combats,combat_events,hunt_encounters.consumed_at). -
Produces:
combats,combat_eventstables and the three new Postgres enum types (combat_status_enum,combat_event_type_enum,combatant_enum), matching an already-running Postgres vianpm run db:migrate. -
Step 1: Write the migration
// apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateCombatSystem1788100000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'ALTER TABLE "hunt_encounters" ADD COLUMN "consumed_at" TIMESTAMP WITH TIME ZONE',
);
await queryRunner.query(
"CREATE TYPE \"combat_status_enum\" AS ENUM ('ACTIVE', 'WON', 'LOST')",
);
await queryRunner.query(
"CREATE TYPE \"combat_event_type_enum\" AS ENUM ('DAMAGE', 'COMBAT_WON', 'COMBAT_LOST')",
);
await queryRunner.query(
"CREATE TYPE \"combatant_enum\" AS ENUM ('PLAYER', 'MONSTER')",
);
await queryRunner.query(`CREATE TABLE "combats" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"character_id" uuid NOT NULL,
"hunt_encounter_id" uuid NOT NULL,
"monster_definition_id" uuid NOT NULL,
"status" "combat_status_enum" NOT NULL,
"round" integer NOT NULL,
"player_max_hp" integer NOT NULL,
"player_current_hp" integer NOT NULL,
"monster_max_hp" integer NOT NULL,
"monster_current_hp" integer NOT NULL,
"player_state" jsonb NOT NULL,
"monster_state" jsonb NOT NULL,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
"completed_at" TIMESTAMP WITH TIME ZONE,
CONSTRAINT "PK_combats" PRIMARY KEY ("id"),
CONSTRAINT "FK_combats_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combats_hunt_encounter" FOREIGN KEY ("hunt_encounter_id") REFERENCES "hunt_encounters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
CONSTRAINT "FK_combats_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combats_character" ON "combats" ("character_id")',
);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_combats_hunt_encounter" ON "combats" ("hunt_encounter_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_combats_monster_definition" ON "combats" ("monster_definition_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_combat_per_character"
ON "combats" ("character_id")
WHERE "status" = 'ACTIVE'`);
await queryRunner.query(`CREATE TABLE "combat_events" (
"id" uuid NOT NULL DEFAULT gen_random_uuid(),
"combat_id" uuid NOT NULL,
"round" integer NOT NULL,
"sequence" integer NOT NULL,
"type" "combat_event_type_enum" NOT NULL,
"source" "combatant_enum" NOT NULL,
"target" "combatant_enum" NOT NULL,
"amount" integer,
"created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
CONSTRAINT "PK_combat_events" PRIMARY KEY ("id"),
CONSTRAINT "FK_combat_events_combat" FOREIGN KEY ("combat_id") REFERENCES "combats"("id") ON DELETE CASCADE ON UPDATE NO ACTION
)`);
await queryRunner.query(
'CREATE INDEX "IDX_combat_events_combat" ON "combat_events" ("combat_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_combat_events_combat_sequence"
ON "combat_events" ("combat_id", "sequence")`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP INDEX "IDX_combat_events_combat_sequence"');
await queryRunner.query('DROP INDEX "IDX_combat_events_combat"');
await queryRunner.query('DROP TABLE "combat_events"');
await queryRunner.query('DROP INDEX "IDX_active_combat_per_character"');
await queryRunner.query('DROP INDEX "IDX_combats_monster_definition"');
await queryRunner.query('DROP INDEX "IDX_combats_hunt_encounter"');
await queryRunner.query('DROP INDEX "IDX_combats_character"');
await queryRunner.query('DROP TABLE "combats"');
await queryRunner.query('DROP TYPE "combatant_enum"');
await queryRunner.query('DROP TYPE "combat_event_type_enum"');
await queryRunner.query('DROP TYPE "combat_status_enum"');
await queryRunner.query(
'ALTER TABLE "hunt_encounters" DROP COLUMN "consumed_at"',
);
}
}
- Step 2: Compile it
Run: npm run build --workspace=@ashen-realms/api
Expected: succeeds with no TypeScript errors.
- Step 3: Run it against the dev database and verify it reverts cleanly
Run: npm run db:migrate
Expected: CreateCombatSystem1788100000000 reported as executed successfully; no errors.
Run: npm run db:revert
Expected: reverts cleanly, dropping combat_events, combats, the three enum types, and the hunt_encounters.consumed_at column with no leftover objects.
Run: npm run db:migrate again to leave the database in the post-migration state for later manual testing.
- Step 4: Commit
git add apps/api/src/database/migrations/1788100000000-CreateCombatSystem.ts
git commit -m "feat(combat): add CreateCombatSystem migration"
Task 3: Pure damage formula
Files:
- Create:
apps/api/src/combat/combat-damage.ts - Test:
apps/api/src/combat/combat-damage.spec.ts
Interfaces:
-
Produces:
calculateDamage(attacker: { attack: number; weaponDamage?: number }, targetArmor: number): number— used by Task 4'sCombatEngineService. -
Step 1: Write the failing tests
// apps/api/src/combat/combat-damage.spec.ts
import { calculateDamage } from './combat-damage';
describe('calculateDamage', () => {
it('applies the established armor mitigation formula', () => {
// raw = 12 + 15 = 27; 27 * 60 / (60 + 20) = 20.25 -> rounds to 20
expect(calculateDamage({ attack: 12, weaponDamage: 15 }, 20)).toBe(20);
});
it('never returns less than 1 damage, even against extreme armor', () => {
expect(calculateDamage({ attack: 1, weaponDamage: 0 }, 100_000)).toBe(1);
});
it('treats an attacker with no weaponDamage as having attack alone as its raw damage', () => {
// raw = 9; 9 * 60 / (60 + 5) = 8.307... -> rounds to 8
expect(calculateDamage({ attack: 9 }, 5)).toBe(8);
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts
Expected: FAIL — cannot find module ./combat-damage.
- Step 3: Implement it
// apps/api/src/combat/combat-damage.ts
export interface DamageAttacker {
attack: number;
weaponDamage?: number;
}
const ARMOR_MITIGATION_CONSTANT = 60;
export function calculateDamage(attacker: DamageAttacker, targetArmor: number): number {
const rawDamage = attacker.attack + (attacker.weaponDamage ?? 0);
const mitigatedDamage =
(rawDamage * ARMOR_MITIGATION_CONSTANT) / (ARMOR_MITIGATION_CONSTANT + targetArmor);
return Math.max(1, Math.round(mitigatedDamage));
}
- Step 4: Run to confirm it passes
Run: npm run test --workspace=@ashen-realms/api -- combat-damage.spec.ts
Expected: PASS (3/3).
- Step 5: Commit
git add apps/api/src/combat/combat-damage.ts apps/api/src/combat/combat-damage.spec.ts
git commit -m "feat(combat): add deterministic damage formula"
Task 4: Combat engine (pure round resolution)
Files:
- Create:
apps/api/src/combat/combat-engine.types.ts - Create:
apps/api/src/combat/combat-engine.service.ts - Test:
apps/api/src/combat/combat-engine.service.spec.ts
Interfaces:
-
Consumes:
calculateDamagefrom Task 3;CombatStatus,CombatAction,CombatEventType,Combatantfrom 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'sCombatService. -
Step 1: Write the failing tests
// apps/api/src/combat/combat-engine.service.spec.ts
import { CombatAction } from './combat-action.enum';
import { CombatEngineService, UnsupportedCombatActionError } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
import { Combatant } from './combatant.enum';
function baseState(overrides: Partial<CombatEngineState> = {}): CombatEngineState {
return {
status: CombatStatus.ACTIVE,
round: 1,
player: {
currentHp: 100,
maxHp: 100,
stats: { attack: 6, weaponDamage: 8, armor: 6 },
},
monster: {
currentHp: 45,
maxHp: 45,
stats: { attack: 5, armor: 0 },
},
...overrides,
};
}
describe('CombatEngineService', () => {
let engine: CombatEngineService;
beforeEach(() => {
engine = new CombatEngineService();
});
it('reduces monster HP by the calculated damage and emits a DAMAGE event', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
// raw = 6 + 8 = 14; armor 0 -> 14 mitigated
expect(result.state.monster.currentHp).toBe(45 - 14);
expect(result.events[0]).toEqual({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: 14,
});
});
it('lets the monster retaliate when it survives the player attack, and advances the round', () => {
const result = engine.resolveAction(baseState(), { action: CombatAction.ATTACK });
// raw = 5; armor 6 -> 5*60/66 = 4.545 -> rounds to 5
expect(result.state.player.currentHp).toBe(100 - 5);
expect(result.events[1]).toEqual({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: 5,
});
expect(result.state.status).toBe(CombatStatus.ACTIVE);
expect(result.state.round).toBe(2);
});
it('does not let the monster attack once it is reduced to 0 HP, and ends the combat as WON', () => {
const state = baseState({
monster: { currentHp: 10, maxHp: 45, stats: { attack: 5, armor: 0 } },
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.monster.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.WON);
expect(result.state.round).toBe(1);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.COMBAT_WON },
]);
});
it('ends the combat as LOST when the monster attack reduces the player to 0 HP', () => {
const state = baseState({
player: { currentHp: 3, maxHp: 100, stats: { attack: 6, weaponDamage: 8, armor: 6 } },
});
const result = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(result.state.player.currentHp).toBe(0);
expect(result.state.status).toBe(CombatStatus.LOST);
expect(result.events).toEqual([
{ source: Combatant.PLAYER, target: Combatant.MONSTER, type: CombatEventType.DAMAGE, amount: 14 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.DAMAGE, amount: 5 },
{ source: Combatant.MONSTER, target: Combatant.PLAYER, type: CombatEventType.COMBAT_LOST },
]);
});
it('produces the exact same result for the same state and action (determinism)', () => {
const state = baseState();
const first = engine.resolveAction(state, { action: CombatAction.ATTACK });
const second = engine.resolveAction(state, { action: CombatAction.ATTACK });
expect(first).toEqual(second);
});
it('throws UnsupportedCombatActionError for an action it does not implement', () => {
expect(() =>
engine.resolveAction(baseState(), { action: 'HEAVY_STRIKE' as CombatAction }),
).toThrow(UnsupportedCombatActionError);
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts
Expected: FAIL — cannot find module ./combat-engine.service.
- Step 3: Write the engine types
// apps/api/src/combat/combat-engine.types.ts
import { Combatant } from './combatant.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export interface CombatEngineCombatantStats {
attack: number;
weaponDamage?: number;
armor: number;
}
export interface CombatEngineCombatant {
currentHp: number;
maxHp: number;
stats: CombatEngineCombatantStats;
}
export interface CombatEngineState {
status: CombatStatus;
round: number;
player: CombatEngineCombatant;
monster: CombatEngineCombatant;
}
export interface CombatActionInput {
action: CombatAction;
}
export interface CombatEngineEvent {
source: Combatant;
target: Combatant;
type: CombatEventType;
amount?: number;
}
export interface CombatEngineResult {
state: CombatEngineState;
events: CombatEngineEvent[];
}
- Step 4: Implement the engine
// apps/api/src/combat/combat-engine.service.ts
import { Injectable } from '@nestjs/common';
import { CombatAction } from './combat-action.enum';
import { calculateDamage } from './combat-damage';
import { Combatant } from './combatant.enum';
import {
CombatActionInput,
CombatEngineEvent,
CombatEngineResult,
CombatEngineState,
} from './combat-engine.types';
import { CombatEventType } from './combat-event-type.enum';
import { CombatStatus } from './combat-status.enum';
export class UnsupportedCombatActionError extends Error {
constructor(action: string) {
super(`Unsupported combat action: ${action}`);
}
}
@Injectable()
export class CombatEngineService {
resolveAction(state: CombatEngineState, input: CombatActionInput): CombatEngineResult {
switch (input.action) {
case CombatAction.ATTACK:
return this.resolveAttack(state);
default:
throw new UnsupportedCombatActionError(input.action);
}
}
private resolveAttack(state: CombatEngineState): CombatEngineResult {
const events: CombatEngineEvent[] = [];
const player = { ...state.player };
const monster = { ...state.monster };
const playerDamage = calculateDamage(player.stats, monster.stats.armor);
monster.currentHp = Math.max(0, monster.currentHp - playerDamage);
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.DAMAGE,
amount: playerDamage,
});
if (monster.currentHp <= 0) {
events.push({
source: Combatant.PLAYER,
target: Combatant.MONSTER,
type: CombatEventType.COMBAT_WON,
});
return {
state: { ...state, player, monster, status: CombatStatus.WON },
events,
};
}
const monsterDamage = calculateDamage(monster.stats, player.stats.armor);
player.currentHp = Math.max(0, player.currentHp - monsterDamage);
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.DAMAGE,
amount: monsterDamage,
});
if (player.currentHp <= 0) {
events.push({
source: Combatant.MONSTER,
target: Combatant.PLAYER,
type: CombatEventType.COMBAT_LOST,
});
return {
state: { ...state, player, monster, status: CombatStatus.LOST },
events,
};
}
return {
state: { ...state, player, monster, status: CombatStatus.ACTIVE, round: state.round + 1 },
events,
};
}
}
- Step 5: Run to confirm it passes
Run: npm run test --workspace=@ashen-realms/api -- combat-engine.service.spec.ts
Expected: PASS (6/6).
- Step 6: Commit
git add apps/api/src/combat/combat-engine.types.ts apps/api/src/combat/combat-engine.service.ts apps/api/src/combat/combat-engine.service.spec.ts
git commit -m "feat(combat): add deterministic combat engine for ATTACK resolution"
Task 5: CharacterCombatStatsService (temporary equipment stand-in)
Files:
- Create:
apps/api/src/characters/character-combat-stats.service.ts - Test:
apps/api/src/characters/character-combat-stats.service.spec.ts
Interfaces:
-
Consumes:
Characterentity (baseHp,baseAttack). -
Produces:
CharacterCombatStats { maxHp, attack, weaponDamage, armor };CharacterCombatStatsService.getStats(character: Character): CharacterCombatStats— consumed by Task 7'sCombatService. -
Step 1: Write the failing test
// apps/api/src/characters/character-combat-stats.service.spec.ts
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { Character } from './entities/character.entity';
describe('CharacterCombatStatsService', () => {
it('derives combat stats from the character, with a temporary fixed weapon/armor stand-in', () => {
const service = new CharacterCombatStatsService();
const character = { baseHp: 100, baseAttack: 6 } as Character;
expect(service.getStats(character)).toEqual({
maxHp: 100,
attack: 6,
weaponDamage: 8,
armor: 6,
});
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/api -- character-combat-stats.service.spec.ts
Expected: FAIL — cannot find module ./character-combat-stats.service.
- Step 3: Implement it
// apps/api/src/characters/character-combat-stats.service.ts
import { Injectable } from '@nestjs/common';
import { Character } from './entities/character.entity';
export interface CharacterCombatStats {
maxHp: number;
attack: number;
weaponDamage: number;
armor: number;
}
// TEMPORARY (Slice 0.3): there is no equipment system yet. These constants
// stand in for the starting weapon/armor until Slice 0.5 introduces real
// equipment. Replacing them there must not change this method's signature
// or the combat API it feeds (spec §10).
const TEMPORARY_WEAPON_DAMAGE = 8;
const TEMPORARY_ARMOR = 6;
@Injectable()
export class CharacterCombatStatsService {
getStats(character: Character): CharacterCombatStats {
return {
maxHp: character.baseHp,
attack: character.baseAttack,
weaponDamage: TEMPORARY_WEAPON_DAMAGE,
armor: TEMPORARY_ARMOR,
};
}
}
- Step 4: Run to confirm it passes
Run: npm run test --workspace=@ashen-realms/api -- character-combat-stats.service.spec.ts
Expected: PASS (1/1).
- Step 5: Commit
git add apps/api/src/characters/character-combat-stats.service.ts apps/api/src/characters/character-combat-stats.service.spec.ts
git commit -m "feat(characters): add temporary combat-stats stand-in for equipment"
Task 6: Combat domain errors
Files:
- Create:
apps/api/src/combat/combat.errors.ts
Interfaces:
- Produces:
CombatDomainError(extendsHttpException, has.code: CombatErrorCode) and factorieshuntEncounterNotFound(),huntEncounterAlreadyConsumed(),invalidHuntEncounter(),characterTravelling(),combatAlreadyActive(),combatNotFound(),combatAlreadyFinished(),combatStateInvalid(); re-exportscharacterNotFoundfrom../travel/travel.errors— all consumed by Task 7'sCombatService.
This module mirrors apps/api/src/travel/travel.errors.ts and apps/api/src/hunting/hunting.errors.ts exactly, so it has no dedicated spec file (neither of those does) — its codes/statuses are exercised through CombatService's tests in Task 7.
- Step 1: Implement it
// apps/api/src/combat/combat.errors.ts
import { HttpException, HttpStatus } from '@nestjs/common';
export type CombatErrorCode =
| 'HUNT_ENCOUNTER_NOT_FOUND'
| 'HUNT_ENCOUNTER_ALREADY_CONSUMED'
| 'INVALID_HUNT_ENCOUNTER'
| 'CHARACTER_TRAVELLING'
| 'COMBAT_ALREADY_ACTIVE'
| 'COMBAT_NOT_FOUND'
| 'COMBAT_ALREADY_FINISHED'
| 'COMBAT_STATE_INVALID';
export class CombatDomainError extends HttpException {
constructor(
public readonly code: CombatErrorCode,
status: HttpStatus,
message: string,
) {
super({ statusCode: status, code, message }, status);
}
}
export function huntEncounterNotFound(): CombatDomainError {
return new CombatDomainError(
'HUNT_ENCOUNTER_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This encounter could not be found.',
);
}
export function huntEncounterAlreadyConsumed(): CombatDomainError {
return new CombatDomainError(
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
HttpStatus.CONFLICT,
'This encounter has already been used to start a combat.',
);
}
export function invalidHuntEncounter(): CombatDomainError {
return new CombatDomainError(
'INVALID_HUNT_ENCOUNTER',
HttpStatus.BAD_REQUEST,
'This encounter is not valid for the current character.',
);
}
export function characterTravelling(): CombatDomainError {
return new CombatDomainError(
'CHARACTER_TRAVELLING',
HttpStatus.CONFLICT,
'The character cannot fight while travelling.',
);
}
export function combatAlreadyActive(): CombatDomainError {
return new CombatDomainError(
'COMBAT_ALREADY_ACTIVE',
HttpStatus.CONFLICT,
'The character already has an active combat.',
);
}
export function combatNotFound(): CombatDomainError {
return new CombatDomainError(
'COMBAT_NOT_FOUND',
HttpStatus.NOT_FOUND,
'This combat could not be found.',
);
}
export function combatAlreadyFinished(): CombatDomainError {
return new CombatDomainError(
'COMBAT_ALREADY_FINISHED',
HttpStatus.CONFLICT,
'This combat has already finished.',
);
}
export function combatStateInvalid(): CombatDomainError {
return new CombatDomainError(
'COMBAT_STATE_INVALID',
HttpStatus.INTERNAL_SERVER_ERROR,
'The persisted combat references unavailable data.',
);
}
export { characterNotFound } from '../travel/travel.errors';
- Step 2: Compile it
Run: npm run build --workspace=@ashen-realms/api
Expected: succeeds (this file has no consumers yet, but must type-check standalone).
- Step 3: Commit
git add apps/api/src/combat/combat.errors.ts
git commit -m "feat(combat): add combat domain errors"
Task 7: CombatService — create, read, and act on combats
Files:
- Create:
apps/api/src/combat/combat.service.ts - Test:
apps/api/src/combat/combat.service.spec.ts
Interfaces:
-
Consumes:
CombatEngineService.resolveAction(Task 4),CharacterCombatStatsService.getStats(Task 5), error factories (Task 6),TravelService.completeTravelIfDue(existing), entities from Task 1. -
Produces:
CombatDto { id, status, round, player: {name, maxHp, currentHp}, monster: {key, name, level, maxHp, currentHp, artworkPath}, events: CombatEventDto[] };CombatService.startCombat(characterId, encounterId): Promise<CombatDto>,.getCombat(characterId, combatId): Promise<CombatDto>,.performAction(characterId, combatId, action: CombatAction): Promise<CombatDto>— consumed by Task 8's controllers. -
Step 1: Write the failing test suite
// apps/api/src/combat/combat.service.spec.ts
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatDomainError } from './combat.errors';
import { CombatService } from './combat.service';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const OTHER_CHARACTER_ID = '10000000-0000-4000-8000-000000000002';
const HUNT_ID = '20000000-0000-4000-8000-000000000001';
const ENCOUNTER_ID = '30000000-0000-4000-8000-000000000001';
const MONSTER_ID = '40000000-0000-4000-8000-000000000001';
interface FakeState {
characters: Character[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
monsters: MonsterDefinition[];
combats: Combat[];
combatEvents: CombatEvent[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly inTransaction: boolean,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: { where: Partial<T>; lock?: { mode: string } }): Promise<T | null> {
if (options.lock) {
if (!this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
this.dataSource.locks.push({ target: this.target, mode: options.lock.mode });
}
return Promise.resolve(this.rows().find((row) => this.matches(row, options.where)) ?? null);
}
findOneBy(where: Partial<T>): Promise<T | null> {
return Promise.resolve(this.rows().find((row) => this.matches(row, where)) ?? null);
}
find(options: {
where: Partial<T>;
order?: Partial<Record<keyof T, 'ASC' | 'DESC'>>;
}): Promise<T[]> {
const matched = this.rows().filter((row) => this.matches(row, options.where));
const orderKey = options.order ? (Object.keys(options.order)[0] as keyof T) : undefined;
if (orderKey) {
const direction = options.order![orderKey] === 'DESC' ? -1 : 1;
matched.sort((a, b) => {
if (a[orderKey] === b[orderKey]) return 0;
return a[orderKey] > b[orderKey] ? direction : -direction;
});
}
return Promise.resolve(matched);
}
count(options: { where: Partial<T> }): Promise<number> {
return Promise.resolve(this.rows().filter((row) => this.matches(row, options.where)).length);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
private rows(): T[] {
if (this.target === Character) return this.state.characters as T[];
if (this.target === Hunt) return this.state.hunts as T[];
if (this.target === HuntEncounter) return this.state.huntEncounters as T[];
if (this.target === MonsterDefinition) return this.state.monsters as T[];
if (this.target === Combat) return this.state.combats as T[];
if (this.target === CombatEvent) return this.state.combatEvents as T[];
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(([key, value]) => row[key as keyof T] === value);
}
private targetName(): string {
return typeof this.target === 'function' ? this.target.name : 'EntitySchema';
}
}
class FakeEntityManager {
constructor(
private readonly state: FakeState,
private readonly dataSource: FakeDataSource,
) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, true, this.dataSource);
}
}
class FakeDataSource {
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
private readonly idCounters = new Map<string, number>();
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, false, this);
}
async transaction<T>(work: (manager: EntityManager) => Promise<T>): Promise<T> {
const draft = structuredClone(this.state);
const result = await work(new FakeEntityManager(draft, this) as unknown as EntityManager);
this.state = draft;
return result;
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function character(overrides: Partial<Character> = {}): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: 'location-1',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Character;
}
function monster(overrides: Partial<MonsterDefinition> = {}): MonsterDefinition {
return {
id: MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as MonsterDefinition;
}
function hunt(overrides: Partial<Hunt> = {}): Hunt {
return {
id: HUNT_ID,
characterId: CHARACTER_ID,
locationId: 'location-1',
status: HuntStatus.ACTIVE,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as Hunt;
}
function huntEncounter(overrides: Partial<HuntEncounter> = {}): HuntEncounter {
return {
id: ENCOUNTER_ID,
huntId: HUNT_ID,
monsterDefinitionId: MONSTER_ID,
position: 0,
consumedAt: null,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
} as HuntEncounter;
}
function createState(overrides: Partial<FakeState> = {}): FakeState {
return {
characters: [character()],
hunts: [hunt()],
huntEncounters: [huntEncounter()],
monsters: [monster()],
combats: [],
combatEvents: [],
...overrides,
};
}
function fakeTravelService(status: 'IDLE' | 'TRAVELLING' = 'IDLE'): TravelService {
return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status }),
} as unknown as TravelService;
}
function createService(options: { state?: FakeState; travelService?: TravelService } = {}) {
const state = options.state ?? createState();
const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService();
const combatEngine = new CombatEngineService();
const characterCombatStats = new CharacterCombatStatsService();
const service = new CombatService(
dataSource as unknown as DataSource,
travelService,
combatEngine,
characterCombatStats,
);
return { dataSource, service, travelService };
}
async function expectCombatDomainError(promise: Promise<unknown>, code: string): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(CombatDomainError);
if (!(error instanceof CombatDomainError)) {
throw new Error('Expected CombatDomainError');
}
expect(error.code).toBe(code);
}
describe('CombatService', () => {
describe('startCombat', () => {
it('starts an ACTIVE combat with snapshotted stats and full HP', async () => {
const { dataSource, service } = createService();
const combat = await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(combat.status).toBe('ACTIVE');
expect(combat.round).toBe(1);
expect(combat.player).toEqual({ name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 });
expect(combat.monster).toEqual({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
});
expect(combat.events).toEqual([]);
expect(dataSource.state.combats).toHaveLength(1);
expect(dataSource.state.combats[0]).toMatchObject({
characterId: CHARACTER_ID,
huntEncounterId: ENCOUNTER_ID,
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
});
});
it('marks the encounter as consumed', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.state.huntEncounters[0].consumedAt).not.toBeNull();
});
it('rejects an unknown encounter id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, 'unknown-id'),
'HUNT_ENCOUNTER_NOT_FOUND',
);
});
it('rejects an already-consumed encounter, and does not create a second combat', async () => {
const state = createState({
huntEncounters: [huntEncounter({ consumedAt: new Date('2026-08-18T09:05:00.000Z') })],
});
const { dataSource, service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'HUNT_ENCOUNTER_ALREADY_CONSUMED',
);
expect(dataSource.state.combats).toHaveLength(0);
});
it('rejects an encounter belonging to a different character', async () => {
const state = createState({
characters: [character(), character({ id: OTHER_CHARACTER_ID })],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(OTHER_CHARACTER_ID, ENCOUNTER_ID),
'INVALID_HUNT_ENCOUNTER',
);
});
it('rejects an encounter whose hunt is no longer ACTIVE', async () => {
const state = createState({ hunts: [hunt({ status: HuntStatus.SUPERSEDED })] });
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'INVALID_HUNT_ENCOUNTER',
);
});
it('rejects starting combat while the character is travelling', async () => {
const { service } = createService({ travelService: fakeTravelService('TRAVELLING') });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'CHARACTER_TRAVELLING',
);
});
it('rejects starting a second combat while one is already ACTIVE', async () => {
const state = createState({
combats: [
{
id: 'combat-existing',
characterId: CHARACTER_ID,
huntEncounterId: 'other-encounter',
monsterDefinitionId: MONSTER_ID,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: 100,
playerCurrentHp: 100,
monsterMaxHp: 45,
monsterCurrentHp: 45,
playerState: { attack: 6, weaponDamage: 8, armor: 6 },
monsterState: { attack: 5, armor: 0 },
createdAt: new Date(),
updatedAt: new Date(),
completedAt: null,
} as Combat,
],
});
const { service } = createService({ state });
await expectCombatDomainError(
service.startCombat(CHARACTER_ID, ENCOUNTER_ID),
'COMBAT_ALREADY_ACTIVE',
);
});
it('locks the character and the active-combat lookup', async () => {
const { dataSource, service } = createService();
await service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
expect(dataSource.locks).toEqual(
expect.arrayContaining([
{ target: Character, mode: 'pessimistic_write' },
{ target: Combat, mode: 'pessimistic_write' },
]),
);
});
});
describe('performAction', () => {
async function startedCombat(state = createState()) {
const context = createService({ state });
const combat = await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
return { ...context, combatId: combat.id };
}
it('resolves ATTACK, persists HP/round changes, and returns them', async () => {
const { dataSource, service, combatId } = await startedCombat();
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(result.status).toBe('ACTIVE');
expect(result.round).toBe(2);
expect(result.monster.currentHp).toBe(45 - 14);
expect(result.player.currentHp).toBe(100 - 5);
expect(dataSource.state.combats[0].round).toBe(2);
expect(dataSource.state.combats[0].monsterCurrentHp).toBe(31);
expect(dataSource.state.combats[0].playerCurrentHp).toBe(95);
});
it('persists ordered, sequential CombatEvents across multiple rounds', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
const events = dataSource.state.combatEvents
.filter((event) => event.combatId === combatId)
.sort((a, b) => a.sequence - b.sequence);
expect(events.map((event) => event.sequence)).toEqual([1, 2, 3, 4]);
expect(events.map((event) => event.round)).toEqual([1, 1, 2, 2]);
expect(events[0]).toMatchObject({ source: 'PLAYER', target: 'MONSTER', type: 'DAMAGE', amount: 14 });
expect(events[1]).toMatchObject({ source: 'MONSTER', target: 'PLAYER', type: 'DAMAGE', amount: 5 });
});
it('ends the combat as WON, stops persisting new rounds, and rejects further actions', async () => {
const state = createState({ monsters: [monster({ maxHp: 10 })] });
const { dataSource, service, combatId } = await startedCombat(state);
const result = await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(result.status).toBe('WON');
expect(dataSource.state.combats[0].completedAt).not.toBeNull();
await expectCombatDomainError(
service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK),
'COMBAT_ALREADY_FINISHED',
);
});
it('rejects actions on an unknown combat id', async () => {
const { service } = createService();
await expectCombatDomainError(
service.performAction(CHARACTER_ID, 'unknown-combat', CombatAction.ATTACK),
'COMBAT_NOT_FOUND',
);
});
it('rejects actions from a character who does not own the combat', async () => {
const { service, combatId } = await startedCombat();
await expectCombatDomainError(
service.performAction(OTHER_CHARACTER_ID, combatId, CombatAction.ATTACK),
'COMBAT_NOT_FOUND',
);
});
it('locks the combat row for the duration of the action', async () => {
const { dataSource, service, combatId } = await startedCombat();
await service.performAction(CHARACTER_ID, combatId, CombatAction.ATTACK);
expect(dataSource.locks).toEqual(
expect.arrayContaining([{ target: Combat, mode: 'pessimistic_write' }]),
);
});
});
describe('getCombat', () => {
it('returns the persisted state and ordered events after a refresh', async () => {
const context = createService();
const started = await context.service.startCombat(CHARACTER_ID, ENCOUNTER_ID);
await context.service.performAction(CHARACTER_ID, started.id, CombatAction.ATTACK);
const reloaded = await context.service.getCombat(CHARACTER_ID, started.id);
expect(reloaded.round).toBe(2);
expect(reloaded.monster.currentHp).toBe(31);
expect(reloaded.events.map((event) => event.sequence)).toEqual([1, 2]);
});
it('rejects an unknown combat id', async () => {
const { service } = createService();
await expectCombatDomainError(service.getCombat(CHARACTER_ID, 'unknown'), 'COMBAT_NOT_FOUND');
});
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts
Expected: FAIL — cannot find module ./combat.service.
- Step 3: Implement CombatService
// apps/api/src/combat/combat.service.ts
import { Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { CharacterCombatStatsService } from '../characters/character-combat-stats.service';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { HuntStatus } from '../hunting/hunt-status.enum';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { CombatAction } from './combat-action.enum';
import { CombatEngineService } from './combat-engine.service';
import { CombatEngineState } from './combat-engine.types';
import {
characterNotFound,
characterTravelling,
combatAlreadyActive,
combatAlreadyFinished,
combatNotFound,
combatStateInvalid,
huntEncounterAlreadyConsumed,
huntEncounterNotFound,
invalidHuntEncounter,
} from './combat.errors';
import { CombatStatus } from './combat-status.enum';
import { CombatEvent } from './entities/combat-event.entity';
import { Combat } from './entities/combat.entity';
export interface CombatPlayerDto {
name: string;
maxHp: number;
currentHp: number;
}
export interface CombatMonsterDto {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
}
export interface CombatEventDto {
round: number;
sequence: number;
type: string;
source: string;
target: string;
amount?: number;
}
export interface CombatDto {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayerDto;
monster: CombatMonsterDto;
events: CombatEventDto[];
}
@Injectable()
export class CombatService {
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
private readonly combatEngine: CombatEngineService,
private readonly characterCombatStats: CharacterCombatStatsService,
) {}
async startCombat(characterId: string, encounterId: string): Promise<CombatDto> {
const travel = await this.travelService.completeTravelIfDue(characterId);
if (travel.status === TravelStatus.TRAVELLING) {
throw characterTravelling();
}
return this.dataSource.transaction(async (manager) => {
const characters = manager.getRepository(Character);
const encounters = manager.getRepository(HuntEncounter);
const hunts = manager.getRepository(Hunt);
const monsters = manager.getRepository(MonsterDefinition);
const combats = manager.getRepository(Combat);
const character = await this.lockCharacter(characters, characterId);
const encounter = await encounters.findOne({
where: { id: encounterId },
lock: { mode: 'pessimistic_write' },
});
if (!encounter) {
throw huntEncounterNotFound();
}
if (encounter.consumedAt) {
throw huntEncounterAlreadyConsumed();
}
const hunt = await hunts.findOneBy({ id: encounter.huntId });
if (!hunt || hunt.characterId !== characterId || hunt.status !== HuntStatus.ACTIVE) {
throw invalidHuntEncounter();
}
const existingActiveCombat = await combats.findOne({
where: { characterId, status: CombatStatus.ACTIVE },
lock: { mode: 'pessimistic_write' },
});
if (existingActiveCombat) {
throw combatAlreadyActive();
}
const monster = await monsters.findOneBy({ id: encounter.monsterDefinitionId });
if (!monster) {
throw invalidHuntEncounter();
}
const playerStats = this.characterCombatStats.getStats(character);
const combat = combats.create({
characterId,
huntEncounterId: encounter.id,
monsterDefinitionId: monster.id,
status: CombatStatus.ACTIVE,
round: 1,
playerMaxHp: playerStats.maxHp,
playerCurrentHp: playerStats.maxHp,
monsterMaxHp: monster.maxHp,
monsterCurrentHp: monster.maxHp,
playerState: {
attack: playerStats.attack,
weaponDamage: playerStats.weaponDamage,
armor: playerStats.armor,
},
monsterState: { attack: monster.attack, armor: monster.armor },
completedAt: null,
});
await combats.save(combat);
encounter.consumedAt = new Date();
await encounters.save(encounter);
return this.toCombatDto(combat, character.name, monster, []);
});
}
async getCombat(characterId: string, combatId: string): Promise<CombatDto> {
const combats = this.dataSource.getRepository(Combat);
const combat = await combats.findOne({ where: { id: combatId, characterId } });
if (!combat) {
throw combatNotFound();
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(combat.characterId),
this.loadMonster(combat.monsterDefinitionId),
this.loadEvents(combat.id),
]);
return this.toCombatDto(combat, character.name, monster, events);
}
async performAction(
characterId: string,
combatId: string,
action: CombatAction,
): Promise<CombatDto> {
return this.dataSource.transaction(async (manager) => {
const combats = manager.getRepository(Combat);
const combatEvents = manager.getRepository(CombatEvent);
const combat = await combats.findOne({
where: { id: combatId, characterId },
lock: { mode: 'pessimistic_write' },
});
if (!combat) {
throw combatNotFound();
}
if (combat.status !== CombatStatus.ACTIVE) {
throw combatAlreadyFinished();
}
const actionRound = combat.round;
const engineState = this.toEngineState(combat);
const result = this.combatEngine.resolveAction(engineState, { action });
combat.round = result.state.round;
combat.status = result.state.status;
combat.playerCurrentHp = result.state.player.currentHp;
combat.monsterCurrentHp = result.state.monster.currentHp;
if (combat.status !== CombatStatus.ACTIVE) {
combat.completedAt = new Date();
}
await combats.save(combat);
const startingSequence = await combatEvents.count({ where: { combatId: combat.id } });
for (let index = 0; index < result.events.length; index += 1) {
const event = result.events[index];
const entity = combatEvents.create({
combatId: combat.id,
round: actionRound,
sequence: startingSequence + index + 1,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? null,
});
await combatEvents.save(entity);
}
const [character, monster, events] = await Promise.all([
this.loadCharacter(combat.characterId, manager.getRepository(Character)),
this.loadMonster(combat.monsterDefinitionId, manager.getRepository(MonsterDefinition)),
this.loadEvents(combat.id, combatEvents),
]);
return this.toCombatDto(combat, character.name, monster, events);
});
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,
): Promise<Character> {
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
throw characterNotFound();
}
return character;
}
private async loadCharacter(
characterId: string,
repo?: Repository<Character>,
): Promise<Character> {
const characters = repo ?? this.dataSource.getRepository(Character);
const character = await characters.findOneBy({ id: characterId });
if (!character) {
// combats.character_id is a RESTRICT FK; a persisted combat's
// character is guaranteed to exist.
throw combatStateInvalid();
}
return character;
}
private async loadMonster(
monsterId: string,
repo?: Repository<MonsterDefinition>,
): Promise<MonsterDefinition> {
const monsters = repo ?? this.dataSource.getRepository(MonsterDefinition);
const monster = await monsters.findOneBy({ id: monsterId });
if (!monster) {
// combats.monster_definition_id is a RESTRICT FK; guaranteed to exist.
throw combatStateInvalid();
}
return monster;
}
private loadEvents(combatId: string, repo?: Repository<CombatEvent>): Promise<CombatEvent[]> {
const combatEvents = repo ?? this.dataSource.getRepository(CombatEvent);
return combatEvents.find({ where: { combatId }, order: { sequence: 'ASC' } });
}
private toEngineState(combat: Combat): CombatEngineState {
return {
status: combat.status,
round: combat.round,
player: {
currentHp: combat.playerCurrentHp,
maxHp: combat.playerMaxHp,
stats: combat.playerState,
},
monster: {
currentHp: combat.monsterCurrentHp,
maxHp: combat.monsterMaxHp,
stats: combat.monsterState,
},
};
}
private toCombatDto(
combat: Combat,
playerName: string,
monster: MonsterDefinition,
events: CombatEvent[],
): CombatDto {
return {
id: combat.id,
status: combat.status,
round: combat.round,
player: {
name: playerName,
maxHp: combat.playerMaxHp,
currentHp: combat.playerCurrentHp,
},
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
maxHp: combat.monsterMaxHp,
currentHp: combat.monsterCurrentHp,
artworkPath: monster.artworkPath,
},
events: events.map((event) => ({
round: event.round,
sequence: event.sequence,
type: event.type,
source: event.source,
target: event.target,
amount: event.amount ?? undefined,
})),
};
}
}
- Step 4: Run to confirm it passes
Run: npm run test --workspace=@ashen-realms/api -- combat.service.spec.ts
Expected: PASS (all cases). If any HuntEncounter/Hunt/Character/MonsterDefinition object-literal fixture elsewhere in the repo now fails to type-check because of the new consumedAt field, add consumedAt: null there too (see Task 1, Step 7).
- Step 5: Run the full backend suite
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS.
- Step 6: Commit
git add apps/api/src/combat/combat.service.ts apps/api/src/combat/combat.service.spec.ts
git commit -m "feat(combat): add CombatService orchestration and persistence"
Task 8: Combat controllers
Files:
- Create:
apps/api/src/combat/dto/combat-action.dto.ts - Create:
apps/api/src/combat/combat.controller.ts - Create:
apps/api/src/combat/hunt-encounter-attack.controller.ts - Test:
apps/api/src/combat/combat.controller.spec.ts - Test:
apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
Interfaces:
-
Consumes:
CombatService(Task 7). -
Produces:
POST /api/hunt-encounters/:encounterId/attack,GET /api/combats/:combatId,POST /api/combats/:combatId/actions— consumed by Task 9'sCombatModuleand Task 10's frontendGameApiService. -
Step 1: Write the failing controller tests
// apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
describe('HuntEncounterAttackController', () => {
let app: INestApplication<App>;
const startCombat = jest.fn();
beforeEach(async () => {
startCombat.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntEncounterAttackController],
providers: [{ provide: CombatService, useValue: { startCombat } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates to combatService.startCombat with the demo character id and the encounter id', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
startCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer())
.post('/api/hunt-encounters/encounter-1/attack')
.expect(201);
expect(startCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'encounter-1');
expect(response.body).toEqual(combat);
});
});
// apps/api/src/combat/combat.controller.spec.ts
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
describe('CombatController', () => {
let app: INestApplication<App>;
const getCombat = jest.fn();
const performAction = jest.fn();
beforeEach(async () => {
getCombat.mockReset();
performAction.mockReset();
const module = await Test.createTestingModule({
controllers: [CombatController],
providers: [{ provide: CombatService, useValue: { getCombat, performAction } }],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates GET /api/combats/:combatId to combatService.getCombat', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 1, player: {}, monster: {}, events: [] };
getCombat.mockResolvedValue(combat);
const response = await request(app.getHttpServer()).get('/api/combats/combat-1').expect(200);
expect(getCombat).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1');
expect(response.body).toEqual(combat);
});
it('delegates POST /api/combats/:combatId/actions with only the action field', async () => {
const combat = { id: 'combat-1', status: 'ACTIVE', round: 2, player: {}, monster: {}, events: [] };
performAction.mockResolvedValue(combat);
const response = await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK' })
.expect(201);
expect(performAction).toHaveBeenCalledWith(DEMO_CHARACTER_ID, 'combat-1', 'ATTACK');
expect(response.body).toEqual(combat);
});
it('rejects an unknown action value', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'HEAVY_STRIKE' })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
it('rejects server-owned combat fields the client must never send', async () => {
await request(app.getHttpServer())
.post('/api/combats/combat-1/actions')
.send({ action: 'ATTACK', damage: 999, playerHp: 1, monsterHp: 1, round: 99 })
.expect(400);
expect(performAction).not.toHaveBeenCalled();
});
});
- Step 2: Run to confirm they fail
Run: npm run test --workspace=@ashen-realms/api -- combat.controller.spec.ts hunt-encounter-attack.controller.spec.ts
Expected: FAIL — cannot find modules ./combat.controller, ./hunt-encounter-attack.controller.
- Step 3: Write the action DTO
// apps/api/src/combat/dto/combat-action.dto.ts
import { IsEnum } from 'class-validator';
import { CombatAction } from '../combat-action.enum';
export class CombatActionDto {
@IsEnum(CombatAction)
action!: CombatAction;
}
- Step 4: Write the two controllers
// apps/api/src/combat/hunt-encounter-attack.controller.ts
import { Controller, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatService } from './combat.service';
@Controller('hunt-encounters')
export class HuntEncounterAttackController {
constructor(private readonly combatService: CombatService) {}
@Post(':encounterId/attack')
attack(@Param('encounterId') encounterId: string) {
return this.combatService.startCombat(DEMO_CHARACTER_ID, encounterId);
}
}
// apps/api/src/combat/combat.controller.ts
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { CombatActionDto } from './dto/combat-action.dto';
import { CombatService } from './combat.service';
@Controller('combats')
export class CombatController {
constructor(private readonly combatService: CombatService) {}
@Get(':combatId')
getCombat(@Param('combatId') combatId: string) {
return this.combatService.getCombat(DEMO_CHARACTER_ID, combatId);
}
@Post(':combatId/actions')
performAction(@Param('combatId') combatId: string, @Body() dto: CombatActionDto) {
return this.combatService.performAction(DEMO_CHARACTER_ID, combatId, dto.action);
}
}
- Step 5: Run to confirm they pass
Run: npm run test --workspace=@ashen-realms/api -- combat.controller.spec.ts hunt-encounter-attack.controller.spec.ts
Expected: PASS (all cases).
- Step 6: Commit
git add apps/api/src/combat/dto apps/api/src/combat/combat.controller.ts apps/api/src/combat/combat.controller.spec.ts apps/api/src/combat/hunt-encounter-attack.controller.ts apps/api/src/combat/hunt-encounter-attack.controller.spec.ts
git commit -m "feat(combat): add HTTP controllers for starting, reading, and acting on combats"
Task 9: Wire the Combat module into the application
Files:
- Create:
apps/api/src/combat/combat.module.ts - Modify:
apps/api/src/characters/characters.module.ts - Modify:
apps/api/src/app.module.ts
Interfaces:
-
Consumes: everything from Tasks 1–8.
-
Produces: a fully wired
CombatModulereachable from the running app. -
Step 1: Export CharacterCombatStatsService from CharactersModule
// apps/api/src/characters/characters.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharacterCombatStatsService } from './character-combat-stats.service';
import { CharactersController } from './characters.controller';
import { CharactersService } from './characters.service';
import { Character } from './entities/character.entity';
@Module({
imports: [TypeOrmModule.forFeature([Character])],
controllers: [CharactersController],
providers: [CharactersService, CharacterCombatStatsService],
exports: [CharacterCombatStatsService],
})
export class CharactersModule {}
- Step 2: Create CombatModule
// apps/api/src/combat/combat.module.ts
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { CharactersModule } from '../characters/characters.module';
import { Character } from '../characters/entities/character.entity';
import { Hunt } from '../hunting/entities/hunt.entity';
import { HuntEncounter } from '../hunting/entities/hunt-encounter.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelModule } from '../travel/travel.module';
import { CombatEngineService } from './combat-engine.service';
import { CombatController } from './combat.controller';
import { CombatService } from './combat.service';
import { Combat } from './entities/combat.entity';
import { CombatEvent } from './entities/combat-event.entity';
import { HuntEncounterAttackController } from './hunt-encounter-attack.controller';
@Module({
imports: [
TypeOrmModule.forFeature([Character, Hunt, HuntEncounter, MonsterDefinition, Combat, CombatEvent]),
TravelModule,
CharactersModule,
],
controllers: [CombatController, HuntEncounterAttackController],
providers: [CombatService, CombatEngineService],
})
export class CombatModule {}
- Step 3: Register CombatModule in AppModule
// apps/api/src/app.module.ts
import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { CombatModule } from './combat/combat.module';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@Module({
imports: [
DatabaseModule,
HealthModule,
CharactersModule,
TravelModule,
WorldModule,
HuntingModule,
CombatModule,
],
})
export class AppModule {}
- Step 4: Build and run the full backend test suite
Run: npm run build --workspace=@ashen-realms/api
Expected: succeeds with no errors (confirms module wiring compiles — circular-import or missing-export mistakes surface here).
Run: npm run test --workspace=@ashen-realms/api
Expected: PASS — full suite green.
- Step 5: Manually verify the wired endpoints against the running dev database
Run: npm run dev:api (in one terminal), then in another terminal, using the seeded demo hunt/encounter ids from npm run db:seed:
curl -X POST http://localhost:3000/api/hunts
# copy an encounters[].id from the response, then:
curl -X POST http://localhost:3000/api/hunt-encounters/<encounterId>/attack
# copy the returned combat.id, then:
curl http://localhost:3000/api/combats/<combatId>
curl -X POST http://localhost:3000/api/combats/<combatId>/actions -H "Content-Type: application/json" -d '{"action":"ATTACK"}'
Expected: the attack endpoint returns an ACTIVE combat with full HP; the actions endpoint reduces monster/player HP per the damage formula and returns updated events; repeating the attack on the same encounter id returns 409 HUNT_ENCOUNTER_ALREADY_CONSUMED; starting a second combat while the first is active returns 409 COMBAT_ALREADY_ACTIVE.
- Step 6: Commit
git add apps/api/src/combat/combat.module.ts apps/api/src/characters/characters.module.ts apps/api/src/app.module.ts
git commit -m "feat(combat): wire CombatModule into the application"
Task 10: Frontend Combat models and API methods
Files:
- Modify:
apps/web/src/app/core/api/game-api.models.ts - Modify:
apps/web/src/app/core/api/game-api.service.ts - Test:
apps/web/src/app/core/api/game-api.service.spec.ts
Interfaces:
-
Produces:
Combat,CombatPlayer,CombatMonster,CombatEvent,CombatStatus,CombatEventType,CombatSide,CombatActiontypes;GameApiService.startCombat(encounterId): Observable<Combat>,.getCombat(combatId): Observable<Combat>,.performCombatAction(combatId, action): Observable<Combat>— consumed by Task 12'sCombatStore. -
Step 1: Write the failing tests (append to the existing spec)
Add to apps/web/src/app/core/api/game-api.service.spec.ts, inside the existing describe('GameApiService', ...) block:
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe();
const request = http.expectOne('/api/hunt-encounters/encounter-uuid/attack');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({});
request.flush({});
});
it('gets a combat by id', () => {
service.getCombat('combat-uuid').subscribe();
const request = http.expectOne('/api/combats/combat-uuid');
expect(request.request.method).toBe('GET');
request.flush({});
});
it('posts only the action enum when performing a combat action', () => {
service.performCombatAction('combat-uuid', 'ATTACK').subscribe();
const request = http.expectOne('/api/combats/combat-uuid/actions');
expect(request.request.method).toBe('POST');
expect(request.request.body).toEqual({ action: 'ATTACK' });
request.flush({});
});
- Step 2: Run to confirm they fail
Run: npm run test --workspace=@ashen-realms/web -- game-api.service.spec.ts
Expected: FAIL — service.startCombat is not a function.
- Step 3: Add the Combat models
Append to apps/web/src/app/core/api/game-api.models.ts:
export type CombatStatus = 'ACTIVE' | 'WON' | 'LOST';
export type CombatEventType = 'DAMAGE' | 'COMBAT_WON' | 'COMBAT_LOST';
export type CombatSide = 'PLAYER' | 'MONSTER';
export type CombatAction = 'ATTACK';
export interface CombatEvent {
round: number;
sequence: number;
type: CombatEventType;
source: CombatSide;
target: CombatSide;
amount?: number;
}
export interface CombatPlayer {
name: string;
maxHp: number;
currentHp: number;
}
export interface CombatMonster {
key: string;
name: string;
level: number;
maxHp: number;
currentHp: number;
artworkPath: string;
}
export interface Combat {
id: string;
status: CombatStatus;
round: number;
player: CombatPlayer;
monster: CombatMonster;
events: CombatEvent[];
}
- Step 4: Add the API methods
// apps/web/src/app/core/api/game-api.service.ts
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import {
CharacterResponse,
Combat,
CombatAction,
CurrentLocationResponse,
CurrentTravel,
HuntResult,
} from './game-api.models';
@Injectable({ providedIn: 'root' })
export class GameApiService {
constructor(private readonly http: HttpClient) {}
getCharacter(): Observable<CharacterResponse> {
return this.http.get<CharacterResponse>('/api/characters/me');
}
getCurrentLocation(): Observable<CurrentLocationResponse> {
return this.http.get<CurrentLocationResponse>('/api/world/current-location');
}
startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
}
getCurrentTravel(): Observable<CurrentTravel> {
return this.http.get<CurrentTravel>('/api/travel/current');
}
startHunt(): Observable<HuntResult> {
return this.http.post<HuntResult>('/api/hunts', {});
}
startCombat(encounterId: string): Observable<Combat> {
return this.http.post<Combat>(`/api/hunt-encounters/${encounterId}/attack`, {});
}
getCombat(combatId: string): Observable<Combat> {
return this.http.get<Combat>(`/api/combats/${combatId}`);
}
performCombatAction(combatId: string, action: CombatAction): Observable<Combat> {
return this.http.post<Combat>(`/api/combats/${combatId}/actions`, { action });
}
}
- Step 5: Run to confirm they pass
Run: npm run test --workspace=@ashen-realms/web -- game-api.service.spec.ts
Expected: PASS (all cases).
- Step 6: Commit
git add apps/web/src/app/core/api/game-api.models.ts apps/web/src/app/core/api/game-api.service.ts apps/web/src/app/core/api/game-api.service.spec.ts
git commit -m "feat(combat): add frontend Combat models and API methods"
Task 11: Extract the shared monster-artwork helper
Files:
- Create:
apps/web/src/app/shared/monster-artwork.ts - Test:
apps/web/src/app/shared/monster-artwork.spec.ts - Modify:
apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts
Interfaces:
- Produces:
runtimeMonsterArtworkPath(artworkPath: string): string | undefined— consumed byEncounterCardComponent(existing) and Task 13'sCombatPageComponent.
EncounterCardComponent currently hardcodes a small runtimeArtworkPaths lookup for the optimized JPEG derivatives (see apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts). CombatPageComponent needs the exact same lookup for the monster portrait in combat, so it is extracted once here rather than duplicated.
- Step 1: Write the failing test
// apps/web/src/app/shared/monster-artwork.spec.ts
import { runtimeMonsterArtworkPath } from './monster-artwork';
describe('runtimeMonsterArtworkPath', () => {
it('returns the optimized JPEG derivative for a known monster artwork path', () => {
expect(runtimeMonsterArtworkPath('/images/monsters/ash-rat.png')).toBe(
'/images/monsters/runtime/ash-rat-560.jpg',
);
});
it('returns undefined for an artwork path with no runtime derivative', () => {
expect(runtimeMonsterArtworkPath('/images/enemies/Dawnwolf.png')).toBeUndefined();
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/web -- monster-artwork.spec.ts
Expected: FAIL — cannot find module ./monster-artwork.
- Step 3: Implement it
// apps/web/src/app/shared/monster-artwork.ts
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
return RUNTIME_MONSTER_ARTWORK[artworkPath];
}
- Step 4: Refactor EncounterCardComponent to use it
// apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { HuntEncounter } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
@Component({
selector: 'app-encounter-card',
imports: [DangerBadgeComponent],
templateUrl: './encounter-card.component.html',
styleUrl: './encounter-card.component.scss',
})
export class EncounterCardComponent {
@Input({ required: true }) encounter!: HuntEncounter;
@Output() readonly attack = new EventEmitter<string>();
protected onAttack(): void {
this.attack.emit(this.encounter.id);
}
protected runtimeArtworkPath(artworkPath: string): string | undefined {
return runtimeMonsterArtworkPath(artworkPath);
}
}
- Step 5: Run to confirm everything still passes
Run: npm run test --workspace=@ashen-realms/web -- monster-artwork.spec.ts encounter-card.component.spec.ts
Expected: PASS (all cases) — EncounterCardComponent's existing behavior is unchanged.
- Step 6: Commit
git add apps/web/src/app/shared/monster-artwork.ts apps/web/src/app/shared/monster-artwork.spec.ts apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts
git commit -m "refactor(web): extract shared runtime monster-artwork lookup"
Task 12: CombatStore
Files:
- Create:
apps/web/src/app/features/combat/combat.store.ts - Test:
apps/web/src/app/features/combat/combat.store.spec.ts
Interfaces:
-
Consumes:
GameApiService.startCombat/getCombat/performCombatAction(Task 10). -
Produces:
CombatStorewithcombat: Signal<Combat | null>,loading: Signal<boolean>,actionPending: Signal<boolean>,error: Signal<string | null>,startCombat(encounterId): Promise<void>,loadCombat(combatId): Promise<void>,attack(): Promise<void>,clearError(): void— consumed by Task 13'sCombatPageComponentand Task 14'sHuntPageComponent. -
Step 1: Write the failing tests
// apps/web/src/app/features/combat/combat.store.spec.ts
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { from, of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { CombatStore } from './combat.store';
const startedCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 45,
artworkPath: '/images/monsters/ash-rat.png',
},
events: [],
};
const afterAttack: Combat = {
...startedCombat,
round: 2,
player: { ...startedCombat.player, currentHp: 95 },
monster: { ...startedCombat.monster, currentHp: 31 },
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
describe('CombatStore', () => {
let api: {
startCombat: ReturnType<typeof vi.fn>;
getCombat: ReturnType<typeof vi.fn>;
performCombatAction: ReturnType<typeof vi.fn>;
};
let store: CombatStore;
beforeEach(() => {
api = {
startCombat: vi.fn(() => of(startedCombat)),
getCombat: vi.fn(() => of(startedCombat)),
performCombatAction: vi.fn(() => of(afterAttack)),
};
TestBed.configureTestingModule({
providers: [CombatStore, { provide: GameApiService, useValue: api }],
});
store = TestBed.inject(CombatStore);
});
it('starts a combat and stores it', async () => {
await store.startCombat('encounter-1');
expect(api.startCombat).toHaveBeenCalledWith('encounter-1');
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBeNull();
});
it('clears any previous combat and reports the mapped error when starting fails', async () => {
api.startCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'COMBAT_ALREADY_ACTIVE', message: 'Active.' },
}),
),
);
await store.startCombat('encounter-1');
expect(store.combat()).toBeNull();
expect(store.error()).toBe('Du befindest dich bereits in einem Kampf.');
});
it('loads a combat by id', async () => {
await store.loadCombat('combat-1');
expect(api.getCombat).toHaveBeenCalledWith('combat-1');
expect(store.combat()).toEqual(startedCombat);
});
it('reports the mapped error when loading an unknown combat', async () => {
api.getCombat.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 404,
error: { statusCode: 404, code: 'COMBAT_NOT_FOUND', message: 'Not found.' },
}),
),
);
await store.loadCombat('unknown');
expect(store.error()).toBe('Dieser Kampf wurde nicht gefunden.');
});
it('sends only the ATTACK action and replaces combat with the server response', async () => {
await store.startCombat('encounter-1');
await store.attack();
expect(api.performCombatAction).toHaveBeenCalledWith('combat-1', 'ATTACK');
expect(store.combat()).toEqual(afterAttack);
});
it('does nothing when attacking without a loaded combat', async () => {
await store.attack();
expect(api.performCombatAction).not.toHaveBeenCalled();
});
it('ignores a second attack while the first is still pending', async () => {
await store.startCombat('encounter-1');
let resolveAttack!: (value: Combat) => void;
api.performCombatAction.mockReturnValue(
from(
new Promise<Combat>((resolve) => {
resolveAttack = resolve;
}),
),
);
const first = store.attack();
expect(store.actionPending()).toBe(true);
const second = store.attack();
resolveAttack(afterAttack);
await Promise.all([first, second]);
expect(api.performCombatAction).toHaveBeenCalledOnce();
});
it('clears actionPending after a failed attack and keeps the previous combat state', async () => {
await store.startCombat('encounter-1');
api.performCombatAction.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.attack();
expect(store.actionPending()).toBe(false);
expect(store.combat()).toEqual(startedCombat);
expect(store.error()).toBe('Netzwerkfehler');
});
it('clears the error message', async () => {
api.startCombat.mockReturnValue(throwError(() => new Error('x')));
await store.startCombat('encounter-1');
expect(store.error()).not.toBeNull();
store.clearError();
expect(store.error()).toBeNull();
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/web -- combat.store.spec.ts
Expected: FAIL — cannot find module ./combat.store.
- Step 3: Implement CombatStore
// apps/web/src/app/features/combat/combat.store.ts
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { Combat } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Der Kampf konnte nicht geladen werden.';
// Mirrors the combat error codes returned by the combat endpoints.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const COMBAT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
HUNT_ENCOUNTER_NOT_FOUND: 'Diese Begegnung wurde nicht gefunden.',
HUNT_ENCOUNTER_ALREADY_CONSUMED: 'Diese Begegnung wurde bereits genutzt.',
INVALID_HUNT_ENCOUNTER: 'Diese Begegnung ist nicht mehr gültig.',
CHARACTER_TRAVELLING: 'Du kannst nicht kämpfen, während du unterwegs bist.',
COMBAT_ALREADY_ACTIVE: 'Du befindest dich bereits in einem Kampf.',
COMBAT_NOT_FOUND: 'Dieser Kampf wurde nicht gefunden.',
COMBAT_ALREADY_FINISHED: 'Dieser Kampf ist bereits beendet.',
};
@Injectable({ providedIn: 'root' })
export class CombatStore {
private readonly combatState = signal<Combat | null>(null);
private readonly loadingState = signal(false);
private readonly actionPendingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly combat = this.combatState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly actionPending = this.actionPendingState.asReadonly();
readonly error = this.errorState.asReadonly();
constructor(private readonly api: GameApiService) {}
async startCombat(encounterId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const combat = await firstValueFrom(this.api.startCombat(encounterId));
this.combatState.set(combat);
} catch (error) {
this.combatState.set(null);
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
async loadCombat(combatId: string): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const combat = await firstValueFrom(this.api.getCombat(combatId));
this.combatState.set(combat);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
async attack(): Promise<void> {
const combat = this.combatState();
if (!combat || this.actionPendingState()) {
return;
}
this.actionPendingState.set(true);
this.errorState.set(null);
try {
const updated = await firstValueFrom(this.api.performCombatAction(combat.id, 'ATTACK'));
this.combatState.set(updated);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.actionPendingState.set(false);
}
}
clearError(): void {
this.errorState.set(null);
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && COMBAT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}
- Step 4: Run to confirm it passes
Run: npm run test --workspace=@ashen-realms/web -- combat.store.spec.ts
Expected: PASS (all cases).
- Step 5: Commit
git add apps/web/src/app/features/combat/combat.store.ts apps/web/src/app/features/combat/combat.store.spec.ts
git commit -m "feat(combat): add CombatStore"
Task 13: CombatPageComponent and route wiring
Files:
- Create:
apps/web/src/app/features/combat/combat-page/combat-page.component.ts - Create:
apps/web/src/app/features/combat/combat-page/combat-page.component.html - Create:
apps/web/src/app/features/combat/combat-page/combat-page.component.scss - Test:
apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts - Modify:
apps/web/src/app/app.routes.ts - Delete:
apps/web/src/app/features/combat/combat-placeholder-page.component.ts
Interfaces:
-
Consumes:
CombatStore(Task 12),runtimeMonsterArtworkPath(Task 11). -
Produces: the
/combat/:combatIdroute. -
Step 1: Write the failing component test
// apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { ActivatedRoute, convertToParamMap, Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat } from '../../../core/api/game-api.models';
import { CombatStore } from '../combat.store';
import { CombatPageComponent } from './combat-page.component';
const activeCombat: Combat = {
id: 'combat-1',
status: 'ACTIVE',
round: 2,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 95 },
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
currentHp: 31,
artworkPath: '/images/monsters/ash-rat.png',
},
events: [
{ round: 1, sequence: 1, type: 'DAMAGE', source: 'PLAYER', target: 'MONSTER', amount: 14 },
{ round: 1, sequence: 2, type: 'DAMAGE', source: 'MONSTER', target: 'PLAYER', amount: 5 },
],
};
describe('CombatPageComponent', () => {
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
loading: ReturnType<typeof signal<boolean>>;
actionPending: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
loadCombat: ReturnType<typeof vi.fn>;
attack: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(combat: Combat | null) {
combatStore = {
combat: signal(combat),
loading: signal(false),
actionPending: signal(false),
error: signal<string | null>(null),
loadCombat: vi.fn(() => Promise.resolve()),
attack: vi.fn(() => Promise.resolve()),
};
await TestBed.configureTestingModule({
imports: [CombatPageComponent],
providers: [
provideRouter([]),
{ provide: CombatStore, useValue: combatStore },
{
provide: ActivatedRoute,
useValue: { snapshot: { paramMap: convertToParamMap({ combatId: 'combat-1' }) } },
},
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(CombatPageComponent);
fixture.detectChanges();
return fixture;
}
it('loads the combat from the route param on init', async () => {
await setup(activeCombat);
expect(combatStore.loadCombat).toHaveBeenCalledWith('combat-1');
});
it('shows the player, monster, HP bars, round, and the Angriff action', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker');
expect(element.textContent).toContain('95 / 100');
expect(element.textContent).toContain('Aschenratte');
expect(element.textContent).toContain('31 / 45');
expect(element.querySelector('[data-combat-round]')?.textContent).toContain('Runde 2');
expect(element.querySelector('[data-combat-attack]')).toBeTruthy();
});
it('renders the structured events as readable German combat-log entries', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Aric Duskwalker trifft Aschenratte für 14 Schaden.');
expect(element.textContent).toContain('Aschenratte trifft Aric Duskwalker für 5 Schaden.');
});
it('calls combatStore.attack() when Angriff is clicked', async () => {
const fixture = await setup(activeCombat);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.click();
expect(combatStore.attack).toHaveBeenCalledOnce();
});
it('disables Angriff while an action is pending', async () => {
const fixture = await setup(activeCombat);
combatStore.actionPending.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector<HTMLButtonElement>('[data-combat-attack]')?.disabled).toBe(true);
});
it('shows the victory state and hides Angriff when the combat is WON', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="WON"]')).toBeTruthy();
expect(element.textContent).toContain('Sieg');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('shows the defeat state and hides Angriff when the combat is LOST', async () => {
const fixture = await setup({ ...activeCombat, status: 'LOST' });
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[data-combat-result="LOST"]')).toBeTruthy();
expect(element.textContent).toContain('Niederlage');
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
it('navigates to /hunt from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-combat-to-hunt]')?.click();
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
});
it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'Dieser Kampf wurde nicht gefunden.',
);
element.querySelector<HTMLButtonElement>('[data-combat-retry]')?.click();
expect(combatStore.loadCombat).toHaveBeenCalledTimes(2);
});
});
- Step 2: Run to confirm it fails
Run: npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts
Expected: FAIL — cannot find module ./combat-page.component.
- Step 3: Implement the component
// apps/web/src/app/features/combat/combat-page/combat-page.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import type { CombatEvent } from '../../../core/api/game-api.models';
import { runtimeMonsterArtworkPath } from '../../../shared/monster-artwork';
import { CombatStore } from '../combat.store';
interface CombatLogRound {
round: number;
events: CombatEvent[];
}
@Component({
selector: 'app-combat-page',
templateUrl: './combat-page.component.html',
styleUrl: './combat-page.component.scss',
})
export class CombatPageComponent implements OnInit {
protected readonly combatStore = inject(CombatStore);
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
ngOnInit(): void {
this.loadFromRoute();
}
protected attack(): void {
void this.combatStore.attack();
}
protected retry(): void {
this.loadFromRoute();
}
protected goToHunt(): void {
void this.router.navigate(['/hunt']);
}
protected runtimeMonsterArtwork(artworkPath: string): string | undefined {
return runtimeMonsterArtworkPath(artworkPath);
}
protected playerHpPercent(): number {
const combat = this.combatStore.combat();
return combat ? (combat.player.currentHp / combat.player.maxHp) * 100 : 0;
}
protected monsterHpPercent(): number {
const combat = this.combatStore.combat();
return combat ? (combat.monster.currentHp / combat.monster.maxHp) * 100 : 0;
}
protected logRounds(): CombatLogRound[] {
const combat = this.combatStore.combat();
if (!combat) {
return [];
}
const rounds = new Map<number, CombatEvent[]>();
for (const event of combat.events) {
const events = rounds.get(event.round) ?? [];
events.push(event);
rounds.set(event.round, events);
}
return [...rounds.entries()].sort(([a], [b]) => a - b).map(([round, events]) => ({ round, events }));
}
protected formatEvent(event: CombatEvent): string {
const combat = this.combatStore.combat();
const playerName = combat?.player.name ?? 'Du';
const monsterName = combat?.monster.name ?? 'Der Gegner';
if (event.type === 'DAMAGE') {
const attacker = event.source === 'PLAYER' ? playerName : monsterName;
const defender = event.target === 'PLAYER' ? playerName : monsterName;
return `${attacker} trifft ${defender} für ${event.amount} Schaden.`;
}
if (event.type === 'COMBAT_WON') {
return `${monsterName} wurde besiegt.`;
}
return `${playerName} wurde im Kampf besiegt.`;
}
private loadFromRoute(): void {
const combatId = this.route.snapshot.paramMap.get('combatId');
if (combatId) {
void this.combatStore.loadCombat(combatId);
}
}
}
- Step 4: Write the template
<!-- apps/web/src/app/features/combat/combat-page/combat-page.component.html -->
<section class="combat-page" aria-label="Kampf">
@if (combatStore.combat(); as combat) {
<div class="combat-page__scene">
<div class="combat-page__combatant combat-page__combatant--player">
<img
class="combat-page__portrait"
src="/images/hud/runtime/CharacterIcon-128.png"
[alt]="combat.player.name"
/>
<div class="combat-page__info">
<span class="combat-page__name">{{ combat.player.name }}</span>
<div class="combat-page__hp" aria-label="Lebenspunkte">
<span>{{ combat.player.currentHp }} / {{ combat.player.maxHp }}</span>
<span class="combat-page__hp-track" aria-hidden="true">
<span class="combat-page__hp-value" [style.inline-size.%]="playerHpPercent()"></span>
</span>
</div>
</div>
</div>
<div class="combat-page__round" data-combat-round>Runde {{ combat.round }}</div>
<div class="combat-page__combatant combat-page__combatant--monster">
<picture>
@if (runtimeMonsterArtwork(combat.monster.artworkPath); as runtimeArtwork) {
<source [srcset]="runtimeArtwork" type="image/jpeg" />
}
<img
class="combat-page__portrait"
[src]="combat.monster.artworkPath"
[alt]="combat.monster.name"
/>
</picture>
<div class="combat-page__info">
<span class="combat-page__name">{{ combat.monster.name }}</span>
<span class="combat-page__level">Stufe {{ combat.monster.level }}</span>
<div class="combat-page__hp" aria-label="Lebenspunkte des Gegners">
<span>{{ combat.monster.currentHp }} / {{ combat.monster.maxHp }}</span>
<span class="combat-page__hp-track" aria-hidden="true">
<span
class="combat-page__hp-value combat-page__hp-value--monster"
[style.inline-size.%]="monsterHpPercent()"
></span>
</span>
</div>
</div>
</div>
</div>
<div class="combat-page__actions">
@if (combat.status === 'ACTIVE') {
<button
type="button"
class="combat-page__attack"
data-combat-attack
[disabled]="combatStore.actionPending()"
(click)="attack()"
>
Angriff
</button>
} @else if (combat.status === 'WON') {
<section class="combat-page__result combat-page__result--won" data-combat-result="WON">
<h2>Sieg</h2>
<p>{{ combat.monster.name }} wurde besiegt.</p>
<p class="combat-page__result-hint">Belohnungen werden im nächsten Schritt verarbeitet.</p>
<button type="button" data-combat-to-hunt (click)="goToHunt()">Zur Jagd</button>
</section>
} @else {
<section class="combat-page__result combat-page__result--lost" data-combat-result="LOST">
<h2>Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<button type="button" data-combat-to-hunt (click)="goToHunt()">Zur Jagd</button>
</section>
}
</div>
<aside class="combat-page__log" aria-label="Kampfprotokoll">
@for (round of logRounds(); track round.round) {
<p class="combat-page__log-round">Runde {{ round.round }}</p>
@for (event of round.events; track event.sequence) {
<p class="combat-page__log-entry">{{ formatEvent(event) }}</p>
}
}
</aside>
} @else if (combatStore.loading()) {
<p class="combat-page__loading" role="status">Kampf wird geladen…</p>
}
@if (combatStore.error(); as error) {
<section class="combat-page__error" role="alert">
<p>{{ error }}</p>
<button type="button" data-combat-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>
- Step 5: Write the stylesheet
// apps/web/src/app/features/combat/combat-page/combat-page.component.scss
:host {
display: block;
min-block-size: 100%;
}
.combat-page {
display: grid;
gap: var(--ar-space-4);
}
.combat-page__scene {
position: relative;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: end;
gap: var(--ar-space-4);
min-block-size: clamp(18rem, 40vh, 26rem);
padding: var(--ar-space-5);
overflow: hidden;
border: 1px solid var(--ar-border);
background-color: #151718;
background-image: url('/images/backgrounds/Aschestrasse.png');
background-position: center;
background-repeat: no-repeat;
background-size: cover;
box-shadow: var(--ar-shadow-raised);
}
@supports (
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
)
) {
.combat-page__scene {
background-image: image-set(
url('/images/backgrounds/runtime/Aschestrasse-960.jpg') type('image/jpeg') 1x
);
}
}
.combat-page__scene::before {
position: absolute;
z-index: 0;
inset: 0;
content: '';
background: linear-gradient(180deg, rgb(4 6 8 / 0.15), rgb(4 6 8 / 0.6));
pointer-events: none;
}
.combat-page__combatant {
position: relative;
z-index: 1;
display: grid;
gap: var(--ar-space-2);
justify-items: center;
text-align: center;
}
.combat-page__combatant--player {
justify-self: start;
}
.combat-page__combatant--monster {
justify-self: end;
}
.combat-page__portrait {
inline-size: clamp(6rem, 14vw, 10rem);
block-size: clamp(6rem, 14vw, 10rem);
object-fit: cover;
border: 2px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
box-shadow: var(--ar-shadow-raised);
}
.combat-page__info {
display: grid;
gap: var(--ar-space-1);
min-inline-size: 10rem;
color: var(--ar-text);
text-shadow: 0 0.1rem 0.4rem rgb(0 0 0 / 0.85);
}
.combat-page__name {
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.15rem;
}
.combat-page__level {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.combat-page__hp {
display: grid;
gap: var(--ar-space-1);
font-size: var(--ar-font-sm);
}
.combat-page__hp-track {
display: block;
inline-size: 100%;
block-size: 0.5rem;
overflow: hidden;
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: rgb(0 0 0 / 0.5);
}
.combat-page__hp-value {
display: block;
block-size: 100%;
background: var(--ar-success);
}
.combat-page__hp-value--monster {
background: var(--ar-danger);
}
.combat-page__round {
position: relative;
z-index: 1;
align-self: start;
justify-self: center;
padding: var(--ar-space-1) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-gold);
background: rgb(9 11 13 / 0.75);
font-family: Georgia, 'Times New Roman', serif;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.combat-page__actions {
display: grid;
justify-items: center;
}
.combat-page__attack {
padding: var(--ar-space-3) var(--ar-space-6);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.1rem;
letter-spacing: 0.03em;
}
.combat-page__attack:hover:not(:disabled) {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.combat-page__attack:disabled {
cursor: not-allowed;
opacity: 0.6;
}
.combat-page__result {
display: grid;
gap: var(--ar-space-2);
max-inline-size: 30rem;
padding: var(--ar-space-5);
border: 1px solid var(--ar-border-highlight);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.combat-page__result h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
font-weight: 400;
}
.combat-page__result--won h2 {
color: var(--ar-success);
}
.combat-page__result--lost h2 {
color: var(--ar-danger);
}
.combat-page__result-hint {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
}
.combat-page__log {
display: grid;
gap: var(--ar-space-1);
max-block-size: 16rem;
padding: var(--ar-space-4);
overflow-y: auto;
border: 1px solid var(--ar-border);
background: var(--ar-panel-muted);
box-shadow: var(--ar-shadow-raised);
}
.combat-page__log-round {
margin: var(--ar-space-2) 0 0;
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.combat-page__log-entry {
margin: 0;
color: var(--ar-text-muted);
}
.combat-page__loading,
.combat-page__error {
padding: var(--ar-space-4);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.combat-page__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
border-color: var(--ar-danger);
}
.combat-page__error button {
flex: 0 0 auto;
padding: var(--ar-space-2) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: #1a2023;
cursor: pointer;
}
@media (prefers-reduced-motion: no-preference) {
.combat-page__hp-value {
transition: inline-size var(--ar-motion-base);
}
}
@media (width < 720px) {
.combat-page__scene {
grid-template-columns: 1fr;
justify-items: center;
text-align: center;
}
.combat-page__combatant--player,
.combat-page__combatant--monster {
justify-self: center;
}
}
- Step 6: Run the component test to confirm it passes
Run: npm run test --workspace=@ashen-realms/web -- combat-page.component.spec.ts
Expected: PASS (all cases).
- Step 7: Wire the route and delete the placeholder
// apps/web/src/app/app.routes.ts
import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'world' },
{
path: '',
component: AppShellComponent,
children: [
{
path: 'world',
loadComponent: () =>
import('./features/world/world-page.component').then((module) => module.WorldPageComponent),
},
{
path: 'hunt',
loadComponent: () =>
import('./features/hunting/hunt-page/hunt-page.component').then(
(module) => module.HuntPageComponent,
),
},
{
path: 'combat/:combatId',
loadComponent: () =>
import('./features/combat/combat-page/combat-page.component').then(
(module) => module.CombatPageComponent,
),
},
],
},
{ path: '**', redirectTo: 'world' },
];
Delete apps/web/src/app/features/combat/combat-placeholder-page.component.ts (superseded by CombatPageComponent; it had no dedicated spec file).
- Step 8: Run the full frontend build and test suite
Run: npm run build --workspace=@ashen-realms/web
Expected: succeeds — confirms the deleted placeholder has no remaining references and the new route compiles.
Run: npm run test --workspace=@ashen-realms/web
Expected: PASS — HuntPageComponent still uses its Slice 0.2 /combat/new placeholder flow at this point (Task 14 rewires it), so nothing here is broken yet.
- Step 9: Commit
git add apps/web/src/app/features/combat/combat-page apps/web/src/app/app.routes.ts
git rm apps/web/src/app/features/combat/combat-placeholder-page.component.ts
git commit -m "feat(combat): add CombatPageComponent and replace the combat/new placeholder route"
Task 14: Wire HuntPageComponent to start real combats
Files:
- Modify:
apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts - Modify:
apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html - Modify:
apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
Interfaces:
-
Consumes:
CombatStore.startCombat/combat/error/clearError(Task 12). -
Produces:
Angreifenon the hunt page starts a real combat and navigates to/combat/:combatId, replacing the Slice 0.2/combat/newplaceholder flow. -
Step 1: Update the failing/changed tests in hunt-page.component.spec.ts
Replace the whole file with:
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const threeEncounterHunt: HuntResult = {
id: 'hunt-id',
location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' },
encounters: [
{
id: 'encounter-1',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
{
id: 'encounter-2',
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
artworkPath: '/images/enemies/RoadBandit.png',
},
dangerRating: 'MATCH',
},
{
id: 'encounter-3',
monster: { key: 'ash-rat', name: 'Aschenratte', level: 1, artworkPath: '/images/enemies/AshRat.png' },
dangerRating: 'WEAK',
},
],
};
const startedCombat: Combat = {
id: 'combat-2',
status: 'ACTIVE',
round: 1,
player: { name: 'Aric Duskwalker', maxHp: 100, currentHp: 100 },
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
maxHp: 75,
currentHp: 75,
artworkPath: '/images/enemies/RoadBandit.png',
},
events: [],
};
describe('HuntPageComponent', () => {
let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
load: ReturnType<typeof vi.fn>;
};
let huntingStore: {
currentHunt: ReturnType<typeof signal<HuntResult | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
encounters: () => HuntResult['encounters'];
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let combatStore: {
combat: ReturnType<typeof signal<Combat | null>>;
error: ReturnType<typeof signal<string | null>>;
startCombat: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
worldStore = { currentLocation: signal(location), load: vi.fn(() => Promise.resolve()) };
const currentHunt = signal(hunt);
huntingStore = {
currentHunt,
loading: signal(false),
error: signal<string | null>(null),
encounters: () => currentHunt()?.encounters ?? [],
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
combatStore = {
combat: signal<Combat | null>(null),
error: signal<string | null>(null),
startCombat: vi.fn(() => Promise.resolve()),
clearError: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [HuntPageComponent],
providers: [
provideRouter([]),
{ provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore },
{ provide: CombatStore, useValue: combatStore },
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(HuntPageComponent);
fixture.detectChanges();
return fixture;
}
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
const fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain('Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.');
expect(
Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen',
),
).toBe(false);
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
toWorldButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/world']);
});
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
const fixture = await setup(burnedRoad);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
it('renders 3 encounter cards, duplicates included, with the correct data', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/);
expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2);
expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1);
expect(element.textContent).toContain('Stufe 1');
expect(element.textContent).toContain('Stufe 3');
});
it('calls refreshHunt when Neu suchen is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-refresh]')?.click();
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
});
it('starts a real combat from the encounter id (not the monster key) and navigates to /combat/:combatId', async () => {
combatStore.startCombat.mockImplementation(async () => {
combatStore.combat.set(startedCombat);
});
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
expect(attackButtons.length).toBe(3);
attackButtons[1].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-2');
expect(combatStore.startCombat).not.toHaveBeenCalledWith('road-bandit');
expect(router.navigate).toHaveBeenCalledWith(['/combat', 'combat-2']);
});
it('does not navigate when starting the combat fails', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
attackButtons[0].click();
await Promise.resolve();
await Promise.resolve();
expect(combatStore.startCombat).toHaveBeenCalledWith('encounter-1');
expect(router.navigate).not.toHaveBeenCalledWith(['/combat', expect.anything()]);
});
it('shows a combat-start error and dismisses it', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
combatStore.error.set('Du befindest dich bereits in einem Kampf.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const alerts = Array.from(element.querySelectorAll('[role="alert"]'));
expect(alerts.some((alert) => alert.textContent?.includes('Du befindest dich bereits in einem Kampf.'))).toBe(
true,
);
element.querySelector<HTMLButtonElement>('[data-hunt-combat-dismiss]')?.click();
expect(combatStore.clearError).toHaveBeenCalledOnce();
});
it('does not trigger a hunt automatically on page entry', async () => {
await setup(burnedRoad);
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
await setup(null);
expect(worldStore.load).toHaveBeenCalledOnce();
});
it('does not call load again when a location is already present', async () => {
await setup(burnedRoad);
expect(worldStore.load).not.toHaveBeenCalled();
});
it('shows a loading state and disables the triggering action', async () => {
const fixture = await setup(burnedRoad);
huntingStore.loading.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Du suchst nach Spuren...');
expect(element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.disabled).toBe(true);
});
it('displays a hunting error and retries via startHunt when there is no current hunt', async () => {
const fixture = await setup(burnedRoad);
huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'An diesem Ort gibt es keine Jagdgebiete.',
);
element.querySelector<HTMLButtonElement>('[data-hunt-retry]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
});
- Step 2: Run to confirm the new/changed cases fail
Run: npm run test --workspace=@ashen-realms/web -- hunt-page.component.spec.ts
Expected: FAIL — No provider for CombatStore! and related errors, since HuntPageComponent doesn't inject it yet.
- Step 3: Update the component
// apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { CombatStore } from '../../combat/combat.store';
import { WorldStore } from '../../world/world.store';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';
@Component({
selector: 'app-hunt-page',
imports: [EncounterCardComponent],
templateUrl: './hunt-page.component.html',
styleUrl: './hunt-page.component.scss',
})
export class HuntPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore);
protected readonly huntingStore = inject(HuntingStore);
protected readonly combatStore = inject(CombatStore);
private readonly router = inject(Router);
ngOnInit(): void {
if (this.worldStore.currentLocation() === null) {
void this.worldStore.load();
}
}
protected startHunt(): void {
void this.huntingStore.startHunt();
}
protected refreshHunt(): void {
void this.huntingStore.refreshHunt();
}
protected retry(): void {
if (this.huntingStore.currentHunt() === null) {
void this.huntingStore.startHunt();
} else {
void this.huntingStore.refreshHunt();
}
}
protected goToWorld(): void {
void this.router.navigate(['/world']);
}
protected async onAttack(encounterId: string): Promise<void> {
await this.combatStore.startCombat(encounterId);
const combat = this.combatStore.combat();
if (combat) {
void this.router.navigate(['/combat', combat.id]);
}
}
protected dismissCombatError(): void {
this.combatStore.clearError();
}
}
- Step 4: Add the combat-error banner to the template
Add this block right after the existing huntingStore.error() block, before the closing </section>, in apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html:
@if (combatStore.error(); as combatError) {
<section class="hunt-page__error" role="alert">
<p>{{ combatError }}</p>
<button type="button" data-hunt-combat-dismiss (click)="dismissCombatError()">Schließen</button>
</section>
}
The (attack)="onAttack($event)" binding on app-encounter-card in the existing template already passes the encounter id straight into the updated onAttack, so no other template changes are needed.
- Step 5: Run to confirm everything passes
Run: npm run test --workspace=@ashen-realms/web -- hunt-page.component.spec.ts
Expected: PASS (all cases).
Run: npm run test --workspace=@ashen-realms/web
Expected: PASS — full frontend suite green.
- Step 6: Commit
git add apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
git commit -m "feat(combat): start real combats from the hunt page and navigate to /combat/:combatId"
Task 15: Full verification pass
Files: none (verification only).
- Step 1: Build and test both apps from the repo root
Run: npm run build
Expected: both apps/web and apps/api build with no errors.
Run: npm run test
Expected: all workspaces pass.
- Step 2: Confirm the migration is applied and reversible on the dev database
Run: npm run db:revert then npm run db:migrate then npm run db:seed
Expected: CreateCombatSystem1788100000000 runs cleanly on top of the existing seeded data; the demo character, locations, and monsters from vertical-slice.seed.ts are intact afterward (npm run db:seed is idempotent — see its findOneBy guards).
- Step 3: Browser walkthrough — Aschenratte victory
Run: npm run dev:api and npm run dev:web. In the browser:
- Navigate to
/world, travel to Verbrannte Straße, open/hunt, click Jagd beginnen. - Click Angreifen on an Aschenratte card.
- Confirm navigation to
/combat/:combatId, with player (left) and monster (right) portraits, both HP bars, and Runde 1 visible. - Click Angriff repeatedly.
- Confirm HP decreases each round, the combat log fills with German
DAMAGElines, and the round counter advances. - 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.
- Click Zur Jagd and confirm it returns to
/huntwithout starting a new hunt automatically.
- Step 4: Browser walkthrough — Straßenräuber, refresh, and re-use/second-combat rejections
- Start a new hunt, attack a Straßenräuber encounter.
- Mid-fight (after at least one round), refresh the browser on
/combat/:combatId. - Confirm the combat reloads from the server with the same round, HP, status, and combat log — nothing resets.
- Finish the fight to WON or LOST; refresh again and confirm the finished state (Sieg/Niederlage) still displays correctly and Angriff stays hidden.
- Go back to
/hunt, start a fresh hunt, and attempt toPOSTthe attack endpoint twice for the same encounter id (e.g. via the browser devtools network tab replaying the request, orcurl) — confirm the second attempt returns409 HUNT_ENCOUNTER_ALREADY_CONSUMEDand the hunt page surfaces the mapped German error. - 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 returns409 COMBAT_ALREADY_ACTIVEand 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.