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

686 lines
54 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:**
```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)`
- `createdAt``created_at timestamptz` (`@CreateDateColumn`)
- `updatedAt``updated_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`:**
```ts
export enum HuntStatus {
ACTIVE = 'ACTIVE',
SUPERSEDED = 'SUPERSEDED',
}
```
**`hunt.entity.ts`** — table `hunts`:
- `id: uuid` PK
- `characterId``character_id: uuid` + `@ManyToOne(() => Character, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'character_id' })` (import from `../../characters/entities/character.entity`)
- `locationId``location_id: uuid` + `@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'location_id' })`
- `status``@Column({ type: 'enum', enum: HuntStatus, enumName: 'hunt_status_enum' })`
- `createdAt``created_at timestamptz` (`@CreateDateColumn`) — **no `updated_at`**, this is an append-only row like `Travel`.
**`hunt-encounter.entity.ts`** — table `hunt_encounters`:
- `id: uuid` PK
- `huntId``hunt_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.
- `monsterDefinitionId``monster_definition_id: uuid` + `@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'monster_definition_id' })` named property `monster`
- `position: integer`
- `createdAt``created_at timestamptz` (`@CreateDateColumn`)
**`random-source.ts`** — exact template, copy `apps/api/src/travel/clock.ts`'s shape:
```ts
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:
```ts
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:**
```sql
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")
)
```
```sql
CREATE UNIQUE INDEX "IDX_monster_definitions_key" ON "monster_definitions" ("key")
```
```sql
CREATE TYPE "location_monster_encounter_type_enum" AS ENUM ('NORMAL', 'RARE', 'ELITE', 'BOSS')
```
```sql
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
)
```
```sql
CREATE INDEX "IDX_location_monsters_location" ON "location_monsters" ("location_id")
```
```sql
CREATE INDEX "IDX_location_monsters_monster" ON "location_monsters" ("monster_id")
```
```sql
CREATE UNIQUE INDEX "IDX_location_monsters_location_monster" ON "location_monsters" ("location_id", "monster_id")
```
```sql
CREATE TYPE "hunt_status_enum" AS ENUM ('ACTIVE', 'SUPERSEDED')
```
```sql
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
)
```
```sql
CREATE INDEX "IDX_hunts_character" ON "hunts" ("character_id")
```
```sql
CREATE INDEX "IDX_hunts_location" ON "hunts" ("location_id")
```
```sql
CREATE UNIQUE INDEX "IDX_active_hunt_per_character" ON "hunts" ("character_id") WHERE "status" = 'ACTIVE'
```
```sql
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
)
```
```sql
CREATE INDEX "IDX_hunt_encounters_hunt" ON "hunt_encounters" ("hunt_id")
```
```sql
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):
```ts
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.ts`** — `HuntingService`, 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 `export`ed there):
```ts
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: false``HUNTING_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`:
```ts
@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.id`s, 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):
```ts
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):
```ts
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:
```ts
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`:
```ts
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):**
```ts
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.