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