54 KiB
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 2–3 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: falsealways; every schema change is one checked-in migration.- The client must never send
characterId,locationId,monsterId,weight, ordangerRatingin any request body. The server resolves the current demo character the same waytravel/worldalready do (DEMO_CHARACTER_IDconstant fromapps/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.idalone. The client can never turn an arbitrarymonsterIddirectly 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-
HttpExceptionstyle, Angular signal-store style, the single sharedGameApiServicefor all HTTP calls, and the existing SCSS design tokens inapps/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 touchapps/api/src/travel/**orapps/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.
Angreifenpreserves aHuntEncounter.idand 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' })— omitupdated_aton append-only/child tables (Travelhas noupdated_at; follow the same rule forHunt/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.tsfile as a plain TSenum, 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, classPascalCaseDescription<epoch-ms>, pure raw SQL viaqueryRunner.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 inapps/api/src/database/seeds/vertical-slice.constants.ts. Locations:findOneBy({key})→updateif found elseinsert(preserves DB-generated id across reseeds). Connections:repository.upsert(rows, [conflictCol1, conflictCol2]). The demo character is guard-inserted only, never overwritten.LocationDefinition.huntingEnabledalready exists and is already seeded (south-gate=false,burned-road=true) — do not touch it. - No-auth character resolution: controllers import
DEMO_CHARACTER_IDdirectly and pass it to the service (seetravel.controller.ts,world.controller.ts). Do the same in the new hunting controller. - Domain errors:
apps/api/src/travel/travel.errors.tsdefines oneTravelDomainError extends HttpException, constructednew 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.tsis an@Injectable({ providedIn: 'root' })class (not NgRx) holding privatesignal()s with public.asReadonly()getters,loading/errorsignals whereerroris a pre-translated German string produced by a privatetoErrorMessage(error)helper that checkserror instanceof HttpErrorResponse(from@angular/common/http) first, looks uperror.error?.codein a local error-code→message map, and only falls back toerror instanceof Errorfor genuine non-HTTP errors. Async methods useasync/awaitwithtry/finallytoggling 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 returningObservable<T>via injectedHttpClient; types live in one sharedapps/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 inapps/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.scssis the best copy-template (bordered panel, gradient background, gold eyebrow label, Georgia serif headings, button gradient with hover/disabled states).travel-panelalready renders danger as text + modifier class, never color alone — copy this pattern exactly forDangerBadge. - Navigation:
apps/web/src/app/layout/side-navigation/side-navigation.component.html— flat<button>list, the active entry hasrouterLink+ an active class, disabled entries havedisabled+ a staticaria-label="X ist noch nicht verfügbar". The "Jagd" button is the one to activate.apps/web/src/app/app.routes.tscurrently only registers/worldas a lazy child under the shell — no/huntor/combatroute exists yet.ContextPanelComponent(apps/web/src/app/layout/context-panel/) is rendered unconditionally by the shell (not per-route) and readsWorldStoredirectly today. - Tests: API — colocated
*.spec.ts, e2e inapps/api/test/*.e2e-spec.ts.travel.service.spec.tshand-rolls fakeFakeRepository/FakeDataSourceclasses to simulatedataSource.transaction()and pessimistic locks rather than using@nestjs/testing+ real TypeORM — mirror this forHuntingService's tests.apps/api/src/travel/clock.ts(export interface Clock { now(): Date },CLOCK = Symbol('CLOCK'),systemClock) is the exact template for the newRandomSourceseam. Web — colocated*.spec.tsusingTestBedwithprovide: WorldStore, useValue: {...fake signals...}. - Character stats available for danger rating:
Characterhaslevel,baseHp,baseAttack,currentHp(no armor). NoCharacterStatsService/CombatPowerexists 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.pngandart/enemies/Strassenraeuber.pngalready exist (moved to the unserved rootart/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 callstravelService.completeTravelIfDue(characterId)before readingcharacter.currentLocationId, and already returnshuntingEnabled,regionKey,minRecommendedLevel,maxRecommendedLevel,dangerLevelonCurrentLocationResponse. 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: uuidPK (@PrimaryGeneratedColumn('uuid', { name: 'id' }))key: varchar(100)— unique via@Index('IDX_monster_definitions_key', ['key'], { unique: true })at class levelname: varchar(150)level: integermaxHp→ columnmax_hp: integerattack: integerarmor: integerexperienceReward→ columnexperience_reward: integersilverMin→ columnsilver_min: integersilverMax→ columnsilver_max: integerartworkPath→ columnartwork_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: uuidPKlocationId→ columnlocation_id: uuid, plus@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'location_id' })(importLocationDefinitionfrom../../world/entities/location-definition.entity)monsterId→ columnmonster_id: uuid, plus@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' }) @JoinColumn({ name: 'monster_id' })named propertymonsterweight: integerencounterType→ columnencounter_type,@Column({ type: 'enum', enum: EncounterType, enumName: 'location_monster_encounter_type_enum', default: EncounterType.NORMAL })enabled: boolean, defaulttrue
hunt-status.enum.ts:
export enum HuntStatus {
ACTIVE = 'ACTIVE',
SUPERSEDED = 'SUPERSEDED',
}
hunt.entity.ts — table hunts:
id: uuidPKcharacterId→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) — noupdated_at, this is an append-only row likeTravel.
hunt-encounter.entity.ts — table hunt_encounters:
id: uuidPKhuntId→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),HuntEncounterrows are owned/composed by their parentHuntand 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 propertymonsterposition: integercreatedAt→created_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 ofart/enemies/Aschenratte.png— do not move the original, do not delete anything fromart/) - Create:
apps/web/public/images/monsters/road-bandit.png(copy ofart/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.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 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:
- Call
await this.travelService.completeTravelIfDue(characterId). This both resolves any travel that has finished but not yet been observed, and throwscharacterNotFound()internally if the character doesn't exist (reuse, do not duplicate that check). If the result'sstatus === TravelStatus.TRAVELLING, throwcharacterTravelling()— the character is genuinely still travelling. - Load the character fresh (
dataSource.getRepository(Character).findOne({ where: { id: characterId }, relations: { currentLocation: true } })) to get the now-authoritativecurrentLocationIdand the loadedcurrentLocation(needed forhuntingEnabledand for building the response'slocationfield). - If
!character.currentLocation.huntingEnabled, throwhuntingNotAvailable(). - Load the enabled
LocationMonsterpool for this location:dataSource.getRepository(LocationMonster).find({ where: { locationId: character.currentLocationId, enabled: true }, relations: { monster: true } }). - If the pool is empty, throw
noHuntEncountersAvailable(). - 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' } })) — mirrorsTravelService.lockCharacter, serializes concurrentstartHuntcalls 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 existingACTIVEhunt for this character:manager.getRepository(Hunt).update({ characterId, status: HuntStatus.ACTIVE }, { status: HuntStatus.SUPERSEDED }). c. Create and save the newHuntrow (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 exceedsroll(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-pickednext()return values. e. For each picked monster, computedangerRating = calculateDangerRating({attack: character.baseAttack, armor: 0, hp: character.baseHp}, {attack: monster.attack, armor: monster.armor, hp: monster.maxHp}). f. Create and save oneHuntEncounterrow per slot (huntId,monsterDefinitionId,position= 0/1/2). g. Return the assembledHuntResultDto.
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: truelocation with a non-empty pool → aHuntis created, exactly 3HuntEncounterrows are created and saved, and the returned DTO'sencountersarray has length 3, each with its ownid. - Travelling character:
completeTravelIfDuefake returns{status: TravelStatus.TRAVELLING, ...}→CHARACTER_TRAVELLING. - Empty encounter pool:
huntingEnabled: truebut theLocationMonsterpool 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 fakeRandomSourcereturning0.1then0.9then0.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
startHunttwice for the same character; assert the firstHuntrow is nowSUPERSEDEDand the second isACTIVE. - Encounter identity: assert each
HuntEncounterhas its own generated id and itsmonsterDefinitionIdmatches 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(registerHuntingModule,MonsterDefinition/LocationMonster/Hunt/HuntEncounterentities 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 coveringgetCurrentLocation) - 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>— callsgameApi.startHunt()viafirstValueFrom, setscurrentHunt, clearsselectedEncounterId, standard loading/error handling.refreshHunt()— same asstartHunt()(the "Neu suchen" action is literally anotherPOST /api/huntscall per spec; do not add a separate method that behaves differently — a thin alias or direct reuse ofstartHuntis correct here, not a design smell).selectEncounter(encounterId: string): void— setsselectedEncounterId(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,.scssif 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'→HuntPageComponentpath: '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 onhuntingEnabled.) Action: a real button/link labeledZur Kartenavigating to/world. No enabled "Jagd beginnen" control is rendered in this state at all (not merely disabled — absent). -
State B – ready to hunt (
huntingEnabled === trueandcurrentHunt() === nulland notloading()): show the location's name/short description (reuse fields already onWorldStore.currentLocation()— do not fetch or hardcode new copy), a short hunting description sentence, and aJagd beginnenbutton that callshuntingStore.startHunt(). -
State C – loading (
loading()is true): showDu 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() !== nulland notloading()): rendercurrentHunt()!.encountersas<app-encounter-card>instances in a row-oriented layout that comfortably fits 2–3 large cards across on desktop widths (flex/grid, matchingworld-page's existing layout approach rather than inventing a new one), plusNeu suchen(callshuntingStore.refreshHunt()) andZur 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 beginnenbutton exists in the DOM at all,Zur Karteis present and navigates to/world. - Hunt start: clicking
Jagd beginnencalls the fakehuntingStore.startHunt. - Render encounters: given a fake
currentHunt()with 3 encounters (Aschenratte, Straßenräuber, Aschenratte — duplicates allowed and expected), assert 3app-encounter-cardelements render with correct names/levels/danger text/artworksrc. - Refresh: clicking
Neu suchencalls the fakehuntingStore.refreshHunt. - Select encounter: clicking an
EncounterCard'sAngreifentriggers navigation to/combat/newwith the correctencounterIdquery param (spy onRouter.navigate) — assert it is the encounter'sid, notmonster.keyor 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:
- Südtor → Jagd → unavailable state, correct copy, no hunt button,
Zur Karteworks. - Südtor → Karte → travel to Verbrannte Straße → countdown completes.
- Verbrannte Straße → Jagd → ready-to-hunt state →
Jagd beginnen→ loading copy briefly visible → 2–3 large encounter cards with real artwork appear. Neu suchen→ new (possibly different) encounters appear, previous hunt is nowSUPERSEDED(spot-check via DB or by re-running the e2e test).Angreifenon any card → navigates to/combat/new?encounterId=<uuid>→ placeholder renders, no combat state exists, theencounterIdin the URL matches the card that was clicked.- 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
MonsterDefinitioncontent. - Both are assigned to
burned-roadthrough persistedLocationMonsterrows. - 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).
HuntandHuntEncounterare persisted; refreshing supersedes the old hunt.- The Angular hunt page displays 2–3 large encounter cards with real artwork and backend-computed danger ratings.
Jagdnavigation,Neu suchen, andAngreifenall work end-to-end;AngreifenpreservesHuntEncounter.idspecifically, never an arbitrarymonsterId.- 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:weball pass.