Files
ashen-realms/docs/superpowers/plans/2026-08-19-playable-slice-0.2-first-hunt.md
Bastian Wagner deab7a31f1 docs: plan playable slice 0.2 first hunt
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:39:27 +02:00

54 KiB
Raw Blame History

Playable Slice 0.2 First Hunt Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task.

Goal: Extend the existing server-authoritative world/travel slice with a Hunting/Encounter boundary: a character standing at Verbrannte Straße can start a hunt, receive 23 server-generated encounters backed by persisted content, inspect them, and select one — establishing HuntEncounter.id as the sole handoff point into a future combat slice. Combat itself is explicitly out of scope.

Architecture: Same npm-workspace modular monolith. New monsters and hunting Nest modules alongside characters/world/travel. All new gameplay decisions (pool resolution, weighted roll, danger rating) happen server-side in one transaction; Angular only requests, renders, and forwards a HuntEncounter.id.

Tech Stack: Angular 22 (standalone components, signals), NestJS 11, TypeORM, PostgreSQL, REST under /api, npm Workspaces, Jest (API), Vitest (web).

Spec: No separate spec file exists for this slice — the full spec is the user's request that produced this plan (transcribed into each task below verbatim where it gives exact values). Treat this plan's task text as the binding spec; docs/Ashen_Realms_Vertical_Slice_World_Content_Design_V1.md and docs/Ashen_Realms_Balancing_Items_Loot_Design_V1.md are secondary references only if a task explicitly says to consult them.

Global Constraints

  • Do not implement: combat, damage, combat actions, loot, XP progression, silver granting, inventory, equipment, item drops, quests, merchants, area currencies, bosses (gameplay behavior), authentication, travel ambushes. Do not introduce Nx, Turborepo, NgRx, GraphQL, CQRS, event sourcing, Redis, WebSockets, microservices, or background workers.
  • synchronize: false always; every schema change is one checked-in migration.
  • The client must never send characterId, locationId, monsterId, weight, or dangerRating in any request body. The server resolves the current demo character the same way travel/world already do (DEMO_CHARACTER_ID constant from apps/api/src/demo/demo-character.constants.ts, passed straight into the service — no guard/decorator).
  • The frontend must never compute pool membership, random rolls, location validity, hunt availability, or danger rating. It requests, renders, and navigates.
  • A future combat slice must be able to start from a persisted HuntEncounter.id alone. The client can never turn an arbitrary monsterId directly into combat — this is a security/domain boundary, not a convenience.
  • Reuse existing conventions exactly (confirmed by inspection, see below) instead of inventing parallel abstractions: entity decorator style, migration raw-SQL style, seed idempotency style, domain-error-as-HttpException style, Angular signal-store style, the single shared GameApiService for all HTTP calls, and the existing SCSS design tokens in apps/web/src/styles.scss.
  • Preserve all existing behavior and tests: /world, character loading, current location, Südtor von Graufurt, Verbrannte Straße, travel start/countdown/completion, return travel, the application shell, the existing visual design. Do not touch apps/api/src/travel/** or apps/api/src/world/** beyond the one explicitly listed addition in Task 5 (exposing the encounter pool's monster names on the existing current-location response).
  • Do not implement Slice 0.3 (combat) just because the architecture makes it easy. Angreifen preserves a HuntEncounter.id and stops there.

Repository conventions confirmed by inspection (do not re-derive, just follow)

  • Entities: @Entity({ name: 'snake_case_plural' }); every column explicit via @Column({ name: 'snake_case', type: '...' }); UUID PK via @PrimaryGeneratedColumn('uuid', { name: 'id' }); timestamps via @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) / @UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' }) — omit updated_at on append-only/child tables (Travel has no updated_at; follow the same rule for Hunt/HuntEncounter/LocationMonster). Relations always get both a plain @Column({ type: 'uuid' }) FK id field and a parallel @ManyToOne(() => Target, { onDelete: '...' }) @JoinColumn({ name: '...' }) object reference. Enums live in their own *.enum.ts file as a plain TS enum, referenced via @Column({ type: 'enum', enum: X, enumName: 'x_enum' }). Unique constraints are @Index('IDX_name', ['field'], { unique: true }) at class level, never @Column({ unique: true }).
  • Migrations: apps/api/src/database/migrations/<epoch-ms>-PascalCaseDescription.ts, class PascalCaseDescription<epoch-ms>, pure raw SQL via queryRunner.query(...) (no QueryRunner schema API), down() reverses every statement in exact opposite order. The existing migration already establishes the "only one active row per character" pattern via a partial unique index (CREATE UNIQUE INDEX ... WHERE "status" = 'TRAVELLING') — reuse this exact mechanism for the one-active-hunt invariant.
  • Seed: apps/api/src/database/seeds/vertical-slice.seed.ts (idempotent, extend it — do not create a second seed file), stable content IDs live in apps/api/src/database/seeds/vertical-slice.constants.ts. Locations: findOneBy({key})update if found else insert (preserves DB-generated id across reseeds). Connections: repository.upsert(rows, [conflictCol1, conflictCol2]). The demo character is guard-inserted only, never overwritten. LocationDefinition.huntingEnabled already exists and is already seeded (south-gate=false, burned-road=true) — do not touch it.
  • No-auth character resolution: controllers import DEMO_CHARACTER_ID directly and pass it to the service (see travel.controller.ts, world.controller.ts). Do the same in the new hunting controller.
  • Domain errors: apps/api/src/travel/travel.errors.ts defines one TravelDomainError extends HttpException, constructed new TravelDomainError(code, status, message) with body { statusCode: status, code, message }. There is no custom exception filter — Nest serializes this as-is, which is exactly the {statusCode, code, message} shape required here too. One factory function per error, exported individually.
  • Angular store: apps/web/src/app/features/world/world.store.ts is an @Injectable({ providedIn: 'root' }) class (not NgRx) holding private signal()s with public .asReadonly() getters, loading/error signals where error is a pre-translated German string produced by a private toErrorMessage(error) helper that checks error instanceof HttpErrorResponse (from @angular/common/http) first, looks up error.error?.code in a local error-code→message map, and only falls back to error instanceof Error for genuine non-HTTP errors. Async methods use async/await with try/finally toggling the loading signal.
  • Angular API client: one shared apps/web/src/app/core/api/game-api.service.ts (@Injectable({providedIn:'root'})), one method per endpoint returning Observable<T> via injected HttpClient; types live in one shared apps/web/src/app/core/api/game-api.models.ts. There is no per-feature API service in this repo — add to the shared one.
  • Shared UI: no shared/ directory exists yet and no Panel/Badge/Card base components exist — every feature hand-rolls its own SCSS following the same token vocabulary in apps/web/src/styles.scss (--ar-bg, --ar-panel, --ar-panel-muted, --ar-border, --ar-border-highlight, --ar-text, --ar-text-muted, --ar-gold, --ar-blue, --ar-success, --ar-warning, --ar-danger, --ar-space-1..6, --ar-radius-sm/md, --ar-shadow-raised, --ar-motion-fast/base). apps/web/src/app/features/world/travel-panel.component.scss is the best copy-template (bordered panel, gradient background, gold eyebrow label, Georgia serif headings, button gradient with hover/disabled states). travel-panel already renders danger as text + modifier class, never color alone — copy this pattern exactly for DangerBadge.
  • Navigation: apps/web/src/app/layout/side-navigation/side-navigation.component.html — flat <button> list, the active entry has routerLink + an active class, disabled entries have disabled + a static aria-label="X ist noch nicht verfügbar". The "Jagd" button is the one to activate. apps/web/src/app/app.routes.ts currently only registers /world as a lazy child under the shell — no /hunt or /combat route exists yet. ContextPanelComponent (apps/web/src/app/layout/context-panel/) is rendered unconditionally by the shell (not per-route) and reads WorldStore directly today.
  • Tests: API — colocated *.spec.ts, e2e in apps/api/test/*.e2e-spec.ts. travel.service.spec.ts hand-rolls fake FakeRepository/FakeDataSource classes to simulate dataSource.transaction() and pessimistic locks rather than using @nestjs/testing + real TypeORM — mirror this for HuntingService's tests. apps/api/src/travel/clock.ts (export interface Clock { now(): Date }, CLOCK = Symbol('CLOCK'), systemClock) is the exact template for the new RandomSource seam. Web — colocated *.spec.ts using TestBed with provide: WorldStore, useValue: {...fake signals...}.
  • Character stats available for danger rating: Character has level, baseHp, baseAttack, currentHp (no armor). No CharacterStatsService/CombatPower exists anywhere in the codebase — confirmed by grep. Demo character seed values: level: 1, baseHp: 100, baseAttack: 6, currentHp: 100.
  • Existing monster art: art/enemies/Aschenratte.png and art/enemies/Strassenraeuber.png already exist (moved to the unserved root art/ directory in a prior session) — copy (not move) into a newly served path.
  • GET /api/world/current-location (apps/api/src/world/world.service.ts) already calls travelService.completeTravelIfDue(characterId) before reading character.currentLocationId, and already returns huntingEnabled, regionKey, minRecommendedLevel, maxRecommendedLevel, dangerLevel on CurrentLocationResponse. Task 5 extends this same response with the encounter pool's monster names — do not create a parallel endpoint or duplicate this data fetch.

Task 1: Domain entities, enums, and pure helpers (RandomSource, DangerRating)

Files:

  • Create: apps/api/src/monsters/entities/monster-definition.entity.ts
  • Create: apps/api/src/monsters/entities/location-monster.entity.ts
  • Create: apps/api/src/monsters/entities/encounter-type.enum.ts
  • Create: apps/api/src/hunting/entities/hunt.entity.ts
  • Create: apps/api/src/hunting/entities/hunt-encounter.entity.ts
  • Create: apps/api/src/hunting/hunt-status.enum.ts
  • Create: apps/api/src/hunting/random-source.ts
  • Create: apps/api/src/hunting/danger-rating.ts
  • Create: apps/api/src/hunting/danger-rating.spec.ts

No DB access, no NestJS module wiring in this task — pure entity/type/pure-function definitions only. Nothing is registered in app.module.ts yet (that happens in Task 5).

encounter-type.enum.ts:

export enum EncounterType {
  NORMAL = 'NORMAL',
  RARE = 'RARE',
  ELITE = 'ELITE',
  BOSS = 'BOSS',
}

monster-definition.entity.ts — table monster_definitions:

  • id: uuid PK (@PrimaryGeneratedColumn('uuid', { name: 'id' }))
  • key: varchar(100) — unique via @Index('IDX_monster_definitions_key', ['key'], { unique: true }) at class level
  • name: varchar(150)
  • level: integer
  • maxHp → column max_hp: integer
  • attack: integer
  • armor: integer
  • experienceReward → column experience_reward: integer
  • silverMin → column silver_min: integer
  • silverMax → column silver_max: integer
  • artworkPath → column artwork_path: varchar(255)
  • createdAtcreated_at timestamptz (@CreateDateColumn)
  • updatedAtupdated_at timestamptz (@UpdateDateColumn)

location-monster.entity.ts — table location_monsters. Fields exactly as specified (no extra timestamp columns — this table's required fields per spec are id/locationId/monsterId/weight/encounterType/enabled only):

  • id: uuid PK
  • locationId → column location_id: uuid, plus @ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'location_id' }) (import LocationDefinition from ../../world/entities/location-definition.entity)
  • monsterId → column monster_id: uuid, plus @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'monster_id' }) named property monster
  • weight: integer
  • encounterType → column encounter_type, @Column({ type: 'enum', enum: EncounterType, enumName: 'location_monster_encounter_type_enum', default: EncounterType.NORMAL })
  • enabled: boolean, default true

hunt-status.enum.ts:

export enum HuntStatus {
  ACTIVE = 'ACTIVE',
  SUPERSEDED = 'SUPERSEDED',
}

hunt.entity.ts — table hunts:

  • id: uuid PK
  • characterIdcharacter_id: uuid + @ManyToOne(() => Character, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'character_id' }) (import from ../../characters/entities/character.entity)
  • locationIdlocation_id: uuid + @ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'location_id' })
  • status@Column({ type: 'enum', enum: HuntStatus, enumName: 'hunt_status_enum' })
  • createdAtcreated_at timestamptz (@CreateDateColumn) — no updated_at, this is an append-only row like Travel.

hunt-encounter.entity.ts — table hunt_encounters:

  • id: uuid PK
  • huntIdhunt_id: uuid + @ManyToOne(() => Hunt, { onDelete: 'CASCADE' }) @JoinColumn({ name: 'hunt_id' })CASCADE, not RESTRICT: unlike the other FKs in this codebase (which reference shared reference data), HuntEncounter rows are owned/composed by their parent Hunt and have no independent lifecycle. Document this one-line rationale as a code comment since it deviates from the file's other FK, which use RESTRICT.
  • monsterDefinitionIdmonster_definition_id: uuid + @ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'monster_definition_id' }) named property monster
  • position: integer
  • createdAtcreated_at timestamptz (@CreateDateColumn)

random-source.ts — exact template, copy apps/api/src/travel/clock.ts's shape:

export interface RandomSource {
  next(): number; // uniform value in [0, 1)
}

export const RANDOM_SOURCE = Symbol('RANDOM_SOURCE');

export const systemRandomSource: RandomSource = {
  next: () => Math.random(),
};

danger-rating.ts — pure, no I/O, no NestJS decorators:

export enum DangerRating {
  WEAK = 'WEAK',
  MATCH = 'MATCH',
  STRONG = 'STRONG',
  VERY_DANGEROUS = 'VERY_DANGEROUS',
  DEADLY = 'DEADLY',
}

export interface CombatantStats {
  attack: number;
  armor: number;
  hp: number;
}

// power(entity) = attack*4 + armor*2 + floor(hp/5) — a deliberately small,
// provisional server-side stand-in for a future CombatPower system (none
// exists yet in this codebase). ratio = monsterPower / characterPower.
export function calculateDangerRating(
  character: CombatantStats,
  monster: CombatantStats,
): DangerRating {
  const power = (stats: CombatantStats) =>
    stats.attack * 4 + stats.armor * 2 + Math.floor(stats.hp / 5);
  const ratio = power(monster) / power(character);

  if (ratio < 0.65) return DangerRating.WEAK;
  if (ratio < 1.0) return DangerRating.MATCH;
  if (ratio < 1.7) return DangerRating.STRONG;
  if (ratio < 2.3) return DangerRating.VERY_DANGEROUS;
  return DangerRating.DEADLY;
}

Character stats are passed as { attack: character.baseAttack, armor: 0, hp: character.baseHp } (character has no armor field — pass 0), monster stats as { attack: monster.attack, armor: monster.armor, hp: monster.maxHp }.

danger-rating.spec.ts must assert, using the demo character's seeded stats (baseAttack: 6, baseHp: 100) against the two seeded monsters (Task 3 values):

  • Aschenratte (attack: 5, armor: 0, maxHp: 45) → DangerRating.MATCH
  • Straßenräuber (attack: 9, armor: 5, maxHp: 75) → DangerRating.STRONG
  • One boundary/edge case per remaining tier (WEAK, VERY_DANGEROUS, DEADLY) using hand-picked stat inputs you compute against the exact formula above — show the arithmetic in a comment so the reviewer can verify by hand.

Report file: <workspace>/task-1-report.md.


Task 2: Migration for the hunting schema

Depends on: Task 1 entity field/column names (must match exactly).

Files:

  • Create: apps/api/src/database/migrations/1787500000000-CreateHuntingSystem.ts
  • Create: apps/api/src/database/migrations/hunting-system.migration.spec.ts

Mirror apps/api/src/database/migrations/1787072400000-CreateVisibleVerticalSlice.ts exactly: raw SQL via queryRunner.query(...), class name CreateHuntingSystem1787500000000. Write the migration to run against a real PostgreSQL instance — do not use TypeORM's schema builder API.

up(), in this exact order:

CREATE TABLE "monster_definitions" (
  "id" uuid NOT NULL DEFAULT gen_random_uuid(),
  "key" character varying(100) NOT NULL,
  "name" character varying(150) NOT NULL,
  "level" integer NOT NULL,
  "max_hp" integer NOT NULL,
  "attack" integer NOT NULL,
  "armor" integer NOT NULL,
  "experience_reward" integer NOT NULL,
  "silver_min" integer NOT NULL,
  "silver_max" integer NOT NULL,
  "artwork_path" character varying(255) NOT NULL,
  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
  "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
  CONSTRAINT "PK_monster_definitions" PRIMARY KEY ("id")
)
CREATE UNIQUE INDEX "IDX_monster_definitions_key" ON "monster_definitions" ("key")
CREATE TYPE "location_monster_encounter_type_enum" AS ENUM ('NORMAL', 'RARE', 'ELITE', 'BOSS')
CREATE TABLE "location_monsters" (
  "id" uuid NOT NULL DEFAULT gen_random_uuid(),
  "location_id" uuid NOT NULL,
  "monster_id" uuid NOT NULL,
  "weight" integer NOT NULL,
  "encounter_type" "location_monster_encounter_type_enum" NOT NULL DEFAULT 'NORMAL',
  "enabled" boolean NOT NULL DEFAULT true,
  CONSTRAINT "PK_location_monsters" PRIMARY KEY ("id"),
  CONSTRAINT "FK_location_monsters_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
  CONSTRAINT "FK_location_monsters_monster" FOREIGN KEY ("monster_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)
CREATE INDEX "IDX_location_monsters_location" ON "location_monsters" ("location_id")
CREATE INDEX "IDX_location_monsters_monster" ON "location_monsters" ("monster_id")
CREATE UNIQUE INDEX "IDX_location_monsters_location_monster" ON "location_monsters" ("location_id", "monster_id")
CREATE TYPE "hunt_status_enum" AS ENUM ('ACTIVE', 'SUPERSEDED')
CREATE TABLE "hunts" (
  "id" uuid NOT NULL DEFAULT gen_random_uuid(),
  "character_id" uuid NOT NULL,
  "location_id" uuid NOT NULL,
  "status" "hunt_status_enum" NOT NULL,
  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
  CONSTRAINT "PK_hunts" PRIMARY KEY ("id"),
  CONSTRAINT "FK_hunts_character" FOREIGN KEY ("character_id") REFERENCES "characters"("id") ON DELETE RESTRICT ON UPDATE NO ACTION,
  CONSTRAINT "FK_hunts_location" FOREIGN KEY ("location_id") REFERENCES "location_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)
CREATE INDEX "IDX_hunts_character" ON "hunts" ("character_id")
CREATE INDEX "IDX_hunts_location" ON "hunts" ("location_id")
CREATE UNIQUE INDEX "IDX_active_hunt_per_character" ON "hunts" ("character_id") WHERE "status" = 'ACTIVE'
CREATE TABLE "hunt_encounters" (
  "id" uuid NOT NULL DEFAULT gen_random_uuid(),
  "hunt_id" uuid NOT NULL,
  "monster_definition_id" uuid NOT NULL,
  "position" integer NOT NULL,
  "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(),
  CONSTRAINT "PK_hunt_encounters" PRIMARY KEY ("id"),
  CONSTRAINT "FK_hunt_encounters_hunt" FOREIGN KEY ("hunt_id") REFERENCES "hunts"("id") ON DELETE CASCADE ON UPDATE NO ACTION,
  CONSTRAINT "FK_hunt_encounters_monster_definition" FOREIGN KEY ("monster_definition_id") REFERENCES "monster_definitions"("id") ON DELETE RESTRICT ON UPDATE NO ACTION
)
CREATE INDEX "IDX_hunt_encounters_hunt" ON "hunt_encounters" ("hunt_id")
CREATE INDEX "IDX_hunt_encounters_monster_definition" ON "hunt_encounters" ("monster_definition_id")

down(): every statement above reversed, in exact opposite order (drop indexes before their table, drop tables before the enum types they reference, hunt_encounters before hunts before location_monsters before monster_definitions).

hunting-system.migration.spec.ts: mirror visible-vertical-slice.migration.spec.ts's approach exactly — use getMetadataArgsStorage() from typeorm (with import 'reflect-metadata') to assert against the Task 1 entities, not the migration file itself: the unique index on MonsterDefinition.key, the onDelete: 'CASCADE' on HuntEncounter's relation to Hunt (this is the one deliberate deviation from the codebase's usual RESTRICT — assert it explicitly so a future refactor can't silently change it), and onDelete: 'RESTRICT' on the other three new relations.

Verification (do this yourself before reporting DONE): run npm run db:migrate against a real disposable PostgreSQL instance (spin up a temporary Docker container the same way prior sessions did: docker run --rm -d --name ashen-realms-postgres-taskN -e POSTGRES_USER=ashen -e POSTGRES_PASSWORD=ashen -e POSTGRES_DB=ashen_realms -p 5433:5432 postgres:16, point .env's DATABASE_URL at it, wait for it to accept connections, run npm run db:migrate from repo root, confirm no errors, then npm run db:revert to confirm down() is also correct, then re-run db:migrate to leave the schema in place for Task 3). Stop the container only if you started one that conflicts with an already-running one — check docker ps first. Report the exact commands and output in the report file.

Report file: <workspace>/task-2-report.md.


Task 3: Seed data — Aschenratte, Straßenräuber, and their assignment to Verbrannte Straße

Depends on: Task 1 (entities), Task 2 (migration applied).

Files:

  • Modify: apps/api/src/database/seeds/vertical-slice.seed.ts
  • Modify: apps/api/src/database/seeds/vertical-slice.constants.ts
  • Modify: apps/api/src/database/seeds/vertical-slice.seed.spec.ts
  • Create: apps/web/public/images/monsters/ash-rat.png (copy of art/enemies/Aschenratte.png — do not move the original, do not delete anything from art/)
  • Create: apps/web/public/images/monsters/road-bandit.png (copy of art/enemies/Strassenraeuber.png)

Stable content IDs: add two new UUID constants to vertical-slice.constants.ts alongside the existing SOUTH_GATE_ID/BURNED_ROAD_ID, e.g. ASH_RAT_MONSTER_ID, ROAD_BANDIT_MONSTER_ID (generate real UUIDs, do not reuse existing ones).

Seed exactly these two MonsterDefinition rows, using the findOneBy({key}) → update else insert idempotency pattern already used for locations (preserves the DB-generated/constant id across reseeds):

field Aschenratte Straßenräuber
key ash-rat road-bandit
name Aschenratte Straßenräuber
level 1 2
maxHp 45 75
attack 5 9
armor 0 5
experienceReward 8 16
silverMin 4 9
silverMax 7 15
artworkPath /images/monsters/ash-rat.png /images/monsters/road-bandit.png

(These served-path values deliberately differ from the task's illustrative /assets/monsters/*.webp example — this repo serves images from /images/..., and the source art is .png, not .webp; do not convert the format or invent an /assets path that nothing else in this repo uses.)

Seed exactly these two LocationMonster rows (assign both to burned-road, i.e. BURNED_ROAD_ID), using repository.upsert(rows, ['locationId', 'monsterId']) (mirrors the connections upsert pattern, and is now possible because Task 2 added the unique index on (location_id, monster_id)):

field Aschenratte mapping Straßenräuber mapping
locationId BURNED_ROAD_ID BURNED_ROAD_ID
monsterId ASH_RAT_MONSTER_ID ROAD_BANDIT_MONSTER_ID
weight 70 30
encounterType NORMAL NORMAL
enabled true true

Idempotency requirement (non-negotiable, test it): running the full seed twice in a row must not duplicate monsters, must not duplicate location-monster mappings, must not reset the demo character, must not delete travel history, must not delete hunt history (none exists yet, but the seed must not include any blanket DELETE/TRUNCATE of hunt-related tables).

vertical-slice.seed.spec.ts additions: extend the existing hand-rolled InMemoryRepository harness (do not switch to a different test approach) with two new fake repositories for MonsterDefinition and LocationMonster, and assert: first run inserts both monsters and both mappings; second run does not insert again (no duplicate rows) and does not alter the demo character or any existing location/connection data.

Report file: <workspace>/task-3-report.md.


Task 4: HuntingService — core domain logic

Depends on: Task 1 (entities, RandomSource, calculateDangerRating), Task 2/3 (schema + seed exist for manual/e2e verification, though this task's automated tests use fakes and do not require a live DB).

Files:

  • Create: apps/api/src/hunting/hunting.errors.ts
  • Create: apps/api/src/hunting/hunting.service.ts
  • Create: apps/api/src/hunting/hunting.service.spec.ts

hunting.errors.ts — mirror apps/api/src/travel/travel.errors.ts's pattern exactly (one HuntingDomainError extends HttpException class, one factory function per error, each exported individually):

import { HttpException } from '@nestjs/common';

export class HuntingDomainError extends HttpException {
  constructor(code: string, status: number, message: string) {
    super({ statusCode: status, code, message }, status);
  }
}

export function huntingNotAvailable(): HuntingDomainError {
  return new HuntingDomainError(
    'HUNTING_NOT_AVAILABLE',
    400,
    'Hunting is not available at the current location.',
  );
}

export function characterTravelling(): HuntingDomainError {
  return new HuntingDomainError(
    'CHARACTER_TRAVELLING',
    409,
    'The character cannot hunt while travelling.',
  );
}

export function noHuntEncountersAvailable(): HuntingDomainError {
  return new HuntingDomainError(
    'NO_HUNT_ENCOUNTERS_AVAILABLE',
    409,
    'No encounters are currently available at this location.',
  );
}

For the character-not-found case, import and reuse characterNotFound from ../travel/travel.errors.ts — do not duplicate that error, it is identical in both flows.

hunting.service.tsHuntingService, constructor injects DataSource (like TravelService), the injected TravelService (import from ../travel/travel.service, add TravelModule to HuntingModule's imports in Task 5 — this task just writes the code and its unit tests against a fake), and @Inject(RANDOM_SOURCE) randomSource: RandomSource.

Primary operation: startHunt(characterId: string): Promise<HuntResultDto>. HuntResultDto/HuntEncounterDto/LocationSummary interfaces live in this file (exported), matching this exact shape (reuse the existing LocationSummary interface by importing it from ../travel/travel.service instead of redeclaring it — it is already exported there):

export interface MonsterSummary {
  key: string;
  name: string;
  level: number;
  artworkPath: string;
}

export interface HuntEncounterDto {
  id: string;
  monster: MonsterSummary;
  dangerRating: DangerRating;
}

export interface HuntResultDto {
  id: string;
  location: LocationSummary;
  encounters: HuntEncounterDto[];
}

Flow, in this exact order:

  1. Call await this.travelService.completeTravelIfDue(characterId). This both resolves any travel that has finished but not yet been observed, and throws characterNotFound() internally if the character doesn't exist (reuse, do not duplicate that check). If the result's status === TravelStatus.TRAVELLING, throw characterTravelling() — the character is genuinely still travelling.
  2. Load the character fresh (dataSource.getRepository(Character).findOne({ where: { id: characterId }, relations: { currentLocation: true } })) to get the now-authoritative currentLocationId and the loaded currentLocation (needed for huntingEnabled and for building the response's location field).
  3. If !character.currentLocation.huntingEnabled, throw huntingNotAvailable().
  4. Load the enabled LocationMonster pool for this location: dataSource.getRepository(LocationMonster).find({ where: { locationId: character.currentLocationId, enabled: true }, relations: { monster: true } }).
  5. If the pool is empty, throw noHuntEncountersAvailable().
  6. Open dataSource.transaction(async (manager) => { ... }) for everything from here on, so a hunt is never left partially created: a. Lock the character row (manager.getRepository(Character).findOne({ where: { id: characterId }, lock: { mode: 'pessimistic_write' } })) — mirrors TravelService.lockCharacter, serializes concurrent startHunt calls for the same character so the one-active-hunt invariant holds even under a race, in addition to the partial unique index. b. Supersede any existing ACTIVE hunt for this character: manager.getRepository(Hunt).update({ characterId, status: HuntStatus.ACTIVE }, { status: HuntStatus.SUPERSEDED }). c. Create and save the new Hunt row (status: HuntStatus.ACTIVE, locationId: character.currentLocationId). d. Generate exactly 3 encounters (the task explicitly permits always generating 3 "if the design is cleaner" — do that, it removes an unnecessary branch). For each of the 3 independent slots: total = sum(pool weights); roll = randomSource.next() * total; walk the pool array in the order it was returned, accumulating weight, and pick the first entry where the running cumulative weight exceeds roll (i.e. roll < cumulative). This must be a pure, easily-unit-testable helper (either a private method or a small standalone function in the same file) so tests can assert exact outcomes for hand-picked next() return values. e. For each picked monster, compute dangerRating = calculateDangerRating({attack: character.baseAttack, armor: 0, hp: character.baseHp}, {attack: monster.attack, armor: monster.armor, hp: monster.maxHp}). f. Create and save one HuntEncounter row per slot (huntId, monsterDefinitionId, position = 0/1/2). g. Return the assembled HuntResultDto.

hunting.service.spec.ts — hand-roll fake repositories/DataSource/TravelService exactly like travel.service.spec.ts does (do not introduce @nestjs/testing). A fake RandomSource with a canned sequence of next() return values is the deterministic-random seam — inject it directly, no mocking library needed. At minimum, cover:

  • Hunt unavailable: character's current location has huntingEnabled: falseHUNTING_NOT_AVAILABLE.
  • Valid hunt: character at a huntingEnabled: true location with a non-empty pool → a Hunt is created, exactly 3 HuntEncounter rows are created and saved, and the returned DTO's encounters array has length 3, each with its own id.
  • Travelling character: completeTravelIfDue fake returns {status: TravelStatus.TRAVELLING, ...}CHARACTER_TRAVELLING.
  • Empty encounter pool: huntingEnabled: true but the LocationMonster pool query returns []NO_HUNT_ENCOUNTERS_AVAILABLE.
  • Deterministic weighted selection: with a two-entry pool [{monster: A, weight: 70}, {monster: B, weight: 30}] (matching the real Aschenratte/Straßenräuber weights) and a fake RandomSource returning 0.1 then 0.9 then 0.1, assert the exact monsters picked for each of the 3 slots (0.1*100=10 < 70 → A; 0.9*100=90 ≥ 70 → B; 0.1*100=10 < 70 → A). No probabilistic/statistical assertions anywhere.
  • Hunt replacement: call startHunt twice for the same character; assert the first Hunt row is now SUPERSEDED and the second is ACTIVE.
  • Encounter identity: assert each HuntEncounter has its own generated id and its monsterDefinitionId matches the monster that was actually rolled for that slot (not just "some id").

Report file: <workspace>/task-4-report.md.


Task 5: HuntingController, HuntingModule, and the current-location monster-pool enrichment

Depends on: Task 4 (HuntingService).

Files:

  • Create: apps/api/src/hunting/hunting.controller.ts
  • Create: apps/api/src/hunting/hunting.controller.spec.ts
  • Create: apps/api/src/hunting/hunting.module.ts
  • Modify: apps/api/src/app.module.ts (register HuntingModule, MonsterDefinition/LocationMonster/Hunt/HuntEncounter entities wherever the existing entity list is assembled)
  • Modify: apps/api/src/world/world.service.ts
  • Modify: apps/api/src/world/world.module.ts
  • Modify: apps/api/src/world/world.service.spec.ts (or the correct existing test file covering getCurrentLocation)
  • Modify: apps/api/test/visible-slice.e2e-spec.ts (extend the existing DB-gated e2e block — do not create a second e2e file)

HuntingController — thin pass-through like TravelController:

@Controller('hunts')
export class HuntingController {
  constructor(private readonly huntingService: HuntingService) {}

  @Post()
  startHunt(): Promise<HuntResultDto> {
    return this.huntingService.startHunt(DEMO_CHARACTER_ID);
  }
}

No request body, no DTO validation needed — the client sends nothing. Route resolves to POST /api/hunts (global /api prefix is already configured elsewhere; do not re-prefix here).

HuntingModule: imports TypeOrmModule.forFeature([MonsterDefinition, LocationMonster, Hunt, HuntEncounter, Character]), TravelModule (export TravelService from TravelModule if it isn't already exported — check first), providers HuntingService and { provide: RANDOM_SOURCE, useValue: systemRandomSource }, controller HuntingController. Export nothing unless a later task needs it.

hunting.controller.spec.ts: a light test confirming the controller delegates to huntingService.startHunt(DEMO_CHARACTER_ID) and returns its result — mirror travel.controller.spec.ts's style and depth, nothing more.

Current-location enrichment (world.service.ts): add a possibleMonsters: string[] field to CurrentLocationResponse, populated only when huntingEnabled is true, from the enabled LocationMonster pool for the character's current location, .monster.name, ordered by weight descending (highest-weight first — this is a display-order choice, not exposing the numeric weight itself; do not include weight, percentage, or any numeric probability in the response). When huntingEnabled is false, possibleMonsters is []. Inject @InjectRepository(LocationMonster) private readonly locationMonsters: Repository<LocationMonster> into WorldService, add LocationMonster (and MonsterDefinition, needed for the relation) to WorldModule's TypeOrmModule.forFeature([...]). This is the only change to world/travel this plan makes — do not touch anything else in those modules.

e2e test extension: inside the existing describe.skip-unless-DATABASE_URL block in apps/api/test/visible-slice.e2e-spec.ts, add one new scenario after travel completes: POST /api/hunts at burned-road returns 201 with a body matching the HuntResultDto shape (id, location.key === 'burned-road', encounters array of length 3, each with id, monster.key['ash-rat','road-bandit'], dangerRating ∈ the five enum values), and a second POST /api/hunts call supersedes the first (this can be asserted purely through the response, e.g. two different hunt.ids, without needing direct DB access from the test).

Report file: <workspace>/task-5-report.md.


Task 6: Frontend API client and models

Depends on: Task 5 (response shape is now final).

Files:

  • Modify: apps/web/src/app/core/api/game-api.service.ts
  • Modify: apps/web/src/app/core/api/game-api.models.ts

Add to game-api.models.ts (match the backend DTOs field-for-field, including the DangerRating union/enum and the German-facing label mapping data structure if one doesn't already exist — do not duplicate a label map here if Task 8's DangerBadge component will own it):

export type DangerRating = 'WEAK' | 'MATCH' | 'STRONG' | 'VERY_DANGEROUS' | 'DEADLY';

export interface MonsterSummary {
  key: string;
  name: string;
  level: number;
  artworkPath: string;
}

export interface HuntEncounter {
  id: string;
  monster: MonsterSummary;
  dangerRating: DangerRating;
}

export interface HuntResult {
  id: string;
  location: LocationSummary; // reuse the existing LocationSummary type already declared here for travel
  encounters: HuntEncounter[];
}

Extend the existing CurrentLocationResponse (or equivalently-named interface already in this file for /api/world/current-location) with possibleMonsters: string[].

Add to game-api.service.ts, following the exact existing method style (one Observable<T> method per endpoint, relative URL, HttpClient injected):

startHunt(): Observable<HuntResult> {
  return this.http.post<HuntResult>('/api/hunts', {});
}

Report file: <workspace>/task-6-report.md.


Task 7: HuntingStore

Depends on: Task 6.

Files:

  • Create: apps/web/src/app/features/hunting/hunting.store.ts
  • Create: apps/web/src/app/features/hunting/hunting.store.spec.ts

Mirror apps/web/src/app/features/world/world.store.ts's exact architecture: @Injectable({ providedIn: 'root' }) class, private signal()s with public .asReadonly() getters, async/await + try/finally for loading, a private toErrorMessage(error) helper checking error instanceof HttpErrorResponse first against a local map, falling back to error instanceof Error, generic fallback otherwise.

State shape:

currentHunt: signal<HuntResult | null>(null)
selectedEncounterId: signal<string | null>(null)
loading: signal<boolean>(false)
error: signal<string | null>(null)

(currentLocation/encounters are not duplicated here — encounters is currentHunt()?.encounters ?? [], exposed as a computed(); currentLocation for hunting-availability purposes is read from the existing WorldStore, not re-fetched — inject WorldStore only if a method genuinely needs it, otherwise the page component reads WorldStore directly for location/availability and HuntingStore only for hunt/encounter state.)

Methods:

  • async startHunt(): Promise<void> — calls gameApi.startHunt() via firstValueFrom, sets currentHunt, clears selectedEncounterId, standard loading/error handling.
  • refreshHunt() — same as startHunt() (the "Neu suchen" action is literally another POST /api/hunts call per spec; do not add a separate method that behaves differently — a thin alias or direct reuse of startHunt is correct here, not a design smell).
  • selectEncounter(encounterId: string): void — sets selectedEncounterId (pure, synchronous, no network call — selection is a local UI concern until "Angreifen" navigates away).

Error-code map, mirroring TRAVEL_ERROR_MESSAGES:

const HUNT_ERROR_MESSAGES: Record<string, string> = {
  CHARACTER_NOT_FOUND: '<reuse the exact existing German text from TRAVEL_ERROR_MESSAGES for this code>',
  CHARACTER_TRAVELLING: 'Du kannst nicht jagen, während du unterwegs bist.',
  HUNTING_NOT_AVAILABLE: 'An diesem Ort gibt es keine Jagdgebiete.',
  NO_HUNT_ENCOUNTERS_AVAILABLE: 'Aktuell sind hier keine Gegner zu finden.',
};

hunting.store.spec.ts: mirror world.store.spec.ts's test style. Cover: successful startHunt() populates currentHunt/encounters; each of the four error codes maps to its German message via a simulated HttpErrorResponse; selectEncounter sets selectedEncounterId without a network call; refreshHunt() issues another API call and replaces currentHunt.

Report file: <workspace>/task-7-report.md.


Task 8: DangerBadge shared component

Depends on: nothing (pure presentational component, can run in parallel with backend tasks in principle, but this skill never dispatches two implementers concurrently — it runs after Task 7 in sequence regardless).

Files:

  • Create: apps/web/src/app/shared/danger-badge/danger-badge.component.ts
  • Create: apps/web/src/app/shared/danger-badge/danger-badge.component.html
  • Create: apps/web/src/app/shared/danger-badge/danger-badge.component.scss
  • Create: apps/web/src/app/shared/danger-badge/danger-badge.component.spec.ts

This is the first component in a new apps/web/src/app/shared/ directory — there is no existing shared component to import from, only the visual/token pattern from travel-panel.component.scss to follow (see Global Constraints / conventions section above). Standalone Angular component, selector: 'app-danger-badge', one @Input({ required: true }) rating!: DangerRating (import the type from ../../core/api/game-api.models).

German label map (exact, this is the one canonical place these labels live — Task 6 deliberately did not duplicate this map):

const DANGER_LABELS: Record<DangerRating, string> = {
  WEAK: 'Schwach',
  MATCH: 'Passend',
  STRONG: 'Stark',
  VERY_DANGEROUS: 'Sehr gefährlich',
  DEADLY: 'Tödlich',
};

Render the label as visible text, always — never rely on color alone (mirror travel-panel's existing dd [class.travel-panel__danger--low] text-plus-modifier-class pattern exactly). Use a [class.danger-badge--weak]="rating === 'WEAK'" style per-rating modifier class in the template, with SCSS colors drawn from the existing tokens (--ar-success for WEAK/MATCH-ish safe end, --ar-warning for STRONG, --ar-danger for VERY_DANGEROUS/DEADLY — pick a reasonable 5-step mapping across the existing 3 semantic color tokens, do not invent new hardcoded hex colors). No prefers-reduced-motion-relevant animation on this component (it's static text).

Test: for each of the 5 DangerRating values, assert the rendered text matches the German label and the corresponding modifier class is present.

Report file: <workspace>/task-8-report.md.


Task 9: EncounterCard component

Depends on: Task 8 (DangerBadge), Task 6 (models).

Files:

  • Create: apps/web/src/app/features/hunting/encounter-card/encounter-card.component.ts
  • Create: apps/web/src/app/features/hunting/encounter-card/encounter-card.component.html
  • Create: apps/web/src/app/features/hunting/encounter-card/encounter-card.component.scss
  • Create: apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts

Standalone component, selector: 'app-encounter-card', @Input({ required: true }) encounter!: HuntEncounter, @Output() attack = new EventEmitter<string>() (emits encounter.id). Template shows, in this order: monster artwork (<img [src]="encounter.monster.artworkPath" [alt]="encounter.monster.name"> — real alt text, not empty/decorative, since the artwork is the primary identifying content here, not decoration), monster name, level (Stufe {{ encounter.monster.level }} or similar established German phrasing — check travel-panel/world-page for how level is already phrased elsewhere and match it), <app-danger-badge [rating]="encounter.dangerRating" />, and a real <button> labeled Angreifen that calls attack.emit(encounter.id) on click.

Visual requirements (dark fantasy, large artwork-forward card — reuse travel-panel.component.scss's exact token/gradient/border vocabulary, do not invent a new palette): explicitly avoid a plain data-table look, admin-style list, generic Material card, Bootstrap card, or white dashboard tile — this must look like it was built by the same team as travel-panel. Real focus-visible state on the button (no outline: none without a replacement), respects prefers-reduced-motion if any transition/hover animation is added (wrap non-essential motion in @media (prefers-reduced-motion: no-preference)).

Test: given a sample HuntEncounter, assert the name, level, danger label (via the rendered DangerBadge), and artwork src/alt are all present, and clicking Angreifen emits the encounter's id (not monster.key, not any other identifier — this is the exact assertion that proves the security boundary from the frontend side).

Report file: <workspace>/task-9-report.md.


Task 10: HuntPageComponent, routing, navigation, and the context-panel addition

Depends on: Task 7 (HuntingStore), Task 9 (EncounterCard).

Files:

  • Create: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
  • Create: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html
  • Create: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.scss
  • Create: apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
  • Create: apps/web/src/app/features/combat/combat-placeholder-page.component.ts (+ .html, .scss if needed — this can be a small inline-template standalone component if the placeholder content is short enough that a separate template file adds no value; use your judgment, matching the simplicity of the content)
  • Modify: apps/web/src/app/app.routes.ts
  • Modify: apps/web/src/app/layout/side-navigation/side-navigation.component.html
  • Modify: apps/web/src/app/layout/side-navigation/side-navigation.component.spec.ts (if one exists covering the disabled/enabled nav state)
  • Modify: apps/web/src/app/layout/context-panel/context-panel.component.ts
  • Modify: apps/web/src/app/layout/context-panel/context-panel.component.html
  • Modify: apps/web/src/app/layout/context-panel/context-panel.component.spec.ts

Routing (app.routes.ts): add two new lazy children under the same shell parent that already hosts /world, following its exact loadComponent pattern:

  • path: 'hunt'HuntPageComponent
  • path: 'combat/new'CombatPlaceholderPageComponent

Navigation (side-navigation.component.html): enable the existing "Jagd" button — remove its disabled attribute, give it routerLink="/hunt", change its aria-label from "noch nicht verfügbar" to just "Jagd", and add routerLinkActive for the active-state class to both the "Karte"/"Welt" and "Jagd" buttons consistently (today only one has active-state wiring at all — fix this inconsistency for both entries in the same small edit, since leaving "Jagd" without active-state styling while adding it a working route would be visibly broken, not a separate concern). Leave every other currently-disabled nav entry exactly as-is.

CombatPlaceholderPageComponent: reads encounterId from the query param (combat/new?encounterId=<uuid>) via ActivatedRoute, and renders an intentional, in-world-styled placeholder — not a bare "TODO" — stating that combat is the next implementation step, while displaying the preserved encounterId (so it's visibly proven the id survived the handoff — e.g. a small muted line with the id, useful for manual verification, not a debug artifact left behind carelessly; word it in-world if possible, e.g. framing it as "Vorbereitung auf den Kampf..."). Do not: create fake combat state, subtract HP, roll damage, kill the monster, grant XP/silver/loot. This component owns no store, no signals beyond reading the route param — it is intentionally inert.

HuntPageComponent — standalone, injects HuntingStore, WorldStore (read-only, for currentLocation()/huntingEnabled — do not duplicate location-fetching logic here), Router. Renders exactly four states, driven by computed() signals over worldStore.currentLocation() and huntingStore.currentHunt()/loading()/error()do not auto-trigger a hunt on page entry:

  • State A hunting unavailable (currentLocation()?.huntingEnabled === false): show the exact German copy —

    Keine Jagd verfügbar

    Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres Gebiet, um nach Gegnern zu suchen.

    (If the actual current location isn't Südtor specifically, phrase the second sentence generically rather than hardcoding "Südtor" when some other non-hunting location is current — this repo currently only has two locations so the literal copy above is fine as the default/only case, but do not hardcode if (location.key === 'south-gate') logic; branch only on huntingEnabled.) Action: a real button/link labeled Zur Karte navigating to /world. No enabled "Jagd beginnen" control is rendered in this state at all (not merely disabled — absent).

  • State B ready to hunt (huntingEnabled === true and currentHunt() === null and not loading()): show the location's name/short description (reuse fields already on WorldStore.currentLocation() — do not fetch or hardcode new copy), a short hunting description sentence, and a Jagd beginnen button that calls huntingStore.startHunt().

  • State C loading (loading() is true): show Du suchst nach Spuren..., keep the shell visible, disable any action that could fire a duplicate request (the button that triggered the load, at minimum).

  • State D encounters found (currentHunt() !== null and not loading()): render currentHunt()!.encounters as <app-encounter-card> instances in a row-oriented layout that comfortably fits 23 large cards across on desktop widths (flex/grid, matching world-page's existing layout approach rather than inventing a new one), plus Neu suchen (calls huntingStore.refreshHunt()) and Zur Karte.

Angreifen handling: EncounterCard's (attack) output calls a page method onAttack(encounterId: string) which calls huntingStore.selectEncounter(encounterId) then router.navigate(['/combat/new'], { queryParams: { encounterId } }). No local combat state is created.

Error state: if huntingStore.error() is set, show it (reuse the same inline error-display pattern world-page already uses for worldStore.error() — do not invent a new one).

Context panel addition (context-panel.component.ts/.html): add a "Mögliche Begegnungen" section, visible whenever worldStore.currentLocation()?.huntingEnabled is true, listing worldStore.currentLocation()!.possibleMonsters as plain text names (from Task 5/6's possibleMonsters: string[]). Do not display any percentage, weight, or numeric probability — the field is already pre-stripped of that by the backend, so this is purely a template addition, no new store coupling. This makes the panel correct on both /world and /hunt (same store, same data) without making ContextPanelComponent route-aware — do not add Router/route-branching logic to this component for this purpose.

Tests (hunt-page.component.spec.ts), using TestBed with provide: HuntingStore, useValue: {...fake signals...} and provide: WorldStore, useValue: {...fake signals...} exactly like existing world component specs:

  • Südtor state: hunting-unavailable message renders, no Jagd beginnen button exists in the DOM at all, Zur Karte is present and navigates to /world.
  • Hunt start: clicking Jagd beginnen calls the fake huntingStore.startHunt.
  • Render encounters: given a fake currentHunt() with 3 encounters (Aschenratte, Straßenräuber, Aschenratte — duplicates allowed and expected), assert 3 app-encounter-card elements render with correct names/levels/danger text/artwork src.
  • Refresh: clicking Neu suchen calls the fake huntingStore.refreshHunt.
  • Select encounter: clicking an EncounterCard's Angreifen triggers navigation to /combat/new with the correct encounterId query param (spy on Router.navigate) — assert it is the encounter's id, not monster.key or any other field.

Report file: <workspace>/task-10-report.md.


Manual/browser verification (controller performs this after Task 10, not delegated)

After all 10 tasks are complete and reviewed, before the final whole-branch review, walk through in a real browser against a real seeded PostgreSQL instance:

  1. Südtor → Jagd → unavailable state, correct copy, no hunt button, Zur Karte works.
  2. Südtor → Karte → travel to Verbrannte Straße → countdown completes.
  3. Verbrannte Straße → Jagd → ready-to-hunt state → Jagd beginnen → loading copy briefly visible → 23 large encounter cards with real artwork appear.
  4. Neu suchen → new (possibly different) encounters appear, previous hunt is now SUPERSEDED (spot-check via DB or by re-running the e2e test).
  5. Angreifen on any card → navigates to /combat/new?encounterId=<uuid> → placeholder renders, no combat state exists, the encounterId in the URL matches the card that was clicked.
  6. Confirm nothing about /world, travel, or the shell regressed.

Completion criteria (do not consider this plan done until all are true)

  • Existing world/travel functionality still works (all pre-existing tests still pass, plus the manual walkthrough above).
  • Aschenratte and Straßenräuber exist as persisted MonsterDefinition content.
  • Both are assigned to burned-road through persisted LocationMonster rows.
  • Südtor rejects hunting (HUNTING_NOT_AVAILABLE); Verbrannte Straße allows it.
  • Active travel rejects hunting (CHARACTER_TRAVELLING).
  • Encounter generation is entirely server-side; weighted randomness is deterministic in tests (no flaky/statistical tests anywhere).
  • Hunt and HuntEncounter are persisted; refreshing supersedes the old hunt.
  • The Angular hunt page displays 23 large encounter cards with real artwork and backend-computed danger ratings.
  • Jagd navigation, Neu suchen, and Angreifen all work end-to-end; Angreifen preserves HuntEncounter.id specifically, never an arbitrary monsterId.
  • No combat implementation has leaked into this slice.
  • npm test --workspace=@ashen-realms/api, npm test --workspace=@ashen-realms/web, npm run build:api, npm run build:web all pass.