Compare commits

...

16 Commits

Author SHA1 Message Date
Bastian Wagner
f24a5fea9c sprites 2026-08-19 14:43:25 +02:00
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
Bastian Wagner
137a18f4e7 style: fix prettier formatting drift in two API files
vertical-slice.seed.ts and danger-rating.spec.ts had line-wrapping
that no longer matched prettier's output, which a CI prettier --check
would flag. Formatting only, no logic change.
2026-08-19 14:33:41 +02:00
Bastian Wagner
833fa52d8d perf: serve optimized JPEG derivatives for monster artwork
encounter-card.component rendered the raw 2.39MB ash-rat.png and
2.00MB road-bandit.png directly, up to 3 cards per hunt (~7MB/load,
re-rendered on "Neu suchen"). Add 560px-wide JPEG runtime derivatives
(generated via PowerShell System.Drawing, HighQualityBicubic, quality
82) and switch to the <picture>/<source srcset> pattern already used
by context-panel for location backgrounds, keeping the original PNGs
as the <img> fallback. Also add loading="lazy" decoding="async".

ash-rat.png: 2,506,677 B -> ash-rat-560.jpg: 35,896 B
road-bandit.png: 2,097,049 B -> road-bandit-560.jpg: 50,797 B
2026-08-19 14:33:32 +02:00
Bastian Wagner
26909d7e2a fix: load WorldStore on direct /hunt navigation
HuntPageComponent never called WorldStore.load(), so opening /hunt
directly (bookmark/hard refresh) without first visiting /world left
currentLocation() at null forever, stranding the page and the context
panel on their empty states with no recovery. Add ngOnInit that calls
worldStore.load() only when no location is present yet, mirroring
WorldPageComponent's existing call and avoiding a duplicate request.
2026-08-19 14:33:15 +02:00
Bastian Wagner
4e684dc45e feat: add hunt page, combat placeholder, and Jagd navigation
Wires up Slice 0.2 end-to-end: HuntPageComponent renders the
hunting-unavailable/ready/loading/encounters-found states off
HuntingStore and WorldStore, Angreifen hands the HuntEncounter id to a
new inert CombatPlaceholderPageComponent via /combat/new, the Jagd nav
entry is enabled with router-driven active state (matching Karte's),
and the context panel now lists possible encounters for hunting-enabled
locations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 14:06:57 +02:00
Bastian Wagner
784bd3ce3a fix: replace invented hex color with existing --ar-bg token
Code review flagged .encounter-card__artwork-frame using a new
hardcoded #050607 background instead of reusing an existing design
token, violating the no-new-hex-colors requirement.
2026-08-19 13:39:35 +02:00
Bastian Wagner
15aa83d221 feat: add EncounterCard component for hunt encounter selection
Task 9 of Playable Slice 0.2 (First Hunt). Artwork-forward encounter
card reusing travel-panel's dark-fantasy panel chrome (tokens, border,
gradient, button style). Emits encounter.id (never monster.key) on
Angreifen click, preserving the frontend security boundary.
2026-08-19 13:33:33 +02:00
Bastian Wagner
59269a7608 feat: add DangerBadge shared component
Renders the 5 HuntEncounter danger tiers as German text plus a
per-tier BEM modifier class, mirroring travel-panel's text+class
danger pattern so color is never the sole signal. Colors map across
the existing 3 semantic tokens (success/warning/danger).
2026-08-19 13:26:58 +02:00
Bastian Wagner
49eb110b25 feat: add HuntingStore for hunt/encounter state
Mirrors WorldStore's signal-store architecture to own hunt state for
Slice 0.2 (currentHunt, selectedEncounterId, loading, error) with a
computed encounters accessor and the four hunt error codes mapped to
German messages.
2026-08-19 13:20:15 +02:00
Bastian Wagner
ed03726931 feat: add hunt API client and models for Playable Slice 0.2
Add to game-api.models.ts:
- DangerRating type for hunt encounter danger levels
- MonsterSummary interface with key, name, level, artworkPath
- HuntEncounter interface for individual hunt encounters
- HuntResult interface for hunt completion results, reusing LocationSummary

Extend CurrentLocationResponse with possibleMonsters: string[] field.

Add to game-api.service.ts:
- startHunt(): Observable<HuntResult> method posting to /api/hunts

Update test fixtures to include possibleMonsters field in mock locations.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 13:14:07 +02:00
Bastian Wagner
bb307da0ff feat: wire HuntingController/HuntingModule and enrich current-location with monster pool
Registers HuntingModule (POST /api/hunts) into the DI graph alongside its
new entities, and adds possibleMonsters (enabled LocationMonster pool,
weight-descending, empty when hunting is disabled) to
WorldService.getCurrentLocation. Also updates the pre-existing
DB-less app.e2e-spec.ts to override HuntingModule the same way the other
feature modules already are, since it now needs a real DataSource.
2026-08-19 12:53:09 +02:00
Bastian Wagner
568478dcd2 feat: add HuntingService core domain logic for first-hunt slice
Adds startHunt's server-authoritative flow: complete due travel, verify
the location allows hunting, weighted-random-roll exactly 3 encounters
from the location's enabled monster pool inside a locked transaction
that supersedes any prior active hunt, and compute a danger rating per
encounter from the rolled monster's own stats. Mirrors TravelService's
transaction/locking pattern and error conventions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 12:24:46 +02:00
Bastian Wagner
9423c28bd8 feat: seed Aschenratte and Straßenräuber for Verbrannte Straße
Adds the two starter monster definitions and their location-monster
mapping (weights 70/30) to the vertical-slice seed, following the
same findOneBy/update-or-insert pattern used for locations, plus an
upsert on (locationId, monsterId) for the mapping. Copies the source
artwork into the served images/monsters directory and extends the
seed spec harness to cover both idempotent inserts.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 12:08:26 +02:00
Bastian Wagner
e04827cd5a feat: add hunting system schema migration
Adds the migration and metadata spec for the hunting schema (monster
definitions, location monster spawn tables, hunts, and hunt encounters)
required by Playable Slice 0.2. Mirrors the raw-SQL style of the visible
vertical slice migration; explicitly asserts the CASCADE-vs-RESTRICT
deviation on HuntEncounter's relation to Hunt.

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-08-19 11:55:21 +02:00
Bastian Wagner
0c2f079a6e feat: add hunt/monster domain entities and pure danger-rating helper
Adds Task 1 of Playable Slice 0.2: pure entity/enum/helper definitions
for the Hunt/Encounter system (MonsterDefinition, LocationMonster,
Hunt, HuntEncounter, EncounterType, HuntStatus, RandomSource,
DangerRating). No DB migration, module wiring, or seed data yet.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-19 11:41:09 +02:00
86 changed files with 5518 additions and 86 deletions

View File

@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { CharactersModule } from './characters/characters.module';
import { DatabaseModule } from './database/database.module';
import { HealthModule } from './health/health.module';
import { HuntingModule } from './hunting/hunting.module';
import { TravelModule } from './travel/travel.module';
import { WorldModule } from './world/world.module';
@@ -12,6 +13,7 @@ import { WorldModule } from './world/world.module';
CharactersModule,
TravelModule,
WorldModule,
HuntingModule,
],
})
export class AppModule {}

View File

@@ -0,0 +1,107 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreateHuntingSystem1787500000000 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`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")
)`);
await queryRunner.query(
'CREATE UNIQUE INDEX "IDX_monster_definitions_key" ON "monster_definitions" ("key")',
);
await queryRunner.query(
"CREATE TYPE \"location_monster_encounter_type_enum\" AS ENUM ('NORMAL', 'RARE', 'ELITE', 'BOSS')",
);
await queryRunner.query(`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
)`);
await queryRunner.query(
'CREATE INDEX "IDX_location_monsters_location" ON "location_monsters" ("location_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_location_monsters_monster" ON "location_monsters" ("monster_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_location_monsters_location_monster"
ON "location_monsters" ("location_id", "monster_id")`);
await queryRunner.query(
"CREATE TYPE \"hunt_status_enum\" AS ENUM ('ACTIVE', 'SUPERSEDED')",
);
await queryRunner.query(`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
)`);
await queryRunner.query(
'CREATE INDEX "IDX_hunts_character" ON "hunts" ("character_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_hunts_location" ON "hunts" ("location_id")',
);
await queryRunner.query(`CREATE UNIQUE INDEX "IDX_active_hunt_per_character"
ON "hunts" ("character_id")
WHERE "status" = 'ACTIVE'`);
await queryRunner.query(`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
)`);
await queryRunner.query(
'CREATE INDEX "IDX_hunt_encounters_hunt" ON "hunt_encounters" ("hunt_id")',
);
await queryRunner.query(
'CREATE INDEX "IDX_hunt_encounters_monster_definition" ON "hunt_encounters" ("monster_definition_id")',
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
'DROP INDEX "IDX_hunt_encounters_monster_definition"',
);
await queryRunner.query('DROP INDEX "IDX_hunt_encounters_hunt"');
await queryRunner.query('DROP TABLE "hunt_encounters"');
await queryRunner.query('DROP INDEX "IDX_active_hunt_per_character"');
await queryRunner.query('DROP INDEX "IDX_hunts_location"');
await queryRunner.query('DROP INDEX "IDX_hunts_character"');
await queryRunner.query('DROP TABLE "hunts"');
await queryRunner.query('DROP TYPE "hunt_status_enum"');
await queryRunner.query(
'DROP INDEX "IDX_location_monsters_location_monster"',
);
await queryRunner.query('DROP INDEX "IDX_location_monsters_monster"');
await queryRunner.query('DROP INDEX "IDX_location_monsters_location"');
await queryRunner.query('DROP TABLE "location_monsters"');
await queryRunner.query('DROP TYPE "location_monster_encounter_type_enum"');
await queryRunner.query('DROP INDEX "IDX_monster_definitions_key"');
await queryRunner.query('DROP TABLE "monster_definitions"');
}
}

View File

@@ -0,0 +1,98 @@
import 'reflect-metadata';
import { getMetadataArgsStorage } from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { Hunt } from '../../hunting/entities/hunt.entity';
import { HuntEncounter } from '../../hunting/entities/hunt-encounter.entity';
describe('hunting system schema', () => {
it('maps the monster definition key index and relationship foreign keys explicitly', () => {
const metadata = getMetadataArgsStorage();
const monsterKeyIndex = metadata.indices.find((index) => {
const metadataIndex = index as typeof index & {
options?: { unique?: boolean };
unique?: boolean;
};
return (
index.target === MonsterDefinition &&
index.columns?.includes('key') &&
(metadataIndex.options?.unique ?? metadataIndex.unique) === true
);
});
expect(monsterKeyIndex).toBeDefined();
const relations = metadata.relations.filter((relation) =>
[LocationMonster, Hunt, HuntEncounter].includes(
relation.target as typeof LocationMonster,
),
);
const joinColumns = metadata.joinColumns
.filter((joinColumn) =>
[LocationMonster, Hunt, HuntEncounter].includes(
joinColumn.target as typeof LocationMonster,
),
)
.map((joinColumn) => joinColumn.name);
expect(joinColumns).toEqual(
expect.arrayContaining([
'location_id',
'monster_id',
'character_id',
'location_id',
'hunt_id',
'monster_definition_id',
]),
);
expect(
relations.map((relation) => ({
onDelete: relation.options.onDelete,
propertyName: relation.propertyName,
target: relation.target,
})),
).toEqual(
expect.arrayContaining([
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'location',
target: LocationMonster,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'monster',
target: LocationMonster,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'character',
target: Hunt,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'location',
target: Hunt,
}),
expect.objectContaining({
onDelete: 'RESTRICT',
propertyName: 'monster',
target: HuntEncounter,
}),
]),
);
// Deliberate deviation from the codebase's usual RESTRICT: HuntEncounter
// rows are owned/composed by their parent Hunt and must be removed along
// with it, so this relation uses CASCADE. Asserted explicitly so a
// future refactor can't silently change it.
const huntEncounterToHunt = relations.find(
(relation) =>
relation.target === HuntEncounter && relation.propertyName === 'hunt',
);
expect(huntEncounterToHunt).toBeDefined();
expect(huntEncounterToHunt?.options.onDelete).toBe('CASCADE');
});
});

View File

@@ -1,2 +1,4 @@
export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';

View File

@@ -1,5 +1,7 @@
import { DataSource } from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { seedVisibleVerticalSlice } from './vertical-slice.seed';
@@ -9,6 +11,8 @@ type Row = Record<string, unknown>;
const DEMO_CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
class InMemoryRepository {
readonly rows: Row[] = [];
@@ -61,6 +65,8 @@ function createDataSource(
locationRepository: InMemoryRepository,
connectionRepository: InMemoryRepository,
characterRepository: InMemoryRepository,
monsterRepository: InMemoryRepository,
locationMonsterRepository: InMemoryRepository,
): DataSource {
return {
getRepository: jest.fn((entity: unknown) => {
@@ -73,6 +79,12 @@ function createDataSource(
if (entity === Character) {
return characterRepository;
}
if (entity === MonsterDefinition) {
return monsterRepository;
}
if (entity === LocationMonster) {
return locationMonsterRepository;
}
throw new Error('Unexpected repository');
}),
@@ -84,10 +96,14 @@ describe('seedVisibleVerticalSlice', () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const dataSource = createDataSource(
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
);
await seedVisibleVerticalSlice(dataSource);
@@ -136,13 +152,63 @@ describe('seedVisibleVerticalSlice', () => {
experience: 39,
}),
);
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
expect(monsterRepository.rows).toHaveLength(2);
expect(monsterRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
}),
expect.objectContaining({
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
}),
]),
);
expect(locationMonsterRepository.upsert).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_ID,
weight: 70,
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_ID,
weight: 30,
}),
]),
['locationId', 'monsterId'],
);
expect(locationMonsterRepository.rows).toHaveLength(2);
});
it('preserves existing location IDs and uses them for the directed connections', async () => {
const locationRepository = new InMemoryRepository();
const connectionRepository = new InMemoryRepository();
const characterRepository = new InMemoryRepository();
const persistedSouthGateId = '30000000-0000-4000-8000-000000000001';
const monsterRepository = new InMemoryRepository();
const locationMonsterRepository = new InMemoryRepository();
const persistedSouthGateId = '40000000-0000-4000-8000-000000000001';
locationRepository.rows.push({
id: persistedSouthGateId,
key: 'south-gate',
@@ -152,6 +218,8 @@ describe('seedVisibleVerticalSlice', () => {
locationRepository,
connectionRepository,
characterRepository,
monsterRepository,
locationMonsterRepository,
);
await seedVisibleVerticalSlice(dataSource);

View File

@@ -1,9 +1,17 @@
import { DataSource } from 'typeorm';
import { DEMO_CHARACTER_ID } from '../../demo/demo-character.constants';
import { Character } from '../../characters/entities/character.entity';
import { EncounterType } from '../../monsters/entities/encounter-type.enum';
import { LocationMonster } from '../../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { BURNED_ROAD_ID, SOUTH_GATE_ID } from './vertical-slice.constants';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
ROAD_BANDIT_MONSTER_ID,
SOUTH_GATE_ID,
} from './vertical-slice.constants';
export async function seedVisibleVerticalSlice(
dataSource: DataSource,
@@ -11,6 +19,8 @@ export async function seedVisibleVerticalSlice(
const locationRepository = dataSource.getRepository(LocationDefinition);
const connectionRepository = dataSource.getRepository(LocationConnection);
const characterRepository = dataSource.getRepository(Character);
const monsterRepository = dataSource.getRepository(MonsterDefinition);
const locationMonsterRepository = dataSource.getRepository(LocationMonster);
const locations = [
{
@@ -85,6 +95,77 @@ export async function seedVisibleVerticalSlice(
['fromLocationId', 'toLocationId'],
);
const monsters = [
{
id: ASH_RAT_MONSTER_ID,
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
maxHp: 45,
attack: 5,
armor: 0,
experienceReward: 8,
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
},
{
id: ROAD_BANDIT_MONSTER_ID,
key: 'road-bandit',
name: 'Straßenräuber',
level: 2,
maxHp: 75,
attack: 9,
armor: 5,
experienceReward: 16,
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
},
];
let ashRatId = ASH_RAT_MONSTER_ID;
let roadBanditId = ROAD_BANDIT_MONSTER_ID;
for (const monster of monsters) {
const existingMonster = await monsterRepository.findOneBy({
key: monster.key,
});
const { id, key, ...definition } = monster;
const persistedId = existingMonster?.id ?? id;
if (existingMonster) {
await monsterRepository.update(existingMonster.id, definition);
} else {
await monsterRepository.insert(monster);
}
if (key === 'ash-rat') {
ashRatId = persistedId;
} else {
roadBanditId = persistedId;
}
}
await locationMonsterRepository.upsert(
[
{
locationId: burnedRoadId,
monsterId: ashRatId,
weight: 70,
encounterType: EncounterType.NORMAL,
enabled: true,
},
{
locationId: burnedRoadId,
monsterId: roadBanditId,
weight: 30,
encounterType: EncounterType.NORMAL,
enabled: true,
},
],
['locationId', 'monsterId'],
);
const existing = await characterRepository.findOneBy({
id: DEMO_CHARACTER_ID,
});

View File

@@ -0,0 +1,74 @@
import { calculateDangerRating, DangerRating } from './danger-rating';
// Demo character seed stats (Task 3): baseAttack: 6, baseHp: 100, no armor.
// power(character) = 6*4 + 0*2 + floor(100/5) = 24 + 0 + 20 = 44
const CHARACTER = { attack: 6, armor: 0, hp: 100 };
const CHARACTER_POWER = 44;
describe('calculateDangerRating', () => {
it('rates the seeded Aschenratte as MATCH', () => {
// Aschenratte: attack 5, armor 0, maxHp 45
// power(monster) = 5*4 + 0*2 + floor(45/5) = 20 + 0 + 9 = 29
// ratio = 29 / 44 = 0.6590909... -> not < 0.65, and < 1.0 -> MATCH
const monster = { attack: 5, armor: 0, hp: 45 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.MATCH);
});
it('rates the seeded Straßenräuber as STRONG', () => {
// Straßenräuber: attack 9, armor 5, maxHp 75
// power(monster) = 9*4 + 5*2 + floor(75/5) = 36 + 10 + 15 = 61
// ratio = 61 / 44 = 1.3863636... -> not < 1.0, and < 1.7 -> STRONG
const monster = { attack: 9, armor: 5, hp: 75 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.STRONG);
});
it('rates a monster just below the WEAK/MATCH boundary as WEAK', () => {
// Hand-picked monster: attack 4, armor 4, hp 20
// power(monster) = 4*4 + 4*2 + floor(20/5) = 16 + 8 + 4 = 28
// ratio = 28 / 44 = 0.6363636...
// WEAK/MATCH boundary is ratio < 0.65, i.e. power < 0.65 * 44 = 28.6.
// 28 is the largest integer power below that boundary -> WEAK.
// (One power higher, 29, is the Aschenratte case above, which is MATCH.)
const monster = { attack: 4, armor: 4, hp: 20 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.WEAK);
});
it('rates a monster just at the STRONG/VERY_DANGEROUS boundary as VERY_DANGEROUS', () => {
// Hand-picked monster: attack 15, armor 5, hp 25
// power(monster) = 15*4 + 5*2 + floor(25/5) = 60 + 10 + 5 = 75
// ratio = 75 / 44 = 1.7045454...
// STRONG/VERY_DANGEROUS boundary is ratio < 1.7, i.e. power < 1.7 * 44 = 74.8.
// 75 is the smallest integer power at/above that boundary -> VERY_DANGEROUS.
// (One power lower, 74, gives ratio 74/44 = 1.6818..., which is STRONG.)
const monster = { attack: 15, armor: 5, hp: 25 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(
DangerRating.VERY_DANGEROUS,
);
});
it('rates a monster just at the VERY_DANGEROUS/DEADLY boundary as DEADLY', () => {
// Hand-picked monster: attack 20, armor 10, hp 10
// power(monster) = 20*4 + 10*2 + floor(10/5) = 80 + 20 + 2 = 102
// ratio = 102 / 44 = 2.3181818...
// VERY_DANGEROUS/DEADLY boundary is ratio < 2.3, i.e. power < 2.3 * 44 = 101.2.
// 102 is the smallest integer power at/above that boundary -> DEADLY.
// (One power lower, 101, gives ratio 101/44 = 2.2954..., which is VERY_DANGEROUS.)
const monster = { attack: 20, armor: 10, hp: 10 };
expect(calculateDangerRating(CHARACTER, monster)).toBe(DangerRating.DEADLY);
});
});
// Sanity check that CHARACTER_POWER documented above matches the formula.
describe('CHARACTER_POWER sanity check', () => {
it('matches power(CHARACTER) as computed by the same formula', () => {
const power =
CHARACTER.attack * 4 + CHARACTER.armor * 2 + Math.floor(CHARACTER.hp / 5);
expect(power).toBe(CHARACTER_POWER);
});
});

View File

@@ -0,0 +1,31 @@
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;
}

View File

@@ -0,0 +1,39 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { Hunt } from './hunt.entity';
@Entity({ name: 'hunt_encounters' })
export class HuntEncounter {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'hunt_id', type: 'uuid' })
huntId!: string;
@Column({ name: 'monster_definition_id', type: 'uuid' })
monsterDefinitionId!: string;
@Column({ name: 'position', type: 'integer' })
position!: number;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
// CASCADE (unlike the other FKs in this file, which use RESTRICT): a
// HuntEncounter is owned/composed by its parent Hunt and has no
// independent lifecycle, so it should be removed along with its Hunt.
@ManyToOne(() => Hunt, { onDelete: 'CASCADE' })
@JoinColumn({ name: 'hunt_id' })
hunt!: Hunt;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_definition_id' })
monster!: MonsterDefinition;
}

View File

@@ -0,0 +1,42 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { HuntStatus } from '../hunt-status.enum';
@Entity({ name: 'hunts' })
export class Hunt {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'character_id', type: 'uuid' })
characterId!: string;
@Column({ name: 'location_id', type: 'uuid' })
locationId!: string;
@Column({
name: 'status',
type: 'enum',
enum: HuntStatus,
enumName: 'hunt_status_enum',
})
status!: HuntStatus;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@ManyToOne(() => Character, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'character_id' })
character!: Character;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'location_id' })
location!: LocationDefinition;
}

View File

@@ -0,0 +1,4 @@
export enum HuntStatus {
ACTIVE = 'ACTIVE',
SUPERSEDED = 'SUPERSEDED',
}

View File

@@ -0,0 +1,50 @@
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import request from 'supertest';
import { App } from 'supertest/types';
import { configureApplication } from '../app.config';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntingController } from './hunting.controller';
import { HuntingService } from './hunting.service';
describe('HuntingController', () => {
let app: INestApplication<App>;
const startHunt = jest.fn();
beforeEach(async () => {
startHunt.mockReset();
const module = await Test.createTestingModule({
controllers: [HuntingController],
providers: [
{
provide: HuntingService,
useValue: { startHunt },
},
],
}).compile();
app = module.createNestApplication<App>();
configureApplication(app);
await app.init();
});
afterEach(async () => {
await app.close();
});
it('delegates to huntingService.startHunt with the demo character id and returns its result', async () => {
const huntResult = {
id: 'hunt-1',
location: { id: 'loc-1', key: 'burned-road', name: 'Verbrannte Strasse' },
encounters: [],
};
startHunt.mockResolvedValue(huntResult);
const response = await request(app.getHttpServer())
.post('/api/hunts')
.expect(201);
expect(startHunt).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
expect(response.body).toEqual(huntResult);
});
});

View File

@@ -0,0 +1,13 @@
import { Controller, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { HuntResultDto, HuntingService } from './hunting.service';
@Controller('hunts')
export class HuntingController {
constructor(private readonly huntingService: HuntingService) {}
@Post()
startHunt(): Promise<HuntResultDto> {
return this.huntingService.startHunt(DEMO_CHARACTER_ID);
}
}

View File

@@ -0,0 +1,37 @@
import { HttpException } from '@nestjs/common';
export class HuntingDomainError extends HttpException {
constructor(
public readonly 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.',
);
}
export { characterNotFound } from '../travel/travel.errors';

View File

@@ -0,0 +1,30 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelModule } from '../travel/travel.module';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntingController } from './hunting.controller';
import { HuntingService } from './hunting.service';
import { RANDOM_SOURCE, systemRandomSource } from './random-source';
@Module({
imports: [
TypeOrmModule.forFeature([
MonsterDefinition,
LocationMonster,
Hunt,
HuntEncounter,
Character,
]),
TravelModule,
],
controllers: [HuntingController],
providers: [
HuntingService,
{ provide: RANDOM_SOURCE, useValue: systemRandomSource },
],
})
export class HuntingModule {}

View File

@@ -0,0 +1,570 @@
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { EncounterType } from '../monsters/entities/encounter-type.enum';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { LocationDefinition } from '../world/entities/location-definition.entity';
import { DangerRating } from './danger-rating';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntStatus } from './hunt-status.enum';
import { HuntingDomainError } from './hunting.errors';
import { HuntingService } from './hunting.service';
import type { RandomSource } from './random-source';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const HUNTING_LOCATION_ID = '20000000-0000-4000-8000-000000000001';
const SAFE_LOCATION_ID = '20000000-0000-4000-8000-000000000002';
const MONSTER_A_ID = '30000000-0000-4000-8000-000000000001'; // Aschenratte
const MONSTER_B_ID = '30000000-0000-4000-8000-000000000002'; // Strassenraeuber
const LOCATION_MONSTER_A_ID = '40000000-0000-4000-8000-000000000001';
const LOCATION_MONSTER_B_ID = '40000000-0000-4000-8000-000000000002';
interface FakeState {
characters: Character[];
locationMonsters: LocationMonster[];
hunts: Hunt[];
huntEncounters: HuntEncounter[];
}
class FakeRepository<T extends { id: string }> {
constructor(
private readonly state: FakeState,
private readonly target: EntityTarget<T>,
private readonly inTransaction: boolean,
private readonly dataSource: FakeDataSource,
) {}
findOne(options: {
where: Partial<T>;
lock?: { mode: string };
}): Promise<T | null> {
if (options.lock) {
if (!this.inTransaction) {
throw new Error('Pessimistic locks require a transaction');
}
this.dataSource.locks.push({
target: this.target,
mode: options.lock.mode,
});
}
return Promise.resolve(
this.rows().find((row) => this.matches(row, options.where)) ?? null,
);
}
find(options: { where: Partial<T> }): Promise<T[]> {
return Promise.resolve(
this.rows().filter((row) => this.matches(row, options.where)),
);
}
create(values: Partial<T>): T {
return { ...values } as T;
}
save(entity: T): Promise<T> {
if (this.dataSource.failSaveTarget === this.target) {
throw new Error(`Failed to save ${this.targetName()}`);
}
if (!entity.id) {
entity.id = this.dataSource.nextId(this.targetName());
}
const rows = this.rows();
const index = rows.findIndex((row) => row.id === entity.id);
if (index === -1) {
rows.push(entity);
} else {
rows[index] = entity;
}
return Promise.resolve(entity);
}
update(where: Partial<T>, partial: Partial<T>): Promise<void> {
for (const row of this.rows()) {
if (this.matches(row, where)) {
Object.assign(row, partial);
}
}
return Promise.resolve();
}
private rows(): T[] {
if (this.target === Character) {
return this.state.characters as T[];
}
if (this.target === LocationMonster) {
return this.state.locationMonsters as T[];
}
if (this.target === Hunt) {
return this.state.hunts as T[];
}
if (this.target === HuntEncounter) {
return this.state.huntEncounters as T[];
}
throw new Error(`Unsupported repository ${this.targetName()}`);
}
private matches(row: T, where: Partial<T>): boolean {
return Object.entries(where).every(
([key, value]) => row[key as keyof T] === value,
);
}
private targetName(): string {
return typeof this.target === 'function'
? this.target.name
: 'EntitySchema';
}
}
class FakeEntityManager {
constructor(
private readonly state: FakeState,
private readonly dataSource: FakeDataSource,
) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, true, this.dataSource);
}
}
class FakeDataSource {
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
failSaveTarget?: EntityTarget<unknown>;
private readonly idCounters = new Map<string, number>();
constructor(public state: FakeState) {}
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
return new FakeRepository(this.state, target, false, this);
}
async transaction<T>(
work: (manager: EntityManager) => Promise<T>,
): Promise<T> {
const draft = structuredClone(this.state);
const result = await work(
new FakeEntityManager(draft, this) as unknown as EntityManager,
);
this.state = draft;
return result;
}
nextId(targetName: string): string {
const next = (this.idCounters.get(targetName) ?? 0) + 1;
this.idCounters.set(targetName, next);
return `${targetName.toLowerCase()}-generated-${next}`;
}
}
function huntingLocation(): LocationDefinition {
return {
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
description: 'A burned road.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: false,
huntingEnabled: true,
artworkPath: '/assets/locations/burned-road.webp',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
outgoingConnections: [],
incomingConnections: [],
};
}
function safeLocation(): LocationDefinition {
return {
id: SAFE_LOCATION_ID,
key: 'south-gate',
name: 'Suedtor von Graufurt',
description: 'A safe gate.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
isSafe: true,
huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp',
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
outgoingConnections: [],
incomingConnections: [],
};
}
function character(currentLocation: LocationDefinition): Character {
return {
id: CHARACTER_ID,
name: 'Aric Duskwalker',
level: 1,
experience: 0,
baseHp: 100,
baseAttack: 6,
currentHp: 100,
currentLocationId: currentLocation.id,
currentLocation,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
};
}
function monsterDefinition(
id: string,
key: string,
name: string,
overrides: Partial<MonsterDefinition> = {},
): MonsterDefinition {
return {
id,
key,
name,
level: 1,
maxHp: 20,
attack: 3,
armor: 0,
experienceReward: 10,
silverMin: 1,
silverMax: 3,
artworkPath: `/assets/monsters/${key}.webp`,
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
...overrides,
};
}
function locationMonster(
id: string,
locationId: string,
monster: MonsterDefinition,
weight: number,
enabled = true,
): LocationMonster {
return {
id,
locationId,
monsterId: monster.id,
weight,
encounterType: EncounterType.NORMAL,
enabled,
monster,
} as LocationMonster;
}
function createState(): FakeState {
return {
characters: [character(safeLocation())],
locationMonsters: [],
hunts: [],
huntEncounters: [],
};
}
function fakeRandomSource(values: number[]): RandomSource {
const queue = [...values];
return {
next: () => {
const value = queue.shift();
if (value === undefined) {
throw new Error('fakeRandomSource exhausted its canned values');
}
return value;
},
};
}
function fakeTravelService(
overrides: Partial<TravelService> = {},
): TravelService {
return {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
...overrides,
} as unknown as TravelService;
}
function createService(
options: {
state?: FakeState;
travelService?: TravelService;
randomSource?: RandomSource;
} = {},
) {
const state = options.state ?? createState();
const dataSource = new FakeDataSource(state);
const travelService = options.travelService ?? fakeTravelService();
const randomSource = options.randomSource ?? fakeRandomSource([]);
const service = new HuntingService(
dataSource as unknown as DataSource,
travelService,
randomSource,
);
return { dataSource, service, travelService, randomSource };
}
async function expectHuntingDomainError(
promise: Promise<unknown>,
code: string,
): Promise<void> {
let error: unknown;
try {
await promise;
} catch (cause) {
error = cause;
}
expect(error).toBeInstanceOf(HuntingDomainError);
if (!(error instanceof HuntingDomainError)) {
throw new Error('Expected HuntingDomainError');
}
expect(error.code).toBe(code);
}
describe('HuntingService', () => {
it('rejects hunting at a location where hunting is disabled', async () => {
const { service } = createService();
await expectHuntingDomainError(
service.startHunt(CHARACTER_ID),
'HUNTING_NOT_AVAILABLE',
);
});
it('starts a valid hunt with exactly three saved encounters', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters).toHaveLength(3);
const encounterIds = new Set(result.encounters.map((e) => e.id));
expect(encounterIds.size).toBe(3);
expect(result.location).toEqual({
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'Verbrannte Strasse',
});
expect(dataSource.state.hunts).toHaveLength(1);
expect(dataSource.state.hunts[0]).toMatchObject({
characterId: CHARACTER_ID,
locationId: HUNTING_LOCATION_ID,
status: HuntStatus.ACTIVE,
});
expect(dataSource.state.huntEncounters).toHaveLength(3);
expect(dataSource.locks).toEqual([
{ target: Character, mode: 'pessimistic_write' },
]);
});
it('rejects hunting while the character is still travelling', async () => {
const travelService = fakeTravelService({
completeTravelIfDue: jest.fn().mockResolvedValue({
status: TravelStatus.TRAVELLING,
originLocation: { id: SAFE_LOCATION_ID, key: 'south-gate', name: 'x' },
targetLocation: {
id: HUNTING_LOCATION_ID,
key: 'burned-road',
name: 'y',
},
startedAt: new Date(),
arrivesAt: new Date(),
}) as unknown as TravelService['completeTravelIfDue'],
});
const { service } = createService({ travelService });
await expectHuntingDomainError(
service.startHunt(CHARACTER_ID),
'CHARACTER_TRAVELLING',
);
});
it('rejects hunting when the location has no enabled encounter pool', async () => {
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [];
const { service } = createService({ state });
await expectHuntingDomainError(
service.startHunt(CHARACTER_ID),
'NO_HUNT_ENCOUNTERS_AVAILABLE',
);
});
it('picks monsters deterministically from canned RandomSource rolls', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
// 0.1*100=10 < 70 -> A ; 0.9*100=90 >= 70 -> B ; 0.1*100=10 < 70 -> A
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
expect(result.encounters.map((e) => e.monster.key)).toEqual([
'aschenratte',
'strassenraeuber',
'aschenratte',
]);
const persisted = [...dataSource.state.huntEncounters].sort(
(a, b) => a.position - b.position,
);
expect(persisted.map((e) => e.monsterDefinitionId)).toEqual([
MONSTER_A_ID,
MONSTER_B_ID,
MONSTER_A_ID,
]);
});
it('supersedes the previous active hunt when a new hunt is started', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
monsterA,
100,
),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1, 0.1, 0.1, 0.1]),
});
await service.startHunt(CHARACTER_ID);
await service.startHunt(CHARACTER_ID);
expect(dataSource.state.hunts).toHaveLength(2);
const [firstHunt, secondHunt] = dataSource.state.hunts;
expect(firstHunt.status).toBe(HuntStatus.SUPERSEDED);
expect(secondHunt.status).toBe(HuntStatus.ACTIVE);
expect(firstHunt.id).not.toBe(secondHunt.id);
expect(dataSource.locks).toEqual([
{ target: Character, mode: 'pessimistic_write' },
{ target: Character, mode: 'pessimistic_write' },
]);
});
it('gives each encounter its own id matching the monster rolled for that slot', async () => {
const monsterA = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
);
const monsterB = monsterDefinition(
MONSTER_B_ID,
'strassenraeuber',
'Straßenräuber',
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.locationMonsters = [
locationMonster(LOCATION_MONSTER_A_ID, HUNTING_LOCATION_ID, monsterA, 70),
locationMonster(LOCATION_MONSTER_B_ID, HUNTING_LOCATION_ID, monsterB, 30),
];
const { dataSource, service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.9, 0.1]),
});
await service.startHunt(CHARACTER_ID);
const encounters = [...dataSource.state.huntEncounters].sort(
(a, b) => a.position - b.position,
);
const ids = encounters.map((e) => e.id);
expect(new Set(ids).size).toBe(3);
expect(encounters[0].monsterDefinitionId).toBe(MONSTER_A_ID);
expect(encounters[1].monsterDefinitionId).toBe(MONSTER_B_ID);
expect(encounters[2].monsterDefinitionId).toBe(MONSTER_A_ID);
});
it('computes a danger rating per encounter from the real monster stats', async () => {
const weakMonster = monsterDefinition(
MONSTER_A_ID,
'aschenratte',
'Aschenratte',
{
attack: 1,
armor: 0,
maxHp: 5,
},
);
const state = createState();
state.characters[0].currentLocationId = HUNTING_LOCATION_ID;
state.characters[0].currentLocation = huntingLocation();
state.characters[0].baseAttack = 6;
state.characters[0].baseHp = 100;
state.locationMonsters = [
locationMonster(
LOCATION_MONSTER_A_ID,
HUNTING_LOCATION_ID,
weakMonster,
100,
),
];
const { service } = createService({
state,
randomSource: fakeRandomSource([0.1, 0.1, 0.1]),
});
const result = await service.startHunt(CHARACTER_ID);
for (const encounter of result.encounters) {
expect(encounter.dangerRating).toBe(DangerRating.WEAK);
}
});
});

View File

@@ -0,0 +1,200 @@
import { Inject, Injectable } from '@nestjs/common';
import { DataSource, Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import type { LocationSummary } from '../travel/travel.service';
import { TravelService } from '../travel/travel.service';
import { TravelStatus } from '../travel/travel-status.enum';
import { calculateDangerRating, DangerRating } from './danger-rating';
import { Hunt } from './entities/hunt.entity';
import { HuntEncounter } from './entities/hunt-encounter.entity';
import { HuntStatus } from './hunt-status.enum';
import {
characterNotFound,
characterTravelling,
huntingNotAvailable,
noHuntEncountersAvailable,
} from './hunting.errors';
import { RANDOM_SOURCE } from './random-source';
import type { RandomSource } from './random-source';
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[];
}
const ENCOUNTER_COUNT = 3;
@Injectable()
export class HuntingService {
constructor(
private readonly dataSource: DataSource,
private readonly travelService: TravelService,
@Inject(RANDOM_SOURCE) private readonly randomSource: RandomSource,
) {}
async startHunt(characterId: string): Promise<HuntResultDto> {
const travel = await this.travelService.completeTravelIfDue(characterId);
if (travel.status === TravelStatus.TRAVELLING) {
throw characterTravelling();
}
const characters = this.dataSource.getRepository(Character);
const character = await characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
// completeTravelIfDue already validated the character exists; this
// guard only protects against a pathological race and satisfies the
// type checker (currentLocation would otherwise be possibly undefined).
throw characterNotFound();
}
if (!character.currentLocation.huntingEnabled) {
throw huntingNotAvailable();
}
const locationMonsters = this.dataSource.getRepository(LocationMonster);
const pool = await locationMonsters.find({
where: { locationId: character.currentLocationId, enabled: true },
relations: { monster: true },
});
if (pool.length === 0) {
throw noHuntEncountersAvailable();
}
return this.dataSource.transaction(async (manager) => {
const txCharacters = manager.getRepository(Character);
const txHunts = manager.getRepository(Hunt);
const txEncounters = manager.getRepository(HuntEncounter);
const lockedCharacter = await this.lockCharacter(
txCharacters,
characterId,
);
await txHunts.update(
{ characterId, status: HuntStatus.ACTIVE },
{ status: HuntStatus.SUPERSEDED },
);
const hunt = txHunts.create({
characterId,
locationId: lockedCharacter.currentLocationId,
status: HuntStatus.ACTIVE,
});
await txHunts.save(hunt);
const pickedMonsters = this.rollEncounters(pool, ENCOUNTER_COUNT);
const encounterDtos: HuntEncounterDto[] = [];
for (let position = 0; position < pickedMonsters.length; position += 1) {
const monster = pickedMonsters[position];
const encounter = txEncounters.create({
huntId: hunt.id,
monsterDefinitionId: monster.id,
position,
});
await txEncounters.save(encounter);
const dangerRating = calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{
attack: monster.attack,
armor: monster.armor,
hp: monster.maxHp,
},
);
encounterDtos.push({
id: encounter.id,
monster: {
key: monster.key,
name: monster.name,
level: monster.level,
artworkPath: monster.artworkPath,
},
dangerRating,
});
}
return {
id: hunt.id,
location: this.toLocationSummary(character.currentLocation),
encounters: encounterDtos,
};
});
}
/**
* Rolls `count` independent weighted picks from `pool`. Each slot walks
* the pool in the order it was supplied, accumulating weight, and picks
* the first entry whose cumulative weight exceeds the roll
* (roll < cumulative). Pure and deterministic given a RandomSource, so
* it is trivially unit-testable with canned `next()` values.
*/
private rollEncounters(
pool: LocationMonster[],
count: number,
): MonsterDefinition[] {
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
const picks: MonsterDefinition[] = [];
for (let i = 0; i < count; i += 1) {
const roll = this.randomSource.next() * totalWeight;
let cumulative = 0;
let picked: LocationMonster = pool[pool.length - 1];
for (const entry of pool) {
cumulative += entry.weight;
if (roll < cumulative) {
picked = entry;
break;
}
}
picks.push(picked.monster);
}
return picks;
}
private async lockCharacter(
characters: Repository<Character>,
characterId: string,
): Promise<Character> {
const character = await characters.findOne({
where: { id: characterId },
lock: { mode: 'pessimistic_write' },
});
if (!character) {
// The pre-transaction load above already confirmed the character
// exists; a miss here would only occur under a pathological
// concurrent deletion, which the schema's RESTRICT FKs prevent.
throw characterNotFound();
}
return character;
}
private toLocationSummary(
location: Character['currentLocation'],
): LocationSummary {
return {
id: location.id,
key: location.key,
name: location.name,
};
}
}

View File

@@ -0,0 +1,9 @@
export interface RandomSource {
next(): number; // uniform value in [0, 1)
}
export const RANDOM_SOURCE = Symbol('RANDOM_SOURCE');
export const systemRandomSource: RandomSource = {
next: () => Math.random(),
};

View File

@@ -0,0 +1,6 @@
export enum EncounterType {
NORMAL = 'NORMAL',
RARE = 'RARE',
ELITE = 'ELITE',
BOSS = 'BOSS',
}

View File

@@ -0,0 +1,45 @@
import {
Column,
Entity,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
} from 'typeorm';
import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { EncounterType } from './encounter-type.enum';
import { MonsterDefinition } from './monster-definition.entity';
@Entity({ name: 'location_monsters' })
export class LocationMonster {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'location_id', type: 'uuid' })
locationId!: string;
@Column({ name: 'monster_id', type: 'uuid' })
monsterId!: string;
@Column({ name: 'weight', type: 'integer' })
weight!: number;
@Column({
name: 'encounter_type',
type: 'enum',
enum: EncounterType,
enumName: 'location_monster_encounter_type_enum',
default: EncounterType.NORMAL,
})
encounterType!: EncounterType;
@Column({ name: 'enabled', type: 'boolean', default: true })
enabled!: boolean;
@ManyToOne(() => LocationDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'location_id' })
location!: LocationDefinition;
@ManyToOne(() => MonsterDefinition, { onDelete: 'RESTRICT' })
@JoinColumn({ name: 'monster_id' })
monster!: MonsterDefinition;
}

View File

@@ -0,0 +1,51 @@
import {
Column,
CreateDateColumn,
Entity,
Index,
PrimaryGeneratedColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity({ name: 'monster_definitions' })
@Index('IDX_monster_definitions_key', ['key'], { unique: true })
export class MonsterDefinition {
@PrimaryGeneratedColumn('uuid', { name: 'id' })
id!: string;
@Column({ name: 'key', type: 'varchar', length: 100 })
key!: string;
@Column({ name: 'name', type: 'varchar', length: 150 })
name!: string;
@Column({ name: 'level', type: 'integer' })
level!: number;
@Column({ name: 'max_hp', type: 'integer' })
maxHp!: number;
@Column({ name: 'attack', type: 'integer' })
attack!: number;
@Column({ name: 'armor', type: 'integer' })
armor!: number;
@Column({ name: 'experience_reward', type: 'integer' })
experienceReward!: number;
@Column({ name: 'silver_min', type: 'integer' })
silverMin!: number;
@Column({ name: 'silver_max', type: 'integer' })
silverMax!: number;
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string;
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
@UpdateDateColumn({ name: 'updated_at', type: 'timestamptz' })
updatedAt!: Date;
}

View File

@@ -1,6 +1,8 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelModule } from '../travel/travel.module';
import { LocationConnection } from './entities/location-connection.entity';
import { WorldController } from './world.controller';
@@ -8,7 +10,12 @@ import { WorldService } from './world.service';
@Module({
imports: [
TypeOrmModule.forFeature([Character, LocationConnection]),
TypeOrmModule.forFeature([
Character,
LocationConnection,
LocationMonster,
MonsterDefinition,
]),
TravelModule,
],
controllers: [WorldController],

View File

@@ -5,6 +5,7 @@ import {
BURNED_ROAD_ID,
SOUTH_GATE_ID,
} from '../database/seeds/vertical-slice.constants';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity';
@@ -102,7 +103,16 @@ describe('WorldService', () => {
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const service = new WorldService(travelService, characters, connections);
const findLocationMonsters = jest.fn();
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
const service = new WorldService(
travelService,
characters,
connections,
locationMonsters,
);
const result = await service.getCurrentLocation(CHARACTER_ID);
@@ -131,6 +141,7 @@ describe('WorldService', () => {
danger: 'LOW',
},
],
possibleMonsters: [],
});
expect(findCharacter).toHaveBeenCalledWith({
where: { id: CHARACTER_ID },
@@ -140,6 +151,51 @@ describe('WorldService', () => {
where: { fromLocationId: SOUTH_GATE_ID, enabled: true },
relations: { toLocation: true },
});
expect(findLocationMonsters).not.toHaveBeenCalled();
});
it('returns the enabled monster pool by name, ordered by weight descending, when hunting is enabled', async () => {
const location = burnedRoad();
const travelService = {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService;
const characters = {
findOne: jest.fn().mockResolvedValue({
id: CHARACTER_ID,
currentLocationId: BURNED_ROAD_ID,
currentLocation: location,
}),
} as unknown as Repository<Character>;
const connections = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>;
const findLocationMonsters = jest
.fn()
.mockResolvedValue([
{ monster: { name: 'Aschenratte' } },
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
]);
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
const service = new WorldService(
travelService,
characters,
connections,
locationMonsters,
);
const result = await service.getCurrentLocation(CHARACTER_ID);
expect(result.possibleMonsters).toEqual([
'Aschenratte',
'Stra\u00dfenr\u00e4uber',
]);
expect(findLocationMonsters).toHaveBeenCalledWith({
where: { locationId: BURNED_ROAD_ID, enabled: true },
relations: { monster: true },
order: { weight: 'DESC' },
});
});
it('reports a missing character after travel completion', async () => {
@@ -157,11 +213,21 @@ describe('WorldService', () => {
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const service = new WorldService(travelService, characters, connections);
const findLocationMonsters = jest.fn();
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
const service = new WorldService(
travelService,
characters,
connections,
locationMonsters,
);
await expect(
service.getCurrentLocation(CHARACTER_ID),
).rejects.toBeInstanceOf(NotFoundException);
expect(findConnections).not.toHaveBeenCalled();
expect(findLocationMonsters).not.toHaveBeenCalled();
});
});

View File

@@ -2,6 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity';
@@ -30,6 +31,7 @@ export interface CurrentLocationResponse {
huntingEnabled: boolean;
artworkPath: string;
connections: CurrentLocationConnection[];
possibleMonsters: string[];
}
@Injectable()
@@ -40,6 +42,8 @@ export class WorldService {
private readonly characters: Repository<Character>,
@InjectRepository(LocationConnection)
private readonly connections: Repository<LocationConnection>,
@InjectRepository(LocationMonster)
private readonly locationMonsters: Repository<LocationMonster>,
) {}
async getCurrentLocation(
@@ -61,6 +65,10 @@ export class WorldService {
});
const location = character.currentLocation;
const possibleMonsters = location.huntingEnabled
? await this.getPossibleMonsters(location.id)
: [];
return {
id: location.id,
key: location.key,
@@ -84,9 +92,19 @@ export class WorldService {
travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance),
})),
possibleMonsters,
};
}
private async getPossibleMonsters(locationId: string): Promise<string[]> {
const pool = await this.locationMonsters.find({
where: { locationId, enabled: true },
relations: { monster: true },
order: { weight: 'DESC' },
});
return pool.map((entry) => entry.monster.name);
}
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
return Number(ambushChance) <= 0.05 ? 'LOW' : 'HIGH';
}

View File

@@ -6,6 +6,7 @@ import { AppModule } from './../src/app.module';
import { CharactersModule } from './../src/characters/characters.module';
import { DatabaseModule } from './../src/database/database.module';
import { configureApplication } from './../src/app.config';
import { HuntingModule } from './../src/hunting/hunting.module';
import { TravelModule } from './../src/travel/travel.module';
import { WorldModule } from './../src/world/world.module';
@@ -21,6 +22,9 @@ class TestTravelModule {}
@Module({})
class TestWorldModule {}
@Module({})
class TestHuntingModule {}
describe('API (e2e)', () => {
let app: INestApplication<App>;
@@ -36,6 +40,8 @@ describe('API (e2e)', () => {
.useModule(TestTravelModule)
.overrideModule(WorldModule)
.useModule(TestWorldModule)
.overrideModule(HuntingModule)
.useModule(TestHuntingModule)
.compile();
app = moduleFixture.createNestApplication();

View File

@@ -8,6 +8,7 @@ import { AppModule } from './../src/app.module';
import { CharactersModule } from './../src/characters/characters.module';
import { DatabaseModule } from './../src/database/database.module';
import { configureApplication } from './../src/app.config';
import { HuntingModule } from './../src/hunting/hunting.module';
import { TravelModule } from './../src/travel/travel.module';
import { WorldModule } from './../src/world/world.module';
import { DEMO_CHARACTER_ID } from './../src/demo/demo-character.constants';
@@ -31,6 +32,9 @@ class TestTravelModule {}
@Module({})
class TestWorldModule {}
@Module({})
class TestHuntingModule {}
describe('Visible vertical slice smoke (e2e)', () => {
describe('without a developer database', () => {
let app: INestApplication<App>;
@@ -47,6 +51,8 @@ describe('Visible vertical slice smoke (e2e)', () => {
.useModule(TestTravelModule)
.overrideModule(WorldModule)
.useModule(TestWorldModule)
.overrideModule(HuntingModule)
.useModule(TestHuntingModule)
.compile();
app = moduleFixture.createNestApplication();
@@ -153,9 +159,7 @@ describe('Visible vertical slice smoke (e2e)', () => {
.expect(400);
});
it(
'POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)',
async () => {
it('POST /api/travel starts a travel, rejects a concurrent start, and completes into the moved character (full happy path)', async () => {
// `GET current-location` lazily completes any overdue travel left
// behind by a previous run before we read the starting point, so
// this test is safe to re-run without a fresh seed.
@@ -227,9 +231,95 @@ describe('Visible vertical slice smoke (e2e)', () => {
.get('/api/world/current-location')
.expect(200);
expect(restoredLocation.body.id).toBe(originLocationId);
},
30_000,
}, 30_000);
it('POST /api/hunts starts a hunt at burned-road, and a second call supersedes the first', async () => {
// Get to burned-road (the only hunting-enabled seeded location),
// remembering the origin so we can restore it afterwards and keep
// this test safely re-runnable.
const origin = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
const originLocationId: string = origin.body.id;
const originLocationKey: string = origin.body.key;
let atBurnedRoad = origin.body;
if (originLocationKey !== 'burned-road') {
const toBurnedRoad = origin.body.connections.find(
(connection: { targetLocation: { key: string } }) =>
connection.targetLocation.key === 'burned-road',
);
expect(toBurnedRoad).toBeDefined();
await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId: toBurnedRoad.targetLocation.id })
.expect(201);
await pollUntilTravelCompletes(app, toBurnedRoad.travelDurationSeconds);
const arrived = await request(app.getHttpServer())
.get('/api/world/current-location')
.expect(200);
atBurnedRoad = arrived.body;
}
expect(atBurnedRoad.key).toBe('burned-road');
expect(atBurnedRoad.possibleMonsters).toEqual([
'Aschenratte',
'Straßenräuber',
]);
const firstHunt = await request(app.getHttpServer())
.post('/api/hunts')
.expect(201);
expect(typeof firstHunt.body.id).toBe('string');
expect(firstHunt.body.location).toMatchObject({ key: 'burned-road' });
expect(firstHunt.body.encounters).toHaveLength(3);
for (const encounter of firstHunt.body.encounters as Array<{
id: string;
monster: { key: string };
dangerRating: string;
}>) {
expect(typeof encounter.id).toBe('string');
expect(['ash-rat', 'road-bandit']).toContain(encounter.monster.key);
expect([
'WEAK',
'MATCH',
'STRONG',
'VERY_DANGEROUS',
'DEADLY',
]).toContain(encounter.dangerRating);
}
const secondHunt = await request(app.getHttpServer())
.post('/api/hunts')
.expect(201);
expect(typeof secondHunt.body.id).toBe('string');
expect(secondHunt.body.id).not.toBe(firstHunt.body.id);
// Restore the demo character to its original location so the suite
// stays safely re-runnable.
if (originLocationKey !== 'burned-road') {
const returnConnection = atBurnedRoad.connections.find(
(connection: { targetLocation: { id: string } }) =>
connection.targetLocation.id === originLocationId,
);
expect(returnConnection).toBeDefined();
await request(app.getHttpServer())
.post('/api/travel')
.send({ targetLocationId: originLocationId })
.expect(201);
await pollUntilTravelCompletes(
app,
returnConnection.travelDurationSeconds,
);
}
}, 30_000);
async function pollUntilTravelCompletes(
application: INestApplication<App>,

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

@@ -14,6 +14,20 @@ export const routes: Routes = [
(module) => module.WorldPageComponent,
),
},
{
path: 'hunt',
loadComponent: () =>
import('./features/hunting/hunt-page/hunt-page.component').then(
(module) => module.HuntPageComponent,
),
},
{
path: 'combat/new',
loadComponent: () =>
import('./features/combat/combat-placeholder-page.component').then(
(module) => module.CombatPlaceholderPageComponent,
),
},
],
},
{ path: '**', redirectTo: 'world' },

View File

@@ -1,6 +1,6 @@
import { signal, WritableSignal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { Router, provideRouter } from '@angular/router';
import { CharacterResponse } from './core/api/game-api.models';
import { WorldStore } from './features/world/world.store';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
@@ -19,7 +19,10 @@ describe('App', () => {
await TestBed.configureTestingModule({
imports: [AppShellComponent],
providers: [
provideRouter([]),
provideRouter([
{ path: 'world', children: [] },
{ path: 'hunt', children: [] },
]),
{
provide: WorldStore,
useValue: { character, currentLocation, selectedConnection },
@@ -28,9 +31,12 @@ describe('App', () => {
}).compileComponents();
});
it('renders the reusable game shell with only Karte available', () => {
it('renders the reusable game shell with Karte and Jagd available', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/world');
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('app-top-bar')).not.toBeNull();
@@ -44,7 +50,13 @@ describe('App', () => {
expect(mapButton?.getAttribute('aria-current')).toBe('page');
expect(mapButton?.getAttribute('aria-label')).toBe('Karte');
for (const destination of ['hunt', 'quests', 'inventory', 'character']) {
const huntButton = element.querySelector<HTMLButtonElement>('[data-navigation="hunt"]');
expect(huntButton).not.toBeNull();
expect(huntButton?.disabled).toBe(false);
expect(huntButton?.getAttribute('aria-current')).toBeNull();
expect(huntButton?.getAttribute('aria-label')).toBe('Jagd');
for (const destination of ['quests', 'inventory', 'character']) {
expect(
element.querySelector<HTMLButtonElement>(`[data-navigation="${destination}"]`)?.disabled,
).toBe(true);
@@ -53,6 +65,21 @@ describe('App', () => {
expect(element.textContent).not.toContain('Shop');
});
it('marks Jagd as the active navigation entry while on /hunt', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
await router.navigateByUrl('/hunt');
fixture.detectChanges();
await fixture.whenStable();
const element = fixture.nativeElement as HTMLElement;
const mapButton = element.querySelector<HTMLButtonElement>('[data-navigation="world"]');
const huntButton = element.querySelector<HTMLButtonElement>('[data-navigation="hunt"]');
expect(huntButton?.getAttribute('aria-current')).toBe('page');
expect(mapButton?.getAttribute('aria-current')).toBeNull();
});
it('renders loaded character values supplied by the WorldStore', () => {
character.set({
id: 'character-id',

View File

@@ -34,6 +34,7 @@ export interface CurrentLocationResponse {
huntingEnabled: boolean;
artworkPath: string;
connections: CurrentLocationConnection[];
possibleMonsters: string[];
}
export type CurrentTravel =
@@ -46,3 +47,24 @@ export type CurrentTravel =
arrivesAt: string;
}
| { status: 'COMPLETED'; targetLocation: LocationSummary };
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;
encounters: HuntEncounter[];
}

View File

@@ -1,7 +1,7 @@
import { HttpClient } from '@angular/common/http';
import { Injectable } from '@angular/core';
import { Observable } from 'rxjs';
import { CharacterResponse, CurrentLocationResponse, CurrentTravel } from './game-api.models';
import { CharacterResponse, CurrentLocationResponse, CurrentTravel, HuntResult } from './game-api.models';
@Injectable({ providedIn: 'root' })
export class GameApiService {
@@ -22,4 +22,8 @@ export class GameApiService {
getCurrentTravel(): Observable<CurrentTravel> {
return this.http.get<CurrentTravel>('/api/travel/current');
}
startHunt(): Observable<HuntResult> {
return this.http.post<HuntResult>('/api/hunts', {});
}
}

View File

@@ -0,0 +1,72 @@
import { Component, inject } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-combat-placeholder-page',
template: `
<section class="combat-placeholder" aria-label="Kampfvorbereitung">
<span class="combat-placeholder__eyebrow">KAMPF</span>
<h2>Vorbereitung auf den Kampf</h2>
<p>
Die Klinge ist gezogen, der Gegner steht bereit doch das eigentliche Gefecht liegt noch
vor dir. Diese Ansicht ist ein Zwischenhalt auf dem Weg in den Kampf, der in einem
späteren Schritt folgt.
</p>
@if (encounterId) {
<p class="combat-placeholder__id" data-encounter-id>
Vorbereitung auf den Kampf gegen Begegnung {{ encounterId }}…
</p>
}
</section>
`,
styles: [
`
:host {
display: block;
}
.combat-placeholder {
display: grid;
gap: var(--ar-space-3);
max-inline-size: 40rem;
margin: var(--ar-space-6) auto;
padding: var(--ar-space-5);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
text-align: center;
}
.combat-placeholder__eyebrow {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.14em;
text-transform: uppercase;
}
.combat-placeholder h2 {
margin: 0;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
font-weight: 400;
}
.combat-placeholder p {
margin: 0;
color: var(--ar-text-muted);
line-height: 1.55;
}
.combat-placeholder__id {
color: var(--ar-text-muted);
font-size: var(--ar-font-sm);
font-style: italic;
}
`,
],
})
export class CombatPlaceholderPageComponent {
private readonly route = inject(ActivatedRoute);
protected readonly encounterId = this.route.snapshot.queryParamMap.get('encounterId');
}

View File

@@ -0,0 +1,22 @@
<article class="encounter-card">
<div class="encounter-card__artwork-frame">
<picture>
@if (runtimeArtworkPath(encounter.monster.artworkPath); as runtimeArtwork) {
<source [srcset]="runtimeArtwork" type="image/jpeg" />
}
<img
class="encounter-card__artwork"
[src]="encounter.monster.artworkPath"
[alt]="encounter.monster.name"
loading="lazy"
decoding="async"
/>
</picture>
</div>
<div class="encounter-card__body">
<h3 class="encounter-card__name">{{ encounter.monster.name }}</h3>
<span class="encounter-card__level">Stufe {{ encounter.monster.level }}</span>
<app-danger-badge class="encounter-card__danger" [rating]="encounter.dangerRating" />
<button type="button" class="encounter-card__attack" (click)="onAttack()">Angreifen</button>
</div>
</article>

View File

@@ -0,0 +1,108 @@
:host {
display: block;
}
.encounter-card {
display: grid;
overflow: hidden;
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-md);
background:
linear-gradient(125deg, rgb(255 255 255 / 0.045), transparent 42%), rgb(12 15 17 / 0.96);
box-shadow: var(--ar-shadow-raised);
}
.encounter-card__artwork-frame {
position: relative;
overflow: hidden;
aspect-ratio: 4 / 3;
border-block-end: 1px solid rgb(155 122 66 / 0.45);
background: var(--ar-bg);
}
.encounter-card__artwork-frame::after {
content: '';
position: absolute;
inset: 0;
box-shadow: inset 0 -3.5rem 3rem -1.5rem rgb(0 0 0 / 0.75);
pointer-events: none;
}
.encounter-card__artwork {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
object-position: center;
}
.encounter-card__body {
display: grid;
justify-items: center;
gap: var(--ar-space-2);
padding: var(--ar-space-4);
text-align: center;
}
.encounter-card__name {
margin: 0;
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.4rem;
font-weight: 400;
}
.encounter-card__level {
color: var(--ar-gold);
font-size: var(--ar-font-sm);
letter-spacing: 0.06em;
text-transform: uppercase;
}
.encounter-card__danger {
margin-block: var(--ar-space-1);
}
.encounter-card__attack {
inline-size: 100%;
margin-block-start: var(--ar-space-2);
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
letter-spacing: 0.02em;
}
.encounter-card__attack:hover {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.encounter-card__attack:focus-visible {
outline: 2px solid var(--ar-blue);
outline-offset: 2px;
}
@media (prefers-reduced-motion: no-preference) {
.encounter-card {
transition:
border-color var(--ar-motion-base),
box-shadow var(--ar-motion-base);
}
.encounter-card:hover {
border-color: #d6b26b;
box-shadow:
var(--ar-shadow-raised),
0 0 1.1rem rgb(214 178 107 / 0.35);
}
.encounter-card__attack {
transition:
border-color var(--ar-motion-fast),
background var(--ar-motion-fast);
}
}

View File

@@ -0,0 +1,58 @@
import { TestBed } from '@angular/core/testing';
import { vi } from 'vitest';
import type { HuntEncounter } from '../../../core/api/game-api.models';
import { EncounterCardComponent } from './encounter-card.component';
const dawnwolfEncounter: HuntEncounter = {
id: 'encounter-id-9f2c',
monster: {
key: 'dawnwolf',
name: 'Dämmerwolf',
level: 3,
artworkPath: '/images/enemies/Dawnwolf.png',
},
dangerRating: 'MATCH',
};
describe('EncounterCardComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EncounterCardComponent],
}).compileComponents();
});
it('renders the monster name, level, danger label, and artwork', () => {
const fixture = TestBed.createComponent(EncounterCardComponent);
fixture.componentRef.setInput('encounter', dawnwolfEncounter);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
const img = element.querySelector('img');
expect(element.textContent).toContain('Dämmerwolf');
expect(element.textContent).toContain('Stufe 3');
expect(element.textContent).toContain('Passend');
expect(img?.getAttribute('src')).toBe('/images/enemies/Dawnwolf.png');
expect(img?.getAttribute('alt')).toBe('Dämmerwolf');
});
it('emits the encounter id (never the monster key) when Angreifen is clicked', () => {
const fixture = TestBed.createComponent(EncounterCardComponent);
fixture.componentRef.setInput('encounter', dawnwolfEncounter);
fixture.detectChanges();
const emitted = vi.fn();
fixture.componentInstance.attack.subscribe(emitted);
const button = Array.from(fixture.nativeElement.querySelectorAll('button')).find(
(candidate): candidate is HTMLButtonElement =>
candidate instanceof HTMLButtonElement && candidate.textContent?.trim() === 'Angreifen',
);
button?.click();
expect(emitted).toHaveBeenCalledOnce();
expect(emitted).toHaveBeenCalledWith(dawnwolfEncounter.id);
expect(emitted).not.toHaveBeenCalledWith(dawnwolfEncounter.monster.key);
expect(dawnwolfEncounter.id).not.toBe(dawnwolfEncounter.monster.key);
});
});

View File

@@ -0,0 +1,27 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { HuntEncounter } from '../../../core/api/game-api.models';
import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
const runtimeArtworkPaths: Readonly<Record<string, string>> = {
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
};
@Component({
selector: 'app-encounter-card',
imports: [DangerBadgeComponent],
templateUrl: './encounter-card.component.html',
styleUrl: './encounter-card.component.scss',
})
export class EncounterCardComponent {
@Input({ required: true }) encounter!: HuntEncounter;
@Output() readonly attack = new EventEmitter<string>();
protected onAttack(): void {
this.attack.emit(this.encounter.id);
}
protected runtimeArtworkPath(artworkPath: string): string | undefined {
return runtimeArtworkPaths[artworkPath];
}
}

View File

@@ -0,0 +1,67 @@
<section class="hunt-page" aria-label="Jagd">
@if (worldStore.currentLocation(); as location) {
@if (!location.huntingEnabled) {
<section class="hunt-page__unavailable">
<h2>Keine Jagd verfügbar</h2>
<p>
Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
Gebiet, um nach Gegnern zu suchen.
</p>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
</section>
} @else if (huntingStore.currentHunt(); as hunt) {
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
<p class="hunt-page__results-heading">{{ location.name }} — Begegnungen</p>
<div class="hunt-page__encounters">
@for (encounter of huntingStore.encounters(); track encounter.id) {
<app-encounter-card [encounter]="encounter" (attack)="onAttack($event)" />
}
</div>
<div class="hunt-page__actions">
<button
type="button"
data-hunt-refresh
[disabled]="huntingStore.loading()"
(click)="refreshHunt()"
>
Neu suchen
</button>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
</div>
</section>
} @else {
<section class="hunt-page__ready">
<h2>{{ location.name }}</h2>
<p>{{ location.description }}</p>
<p class="hunt-page__hint">
Durchsuche die Umgebung nach Spuren und Gegnern, bevor du dich in den Kampf wagst.
</p>
<button
type="button"
data-hunt-start
[disabled]="huntingStore.loading()"
(click)="startHunt()"
>
Jagd beginnen
</button>
</section>
}
@if (huntingStore.loading()) {
<p class="hunt-page__loading" role="status">Du suchst nach Spuren...</p>
}
} @else {
<section class="hunt-page__empty" aria-live="polite">
<p>Jagdgebiet wird vorbereitet.</p>
</section>
}
@if (huntingStore.error(); as error) {
<section class="hunt-page__error" role="alert">
<p>{{ error }}</p>
<button type="button" data-hunt-retry (click)="retry()">Erneut versuchen</button>
</section>
}
</section>

View File

@@ -0,0 +1,127 @@
:host {
display: block;
min-block-size: 100%;
}
.hunt-page {
position: relative;
min-block-size: 100%;
}
.hunt-page__unavailable,
.hunt-page__ready,
.hunt-page__results {
padding: var(--ar-space-5);
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.hunt-page__unavailable h2,
.hunt-page__ready h2 {
margin: 0 0 var(--ar-space-3);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.6rem;
font-weight: 400;
}
.hunt-page__unavailable p,
.hunt-page__ready p {
margin: 0 0 var(--ar-space-4);
color: var(--ar-text-muted);
line-height: 1.55;
}
.hunt-page__hint {
font-style: italic;
}
.hunt-page__results-heading {
margin: 0 0 var(--ar-space-4);
color: var(--ar-gold);
font-family: Georgia, 'Times New Roman', serif;
font-size: 1.3rem;
}
.hunt-page__encounters {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: var(--ar-space-4);
margin-block-end: var(--ar-space-5);
}
.hunt-page__actions {
display: flex;
flex-wrap: wrap;
gap: var(--ar-space-3);
}
.hunt-page__unavailable button,
.hunt-page__ready button,
.hunt-page__actions button {
padding: var(--ar-space-2) var(--ar-space-4);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: linear-gradient(180deg, #263b4b, #17232d);
cursor: pointer;
font-family: Georgia, 'Times New Roman', serif;
font-size: 1rem;
}
.hunt-page__unavailable button:hover,
.hunt-page__ready button:not(:disabled):hover,
.hunt-page__actions button:not(:disabled):hover {
border-color: #d6b26b;
background: linear-gradient(180deg, #315067, #1a2c3a);
}
.hunt-page__ready button:disabled,
.hunt-page__actions button:disabled {
cursor: not-allowed;
opacity: 0.52;
}
.hunt-page__loading,
.hunt-page__error,
.hunt-page__empty {
margin: var(--ar-space-4) 0 0;
border: 1px solid var(--ar-border);
background: var(--ar-panel);
box-shadow: var(--ar-shadow-raised);
}
.hunt-page__loading,
.hunt-page__empty {
padding: var(--ar-space-4);
color: var(--ar-text-muted);
}
.hunt-page__error {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--ar-space-4);
padding: var(--ar-space-3) var(--ar-space-4);
border-color: var(--ar-danger);
}
.hunt-page__error p {
margin: 0;
}
.hunt-page__error button {
flex: 0 0 auto;
padding: var(--ar-space-2) var(--ar-space-3);
border: 1px solid var(--ar-border-highlight);
border-radius: var(--ar-radius-sm);
color: var(--ar-text);
background: #1a2023;
cursor: pointer;
}
@media (width < 720px) {
.hunt-page__encounters {
grid-template-columns: 1fr;
}
}

View File

@@ -0,0 +1,235 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const threeEncounterHunt: HuntResult = {
id: 'hunt-id',
location: { id: 'burned-road-id', key: 'burned-road', name: 'Verbrannte Straße' },
encounters: [
{
id: 'encounter-1',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
dangerRating: 'WEAK',
},
{
id: 'encounter-2',
monster: {
key: 'road-bandit',
name: 'Straßenräuber',
level: 3,
artworkPath: '/images/enemies/RoadBandit.png',
},
dangerRating: 'MATCH',
},
{
id: 'encounter-3',
monster: {
key: 'ash-rat',
name: 'Aschenratte',
level: 1,
artworkPath: '/images/enemies/AshRat.png',
},
dangerRating: 'WEAK',
},
],
};
describe('HuntPageComponent', () => {
let worldStore: {
currentLocation: ReturnType<typeof signal<CurrentLocationResponse | null>>;
load: ReturnType<typeof vi.fn>;
};
let huntingStore: {
currentHunt: ReturnType<typeof signal<HuntResult | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
encounters: () => HuntResult['encounters'];
startHunt: ReturnType<typeof vi.fn>;
refreshHunt: ReturnType<typeof vi.fn>;
selectEncounter: ReturnType<typeof vi.fn>;
};
let router: Router;
async function setup(location: CurrentLocationResponse | null, hunt: HuntResult | null = null) {
worldStore = { currentLocation: signal(location), load: vi.fn(() => Promise.resolve()) };
const currentHunt = signal(hunt);
huntingStore = {
currentHunt,
loading: signal(false),
error: signal<string | null>(null),
encounters: () => currentHunt()?.encounters ?? [],
startHunt: vi.fn(() => Promise.resolve()),
refreshHunt: vi.fn(() => Promise.resolve()),
selectEncounter: vi.fn(),
};
await TestBed.configureTestingModule({
imports: [HuntPageComponent],
providers: [
provideRouter([]),
{ provide: WorldStore, useValue: worldStore },
{ provide: HuntingStore, useValue: huntingStore },
],
}).compileComponents();
router = TestBed.inject(Router);
vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(HuntPageComponent);
fixture.detectChanges();
return fixture;
}
it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
const fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Keine Jagd verfügbar');
expect(element.textContent).toContain(
'Am Südtor von Graufurt gibt es keine regulären Jagdgebiete.',
);
expect(
Array.from(element.querySelectorAll('button')).some(
(button) => button.textContent?.trim() === 'Jagd beginnen',
),
).toBe(false);
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
toWorldButton?.click();
expect(router.navigate).toHaveBeenCalledWith(['/world']);
});
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
const fixture = await setup(burnedRoad);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
it('renders 3 encounter cards, duplicates included, with the correct data', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const cards = element.querySelectorAll('app-encounter-card');
expect(cards.length).toBe(3);
expect(element.textContent).toMatch(/Aschenratte[\s\S]*Straßenräuber[\s\S]*Aschenratte/);
expect(element.querySelectorAll('img[src="/images/enemies/AshRat.png"]').length).toBe(2);
expect(element.querySelectorAll('img[src="/images/enemies/RoadBandit.png"]').length).toBe(1);
expect(element.textContent).toContain('Stufe 1');
expect(element.textContent).toContain('Stufe 3');
});
it('calls refreshHunt when Neu suchen is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
element.querySelector<HTMLButtonElement>('[data-hunt-refresh]')?.click();
expect(huntingStore.refreshHunt).toHaveBeenCalledOnce();
});
it('navigates to /combat/new with the encounter id (not the monster key) when Angreifen is clicked', async () => {
const fixture = await setup(burnedRoad, threeEncounterHunt);
const element = fixture.nativeElement as HTMLElement;
const attackButtons = Array.from(element.querySelectorAll('button')).filter(
(button) => button.textContent?.trim() === 'Angreifen',
);
expect(attackButtons.length).toBe(3);
attackButtons[1].click();
expect(huntingStore.selectEncounter).toHaveBeenCalledWith('encounter-2');
expect(router.navigate).toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'encounter-2' },
});
expect(router.navigate).not.toHaveBeenCalledWith(['/combat/new'], {
queryParams: { encounterId: 'road-bandit' },
});
});
it('does not trigger a hunt automatically on page entry', async () => {
await setup(burnedRoad);
expect(huntingStore.startHunt).not.toHaveBeenCalled();
});
it('loads the world state on init when no location has been loaded yet (direct navigation/hard refresh)', async () => {
await setup(null);
expect(worldStore.load).toHaveBeenCalledOnce();
});
it('does not call load again when a location is already present', async () => {
await setup(burnedRoad);
expect(worldStore.load).not.toHaveBeenCalled();
});
it('shows a loading state and disables the triggering action', async () => {
const fixture = await setup(burnedRoad);
huntingStore.loading.set(true);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Du suchst nach Spuren...');
expect(element.querySelector<HTMLButtonElement>('[data-hunt-start]')?.disabled).toBe(true);
});
it('displays a hunting error and retries via startHunt when there is no current hunt', async () => {
const fixture = await setup(burnedRoad);
huntingStore.error.set('An diesem Ort gibt es keine Jagdgebiete.');
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.querySelector('[role="alert"]')?.textContent).toContain(
'An diesem Ort gibt es keine Jagdgebiete.',
);
element.querySelector<HTMLButtonElement>('[data-hunt-retry]')?.click();
expect(huntingStore.startHunt).toHaveBeenCalledOnce();
});
});

View File

@@ -0,0 +1,48 @@
import { Component, OnInit, inject } from '@angular/core';
import { Router } from '@angular/router';
import { EncounterCardComponent } from '../encounter-card/encounter-card.component';
import { HuntingStore } from '../hunting.store';
import { WorldStore } from '../../world/world.store';
@Component({
selector: 'app-hunt-page',
imports: [EncounterCardComponent],
templateUrl: './hunt-page.component.html',
styleUrl: './hunt-page.component.scss',
})
export class HuntPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore);
protected readonly huntingStore = inject(HuntingStore);
private readonly router = inject(Router);
ngOnInit(): void {
if (this.worldStore.currentLocation() === null) {
void this.worldStore.load();
}
}
protected startHunt(): void {
void this.huntingStore.startHunt();
}
protected refreshHunt(): void {
void this.huntingStore.refreshHunt();
}
protected retry(): void {
if (this.huntingStore.currentHunt() === null) {
void this.huntingStore.startHunt();
} else {
void this.huntingStore.refreshHunt();
}
}
protected goToWorld(): void {
void this.router.navigate(['/world']);
}
protected onAttack(encounterId: string): void {
this.huntingStore.selectEncounter(encounterId);
void this.router.navigate(['/combat/new'], { queryParams: { encounterId } });
}
}

View File

@@ -0,0 +1,209 @@
import { HttpErrorResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { of, throwError } from 'rxjs';
import { vi } from 'vitest';
import type { HuntResult } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
import { HuntingStore } from './hunting.store';
const huntResult: HuntResult = {
id: 'hunt-id',
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
encounters: [
{
id: 'encounter-1',
monster: { key: 'wolf', name: 'Wolf', level: 1, artworkPath: '/images/enemies/Wolf.png' },
dangerRating: 'MATCH',
},
{
id: 'encounter-2',
monster: { key: 'bear', name: 'Bär', level: 3, artworkPath: '/images/enemies/Bear.png' },
dangerRating: 'STRONG',
},
],
};
const refreshedHuntResult: HuntResult = {
id: 'hunt-id-2',
location: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
encounters: [
{
id: 'encounter-3',
monster: { key: 'rat', name: 'Ratte', level: 1, artworkPath: '/images/enemies/Rat.png' },
dangerRating: 'WEAK',
},
],
};
describe('HuntingStore', () => {
let api: {
startHunt: ReturnType<typeof vi.fn>;
};
let store: HuntingStore;
beforeEach(() => {
api = {
startHunt: vi.fn(() => of(huntResult)),
};
TestBed.configureTestingModule({
providers: [HuntingStore, { provide: GameApiService, useValue: api }],
});
store = TestBed.inject(HuntingStore);
});
it('starts a hunt and populates currentHunt and encounters', async () => {
await store.startHunt();
expect(api.startHunt).toHaveBeenCalledOnce();
expect(store.currentHunt()).toEqual(huntResult);
expect(store.encounters()).toEqual(huntResult.encounters);
expect(store.loading()).toBe(false);
expect(store.error()).toBeNull();
});
it('clears any previously selected encounter when starting a new hunt', async () => {
await store.startHunt();
store.selectEncounter('encounter-1');
expect(store.selectedEncounterId()).toBe('encounter-1');
await store.startHunt();
expect(store.selectedEncounterId()).toBeNull();
});
it('exposes an empty encounters array before any hunt has started', () => {
expect(store.currentHunt()).toBeNull();
expect(store.encounters()).toEqual([]);
});
it('sets loading while the request is in flight and clears it afterwards', async () => {
expect(store.loading()).toBe(false);
const promise = store.startHunt();
expect(store.loading()).toBe(true);
await promise;
expect(store.loading()).toBe(false);
});
it('clears loading even when the API call throws', async () => {
api.startHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.startHunt();
expect(store.loading()).toBe(false);
});
it('selects an encounter without making a network call', () => {
store.selectEncounter('encounter-2');
expect(store.selectedEncounterId()).toBe('encounter-2');
expect(api.startHunt).not.toHaveBeenCalled();
});
it('refreshHunt issues another API call and replaces currentHunt', async () => {
await store.startHunt();
expect(store.currentHunt()).toEqual(huntResult);
api.startHunt.mockReturnValue(of(refreshedHuntResult));
await store.refreshHunt();
expect(api.startHunt).toHaveBeenCalledTimes(2);
expect(store.currentHunt()).toEqual(refreshedHuntResult);
expect(store.encounters()).toEqual(refreshedHuntResult.encounters);
});
it('maps CHARACTER_NOT_FOUND to its German message', async () => {
api.startHunt.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 404,
error: { statusCode: 404, code: 'CHARACTER_NOT_FOUND', message: 'Character not found.' },
}),
),
);
await store.startHunt();
expect(store.error()).toBe('Dein Charakter konnte nicht gefunden werden.');
});
it('maps CHARACTER_TRAVELLING to its German message', async () => {
api.startHunt.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 409,
error: { statusCode: 409, code: 'CHARACTER_TRAVELLING', message: 'Character is travelling.' },
}),
),
);
await store.startHunt();
expect(store.error()).toBe('Du kannst nicht jagen, während du unterwegs bist.');
});
it('maps HUNTING_NOT_AVAILABLE to its German message', async () => {
api.startHunt.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 400,
error: { statusCode: 400, code: 'HUNTING_NOT_AVAILABLE', message: 'Hunting not available.' },
}),
),
);
await store.startHunt();
expect(store.error()).toBe('An diesem Ort gibt es keine Jagdgebiete.');
});
it('maps NO_HUNT_ENCOUNTERS_AVAILABLE to its German message', async () => {
api.startHunt.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 404,
error: {
statusCode: 404,
code: 'NO_HUNT_ENCOUNTERS_AVAILABLE',
message: 'No encounters available.',
},
}),
),
);
await store.startHunt();
expect(store.error()).toBe('Aktuell sind hier keine Gegner zu finden.');
});
it('falls back to the generic message for an HttpErrorResponse with no known code', async () => {
api.startHunt.mockReturnValue(
throwError(
() =>
new HttpErrorResponse({
status: 500,
error: { message: 'Internal server error' },
}),
),
);
await store.startHunt();
expect(store.error()).toBe('Weltzustand konnte nicht geladen werden.');
});
it('uses the message of a genuine non-HTTP Error', async () => {
api.startHunt.mockReturnValue(throwError(() => new Error('Netzwerkfehler')));
await store.startHunt();
expect(store.error()).toBe('Netzwerkfehler');
});
});

View File

@@ -0,0 +1,65 @@
import { HttpErrorResponse } from '@angular/common/http';
import { Injectable, computed, signal } from '@angular/core';
import { firstValueFrom } from 'rxjs';
import { HuntResult } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
const GENERIC_ERROR_MESSAGE = 'Weltzustand konnte nicht geladen werden.';
// Mirrors the hunt error codes returned by `POST /api/hunts`.
// Unknown/missing codes fall back to `GENERIC_ERROR_MESSAGE`.
const HUNT_ERROR_MESSAGES: Readonly<Record<string, string>> = {
CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
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.',
};
@Injectable({ providedIn: 'root' })
export class HuntingStore {
private readonly currentHuntState = signal<HuntResult | null>(null);
private readonly selectedEncounterIdState = signal<string | null>(null);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
readonly currentHunt = this.currentHuntState.asReadonly();
readonly selectedEncounterId = this.selectedEncounterIdState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly();
readonly encounters = computed(() => this.currentHuntState()?.encounters ?? []);
constructor(private readonly api: GameApiService) {}
async startHunt(): Promise<void> {
this.loadingState.set(true);
this.errorState.set(null);
try {
const hunt = await firstValueFrom(this.api.startHunt());
this.currentHuntState.set(hunt);
this.selectedEncounterIdState.set(null);
} catch (error) {
this.errorState.set(this.toErrorMessage(error));
} finally {
this.loadingState.set(false);
}
}
async refreshHunt(): Promise<void> {
await this.startHunt();
}
selectEncounter(encounterId: string): void {
this.selectedEncounterIdState.set(encounterId);
}
private toErrorMessage(error: unknown): string {
if (error instanceof HttpErrorResponse) {
const code = (error.error as { code?: string } | null)?.code;
return (code && HUNT_ERROR_MESSAGES[code]) || GENERIC_ERROR_MESSAGE;
}
return error instanceof Error ? error.message : GENERIC_ERROR_MESSAGE;
}
}

View File

@@ -32,6 +32,7 @@ const southGate: CurrentLocationResponse = {
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [burnedRoadConnection],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = {
@@ -43,6 +44,7 @@ const burnedRoad: CurrentLocationResponse = {
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['goblin', 'skeleton'],
connections: [
{
targetLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },

View File

@@ -33,6 +33,7 @@ const currentLocation: CurrentLocationResponse = {
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
possibleMonsters: [],
connections: [
{
targetLocation: {

View File

@@ -30,6 +30,12 @@
<dt>Jagd</dt>
<dd>{{ location.huntingEnabled ? 'Jagd möglich' : 'Keine Jagd' }}</dd>
</div>
@if (location.huntingEnabled && location.possibleMonsters.length) {
<div>
<dt>Mögliche Begegnungen</dt>
<dd>{{ location.possibleMonsters.join(', ') }}</dd>
</div>
}
</dl>
} @else {
<span class="context-panel__eyebrow">GEBIETSINFO</span>

View File

@@ -3,15 +3,7 @@ import { TestBed } from '@angular/core/testing';
import { WorldStore } from '../../features/world/world.store';
import { ContextPanelComponent } from './context-panel.component';
describe('ContextPanelComponent', () => {
it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => {
await TestBed.configureTestingModule({
imports: [ContextPanelComponent],
providers: [
{
provide: WorldStore,
useValue: {
currentLocation: signal({
const southGate = {
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
@@ -24,7 +16,29 @@ describe('ContextPanelComponent', () => {
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
}),
possibleMonsters: [],
};
const burnedRoad = {
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
};
describe('ContextPanelComponent', () => {
it('uses a runtime location derivative while preserving the API artwork path as image fallback', async () => {
await TestBed.configureTestingModule({
imports: [ContextPanelComponent],
providers: [
{
provide: WorldStore,
useValue: {
currentLocation: signal(southGate),
selectedConnection: signal(null),
},
},
@@ -42,4 +56,48 @@ describe('ContextPanelComponent', () => {
'/images/backgrounds/Suedtor.png',
);
});
it('hides "Mögliche Begegnungen" when the location has no hunting', async () => {
await TestBed.configureTestingModule({
imports: [ContextPanelComponent],
providers: [
{
provide: WorldStore,
useValue: {
currentLocation: signal(southGate),
selectedConnection: signal(null),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ContextPanelComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).not.toContain('Mögliche Begegnungen');
});
it('lists possible monsters as plain text when hunting is enabled', async () => {
await TestBed.configureTestingModule({
imports: [ContextPanelComponent],
providers: [
{
provide: WorldStore,
useValue: {
currentLocation: signal(burnedRoad),
selectedConnection: signal(null),
},
},
],
}).compileComponents();
const fixture = TestBed.createComponent(ContextPanelComponent);
fixture.detectChanges();
const element = fixture.nativeElement as HTMLElement;
expect(element.textContent).toContain('Mögliche Begegnungen');
expect(element.textContent).toContain('Aschenratte, Straßenräuber');
expect(element.textContent).not.toMatch(/\d+\s?%/);
});
});

View File

@@ -1,10 +1,12 @@
<nav class="side-navigation" aria-label="Spielnavigation">
<button
class="side-navigation__item side-navigation__item--active"
class="side-navigation__item"
type="button"
routerLink="/world"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="world"
aria-current="page"
aria-label="Karte"
>
<img src="/images/hud/runtime/MapsIcon-128.png" alt="" />
@@ -14,9 +16,12 @@
<button
class="side-navigation__item"
type="button"
routerLink="/hunt"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="hunt"
disabled
aria-label="Jagd ist noch nicht verfügbar"
aria-label="Jagd"
>
<img src="/images/hud/runtime/HuntIcon-128.png" alt="" />
<span>Jagd</span>

View File

@@ -1,9 +1,9 @@
import { Component } from '@angular/core';
import { RouterLink } from '@angular/router';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-side-navigation',
imports: [RouterLink],
imports: [RouterLink, RouterLinkActive],
templateUrl: './side-navigation.component.html',
styleUrl: './side-navigation.component.scss',
})

View File

@@ -0,0 +1,9 @@
<span
class="danger-badge"
[class.danger-badge--weak]="rating === 'WEAK'"
[class.danger-badge--match]="rating === 'MATCH'"
[class.danger-badge--strong]="rating === 'STRONG'"
[class.danger-badge--very-dangerous]="rating === 'VERY_DANGEROUS'"
[class.danger-badge--deadly]="rating === 'DEADLY'"
>{{ label }}</span
>

View File

@@ -0,0 +1,44 @@
:host {
display: inline-block;
}
.danger-badge {
display: inline-flex;
align-items: center;
padding: var(--ar-space-1) var(--ar-space-2);
border: 1px solid var(--ar-border);
border-radius: var(--ar-radius-sm);
background: var(--ar-panel-muted);
color: var(--ar-text);
font-family: Georgia, 'Times New Roman', serif;
font-size: var(--ar-font-sm);
letter-spacing: 0.02em;
}
.danger-badge--weak {
color: var(--ar-success);
border-color: var(--ar-success);
}
.danger-badge--match {
color: var(--ar-success);
border-color: var(--ar-success);
font-weight: 700;
}
.danger-badge--strong {
color: var(--ar-warning);
border-color: var(--ar-warning);
}
.danger-badge--very-dangerous {
color: var(--ar-danger);
border-color: var(--ar-danger);
}
.danger-badge--deadly {
color: var(--ar-danger);
border-color: var(--ar-danger);
font-weight: 700;
background: color-mix(in srgb, var(--ar-danger) 18%, var(--ar-panel-muted));
}

View File

@@ -0,0 +1,30 @@
import { TestBed } from '@angular/core/testing';
import type { DangerRating } from '../../core/api/game-api.models';
import { DangerBadgeComponent } from './danger-badge.component';
const expectations: Array<{ rating: DangerRating; label: string; modifierClass: string }> = [
{ rating: 'WEAK', label: 'Schwach', modifierClass: 'danger-badge--weak' },
{ rating: 'MATCH', label: 'Passend', modifierClass: 'danger-badge--match' },
{ rating: 'STRONG', label: 'Stark', modifierClass: 'danger-badge--strong' },
{
rating: 'VERY_DANGEROUS',
label: 'Sehr gefährlich',
modifierClass: 'danger-badge--very-dangerous',
},
{ rating: 'DEADLY', label: 'Tödlich', modifierClass: 'danger-badge--deadly' },
];
describe('DangerBadgeComponent', () => {
for (const { rating, label, modifierClass } of expectations) {
it(`renders the German label "${label}" and modifier class for ${rating}`, () => {
const fixture = TestBed.createComponent(DangerBadgeComponent);
fixture.componentRef.setInput('rating', rating);
fixture.detectChanges();
const badge = fixture.nativeElement.querySelector('.danger-badge') as HTMLElement;
expect(badge.textContent?.trim()).toBe(label);
expect(badge.classList.contains(modifierClass)).toBe(true);
});
}
});

View File

@@ -0,0 +1,23 @@
import { Component, Input } from '@angular/core';
import { DangerRating } from '../../core/api/game-api.models';
const DANGER_LABELS: Record<DangerRating, string> = {
WEAK: 'Schwach',
MATCH: 'Passend',
STRONG: 'Stark',
VERY_DANGEROUS: 'Sehr gefährlich',
DEADLY: 'Tödlich',
};
@Component({
selector: 'app-danger-badge',
templateUrl: './danger-badge.component.html',
styleUrl: './danger-badge.component.scss',
})
export class DangerBadgeComponent {
@Input({ required: true }) rating!: DangerRating;
protected get label(): string {
return DANGER_LABELS[this.rating];
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 400 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 324 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 321 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 319 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 353 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 340 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 KiB

View File

@@ -0,0 +1,283 @@
# Ashen Realms Playable Slice 0.11: Grenzmarken & First Merchant
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.10 First NPC & Quest
**Scope:** First regional currency and targeted merchant progression
**Currency:** Grenzmarken
**Next Slice:** Playable Slice 0.12 Aschenfelder Complete
## 1. Goal
Implement the first anti-frustration progression system.
The core philosophy is:
> Drops create excitement. Regional currency prevents frustration.
The player can earn Grenzmarken through normal progression and use them to buy targeted Tier-1 upgrades.
## 2. Grenzmarken
Implement persistent:
```text
Grenzmarken
```
Sources include existing configured rewards such as:
```text
Quests
rare enemies
stronger enemies
Elite
Boss
```
Use the current balancing/content definitions as the source of truth.
## 3. Currency model
At this stage a reusable currency model may become worthwhile.
Conceptually:
```text
CurrencyDefinition
CharacterCurrency
```
Example stable keys:
```text
silver
border-marks
```
If Silver is already safely represented directly on Character, do not force a risky large migration solely for architectural purity.
The important requirement is that Grenzmarken are persistent and server-authoritative.
## 4. Merchant
Introduce the first regional merchant at the appropriate existing NPC/location.
Use existing project content if a merchant NPC/location has already been defined.
Minimal merchant data:
```text
ShopDefinition
ShopOffer
```
A ShopOffer should determine:
```text
item
currency
price
availability
```
## 5. Grenzmarken offers
Use the established prices:
| Item | Price |
|---|---:|
| Räuberhaube | 5 Grenzmarken |
| Plündererhandschuhe | 6 Grenzmarken |
| Wachmannsbeinkleid | 8 Grenzmarken |
| Verstärkte Lederjacke | 10 Grenzmarken |
| Aschenklinge | 15 Grenzmarken |
Do not silently rebalance these prices in this implementation slice.
## 6. Shop API
Use the existing REST conventions.
Conceptually:
```http
GET /api/shops/:shopId
POST /api/shops/:shopId/purchases
```
Purchase request:
```json
{
"offerId": "uuid"
}
```
Do not accept authoritative client values such as:
```text
price
itemId
currency amount
discount
```
The server derives them from the persisted offer.
## 7. Purchase transaction
A purchase must be atomic:
```text
load character currency
load and validate offer
validate balance
subtract Grenzmarken
create CharacterItem
commit
```
If the transaction fails, neither the currency deduction nor the item grant should remain partially applied.
## 8. Ownership and duplicate items
Purchased equipment becomes normal persistent CharacterItem data.
Duplicates are allowed.
Do not implement:
```text
salvaging
buyback
duplicate conversion
pity conversion
```
## 9. Merchant UI
Show:
- merchant/NPC presentation
- current Grenzmarken balance
- available offers
- item icon
- item name
- item stats
- price
- affordability
- purchase action
Reuse the item display and comparison language from Inventory where possible.
## 10. Purchase feedback
After purchase:
```text
Grenzmarken balance decreases
item appears in inventory
player may open inventory
player may equip item
```
Do not automatically equip purchased equipment.
## 11. Progression proof
The important flow is:
```text
desired drop does not appear
player keeps playing
Grenzmarken accumulate
merchant provides targeted upgrade
```
The player should always feel that unlucky drops still produce progress.
## 12. Server authority
The server decides:
- currency balance
- reward grants
- offer availability
- offer price
- purchase validity
- resulting item ownership
Angular only requests purchase of a valid server-provided `offerId`.
## 13. Explicit non-goals
Do not implement:
```text
selling
buyback
dynamic prices
limited stock
shop refresh timers
player trading
auction house
discount systems
reputation
crafting vendor
```
## 14. Required tests
Verify:
- Grenzmarken rewards persist
- current balance is server-authoritative
- shop returns persisted offers
- insufficient balance rejects purchase
- valid purchase deducts exact configured cost
- valid purchase grants the correct CharacterItem
- purchase is transactional
- repeated request cannot accidentally duplicate a single transaction through race conditions
- item survives refresh
- purchased item can be equipped using existing equipment flow
## 15. Definition of Done
The player can:
```text
earn Grenzmarken
see balance
open merchant
inspect offers
buy targeted Tier-1 item
item enters inventory
equip item
```
Random loot and guaranteed long-term progression are now connected.
## 16. Handoff
Next:
```text
Playable Slice 0.12 Aschenfelder Complete
```

View File

@@ -0,0 +1,490 @@
# Ashen Realms Playable Slice 0.12: Aschenfelder Complete
**Status:** Final integration slice for the first region
**Prerequisite:** Playable Slices 0.10.11
**Scope:** Integration, balancing, content completion and polish for the Aschenfelder
**Primary Goal:** Complete the first genuinely playable regional progression loop.
## 1. Goal
Playable Slice 0.12 does not introduce a large new system.
It integrates, verifies, balances, and polishes everything built so far into one coherent first region.
The full intended flow is:
```text
Graufurt
Südtor
Verbrannte Straße
first hunts
first combat
first loot
first upgrades
Verlassener Wachtposten
NPC / Quest
stronger enemies
first Elite
Grenzmarken
targeted upgrades
Aschengrube
regional Boss
guaranteed boss loot
path toward Dämmerwald discovered
```
## 2. Complete locations
The Aschenfelder progression contains:
```text
Südtor von Graufurt
Verbrannte Straße
Verlassener Wachtposten
Aschengrube
```
Each location must have a clear purpose and correct travel connections.
## 3. Complete Tier-1 enemy set
The relevant first-region pool should now include the established enemies:
```text
Aschenratte
Verwilderter Straßenhund
Straßenräuber
Plünderer-Späher
Plünderer-Veteran
Verkohlter Plünderer
Aschenwühler
Verbrannter Jagdhund
Plündererhauptmann / Elite
Hauptmann der Aschenbande / Boss
```
If final naming differs in existing project content, preserve the existing finalized names rather than creating duplicates.
## 4. Complete Tier-1 item set
The relevant Tier-1 pool should now include:
```text
Abgenutztes Kurzschwert
Räuberklinge
Aschenklinge
Räuberhaube
Verstärkte Lederjacke
Plündererhandschuhe
Wachmannsbeinkleid
Aschenstiefel
Zeichen der Grenzwacht
Anhänger des verbrannten Hauptmanns
```
Not every item must be required for progression.
Prestige/special drops remain optional.
## 5. Combat duration targets
During the balancing pass, verify:
### Normal enemies
```text
48 rounds
```
### Elite
```text
610 rounds
```
### Boss
```text
814 rounds
```
These are balancing targets rather than hard rules.
## 6. Power progression targets
Use the established Combat Power progression as a reference:
### Start
```text
approximately 47 CP
```
### After first upgrades
```text
approximately 6070 CP
```
### Wachtposten progression
```text
approximately 8090 CP
```
### Aschengrube / boss-ready range
```text
approximately 100110 CP
```
Combat Power is an internal balancing tool, not a hard access gate.
## 7. Progression pacing
A typical player should need roughly:
```text
1218 normal combats
+
quests
+
boss
```
to progress meaningfully through the first region.
The intended experience must not require 50+ normal combats for basic progression.
## 8. First upgrade pacing
Verify that within approximately the first:
```text
35 combats
```
the player is very likely to see a meaningful equipment upgrade.
If this does not happen reliably enough, tune only the smallest necessary variables:
```text
drop rate
quest reward
starter equipment
```
Do not add unnecessary new reward systems.
## 9. Boss readiness
The regional boss should become reasonably beatable at approximately:
```text
7080% of maximum realistically obtainable regional power
```
Best-in-slot equipment should provide comfort and completionist value, not be mandatory.
## 10. Soft-gate validation
The player should not be blocked primarily by arbitrary level errors.
Where possible:
```text
actual world danger
```
should communicate that the player is too weak.
A player may attempt difficult content early, but the game should make the risk clear.
## 11. Full UI consistency pass
Review all implemented screens together:
```text
World
Hunt
Combat
Loot
Inventory
Quests
Merchant
```
Verify consistency of:
```text
Topbar
SideNavigation
Footer
Context Panel
Panel frames
Buttons
DangerBadges
Item icons
Combat actions
Typography
Spacing
Design tokens
```
No screen should feel like a separate web application.
## 12. Loading and error-state pass
All core player actions need intentional UI states:
```text
loading
success
domain error
network error
retry
```
Review at minimum:
```text
Travel
Hunt
Combat Action
Reward
Equip
Quest
Purchase
```
Do not use browser alerts.
## 13. Refresh and recovery pass
The following states must survive browser refresh through authoritative backend state:
```text
active travel
current hunt
active combat
finished combat/reward
inventory
equipment
active quest
Grenzmarken
boss completion
```
No critical gameplay state may exist only in Angular memory.
## 14. Full integration test
Where practical, automate the first complete progression flow:
```text
character starts at Südtor
travel to Verbrannte Straße
hunt
combat
win
receive loot
equip upgrade
travel to Wachtposten
accept quest
defeat relevant enemies
progress quest
earn Grenzmarken
buy targeted item
equip item
travel to Aschengrube
defeat boss
receive guaranteed boss reward
region completion persists
```
## 15. Manual playtest average RNG
Perform at least one normal playthrough and measure:
```text
combat duration
loot frequency
upgrade frequency
number of hunts
travel flow
quest pacing
Grenzmarken income
boss readiness
total region time
```
## 16. Manual playtest bad RNG
Test a deliberately unlucky run.
Verify:
```text
Grenzmarken still provide progress
progression does not hard-block
boss readiness remains achievable
player does not require extreme grind
```
## 17. Manual playtest good RNG
Test a deliberately lucky run.
Verify:
```text
region does not become instantly trivial
progression cannot be skipped too aggressively
boss remains meaningful
```
## 18. Data/content validation
Verify:
- all locations use persisted content
- all encounter pools use persisted relationships
- all loot is data-driven
- all shop offers are data-driven
- quest definitions are persisted/data-driven
- no core gameplay path depends on hardcoded Angular content
- no server domain service contains unnecessary location-specific special cases
## 19. Server-authority validation
Confirm that the backend remains authoritative for:
```text
travel
hunt availability
encounter generation
danger rating
combat
loot
XP
Silver
Grenzmarken
inventory ownership
equipment
effective stats
quest progress
shop purchases
boss completion
```
## 20. Performance / technical cleanup
Review the accumulated implementation for:
- duplicate API calls
- duplicated frontend state
- duplicated domain validation
- unused temporary placeholder logic
- obsolete Slice 0.10.5 shortcuts
- hardcoded demo combat stats that should now use CharacterStatsService
- temporary combat placeholder routes
- duplicated item definitions
- migration quality
- seed idempotency
Do not perform unrelated architectural rewrites.
## 21. Definition of Done
Playable Slice 0.12 / Aschenfelder Complete is complete when a fresh character can, without developer intervention:
```text
start
travel
hunt
fight
receive loot
improve equipment
meet NPC
complete quest
fight Elite
earn Grenzmarken
buy targeted item
reach Aschengrube
defeat regional Boss
receive guaranteed progression reward
discover path toward Dämmerwald
```
## 22. Final product test
The most important qualitative result is:
> After completing the Aschenfelder, the player should want to see what waits in the Dämmerwald.
If the loop is technically correct but does not create that motivation, the region still needs iteration.
## 23. After Slice 0.12
Do not immediately expand into the full Dämmerwald.
First:
```text
playtest
measure
balance
fix friction
validate reward pacing
validate combat decisions
validate boss readiness
```
Only after the first region loop works well should the same systems be expanded into the Dämmerwald and later the Vergessene Ruinen.

View File

@@ -0,0 +1,227 @@
# Ashen Realms Playable Slice 0.6: Full First Combat
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.5 First Upgrade
**Scope:** First complete tactical combat action set
**Next Slice:** Playable Slice 0.7 Complete Verbrannte Straße
## 1. Goal
Playable Slice 0.6 turns the technical `ATTACK`-only fight into the first real Ashen Realms combat with meaningful player decisions.
The player loop becomes:
```text
Encounter
→ Combat
→ recognize enemy intent
→ choose appropriate action
→ attack / defend / interrupt / heal
→ win or lose
```
The slice must prove that combat is more than repeatedly pressing Attack.
## 2. Player actions
Implement:
```text
ATTACK
HEAVY_STRIKE
SHIELD_BASH
DEFEND
POTION
```
### ATTACK
- 100% normal damage.
### HEAVY_STRIKE
- 160% normal damage.
### SHIELD_BASH
- 70% normal damage.
- Interrupts a prepared interruptible enemy action.
### DEFEND
- Reduces incoming damage by 50% for the current round.
### POTION
- Heals 35% of maximum HP.
- Uses the player's action for the round.
- Cannot heal beyond max HP.
## 3. Combat bag
For V1, each combat starts with:
```text
2 healing potions
```
For this slice, the combat bag may remain part of the Combat snapshot if connecting it to persistent consumable inventory would unnecessarily expand scope.
Do not implement a complete consumable inventory economy yet.
## 4. Telegraphing
Enemies must be able to prepare dangerous actions.
Example:
```text
Straßenräuber bereitet Schweren Hieb vor.
```
The next player decision may be:
```text
ATTACK
DEFEND
SHIELD_BASH
HEAVY_STRIKE
POTION
```
A prepared action must be visible before the player chooses the next action.
## 5. Enemy intent
Extend combat state so the monster can expose a prepared intent.
Conceptually:
```ts
monsterState: {
pendingAction?: CombatIntent;
}
```
Initial intent types may include:
```text
NORMAL_ATTACK
HEAVY_ATTACK
```
Keep the model extensible without implementing future mechanics prematurely.
## 6. Straßenräuber behavior
The Straßenräuber becomes the first real learning enemy.
Basic behavior:
```text
normal attack
occasionally prepare Heavy Attack
resolve Heavy Attack on the following round
```
`SHIELD_BASH` can interrupt the prepared Heavy Attack.
`DEFEND` can mitigate it.
The player may also intentionally accept the risk and attack.
## 7. Combat UI
The action bar should now show:
```text
[ Angriff ]
[ Schwerer Hieb ]
[ Schildstoß ]
[ Verteidigen ]
[ Trank 2/2 ]
```
The combat screen must clearly display:
- player HP
- monster HP
- current round
- enemy intent / telegraph
- combat log
- available actions
- disabled/loading action states
Telegraphing must be more visually prominent than a normal combat log line.
## 8. Server authority
The server decides:
- action validity
- damage
- healing
- potion count
- defend mitigation
- interrupt success
- enemy intent
- monster action
- combat result
The client sends only the selected action enum.
## 9. Explicit non-goals
Do not implement yet:
```text
Bleeding
Poison
Mana
Crit
Dodge
Block Chance
Cooldown system
Sets
Boss phases
Flee
Elemental damage
Resistances
```
## 10. Required tests
At minimum verify:
- `ATTACK` uses 100% damage.
- `HEAVY_STRIKE` uses 160% damage.
- `SHIELD_BASH` uses 70% damage.
- `SHIELD_BASH` interrupts a prepared interruptible action.
- `DEFEND` halves incoming damage for the round.
- `POTION` heals 35% max HP.
- Potion healing cannot exceed max HP.
- Only two potions are available in a combat.
- A telegraphed attack resolves on the next round if not interrupted.
- A killed monster does not resolve a pending attack.
- Combat remains deterministic.
## 11. Definition of Done
The player can fight a Straßenräuber and encounter a situation such as:
```text
Schwerer Angriff angekündigt
Verteidigen
or
Schildstoß
or
Risiko eingehen
```
The combat is no longer just repeated use of `ATTACK`.
## 12. Handoff
Next:
```text
Playable Slice 0.7 Complete Verbrannte Straße
```

View File

@@ -0,0 +1,235 @@
# Ashen Realms Playable Slice 0.7: Complete Verbrannte Straße
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.6 Full First Combat
**Scope:** First fully playable hunting location
**Primary Location:** Verbrannte Straße
**Next Slice:** Playable Slice 0.8 Wachtposten & First Elite
## 1. Goal
Turn the technical hunting location into the first complete gameplay location.
The Verbrannte Straße must support repeated hunting, multiple enemy archetypes, differentiated mechanics, meaningful loot, and the first visible progression through equipment.
## 2. Complete encounter pool
The hunting pool becomes:
```text
Aschenratte
Verwilderter Straßenhund
Straßenräuber
Verkohlter Plünderer rare
```
## 3. Aschenratte
Role:
- Level 1.
- Basic enemy.
- No special mechanic required.
Rewards:
```text
47 Silver
8 XP
60% Aschenfell / trade material
8% simple starter-slot equipment
```
## 4. Verwilderter Straßenhund
Role:
- Level 12.
- Introduces the first simple status effect.
Mechanic:
```text
BLEED
```
Keep Bleed deliberately simple:
- fixed duration
- fixed damage
- no complex stacking
- server-authoritative
- represented through structured combat state/events
Rewards:
```text
610 Silver
12 XP
70% Zähes Fell
8% Aschenstiefel
5% Kleiner Heiltrank
```
Do not implement the entire long-term consumable economy merely because a potion can drop.
## 5. Straßenräuber
Role:
- Level 2.
- Reinforces Telegraphing.
Mechanic:
```text
prepared Heavy Attack
```
Rewards:
```text
915 Silver
16 XP
18% Räuberklinge
12% Räuberhaube
8% Plündererhandschuhe
10% Kleiner Heiltrank
```
## 6. Verkohlter Plünderer
Role:
- Level 3.
- Rare encounter.
- Noticeably stronger.
- Early long-term target.
Guaranteed:
```text
2535 Silver
35 XP
1 Grenzmarke
```
Additional equipment drops use the existing balancing/content definitions.
The encounter should communicate:
> This enemy may be too strong right now, but the player can return later.
## 7. Encounter pool weights
All encounter probability belongs in persisted `LocationMonster` content.
Do not hardcode location-specific monster probabilities inside the HuntingService.
Conceptually:
```text
Aschenratte common
Straßenhund common
Straßenräuber normal
Verkohlter Plünderer rare
```
Exact weights may be tuned later.
## 8. Grenzmarken
Slice 0.7 may begin persisting Grenzmarken as reward data because the rare enemy already grants one.
They do not need to be spendable yet.
Spending Grenzmarken belongs to Slice 0.11.
## 9. Progression target
Within approximately the first 35 combats, the player should have a high chance of seeing the first meaningful equipment upgrade.
The purpose is to prove:
```text
hunt
→ fight
→ loot
→ equip
→ become stronger
```
through repeated play at one location.
## 10. Hunt UI
The Hunt context panel should now communicate the broader enemy pool.
Example:
```text
Mögliche Begegnungen
Aschenratte
Verwilderter Straßenhund
Straßenräuber
???
```
A rare enemy may remain hidden until first encountered.
Do not expose exact encounter percentages.
## 11. Explicit non-goals
Do not implement yet:
```text
Quest
NPC
Wachtposten progression
Boss
Merchant
Grenzmarken shop
Area completion
```
## 12. Required tests
Verify:
- all four monsters are persisted content
- all four are assigned to `burned-road`
- weighted selection remains deterministic in tests
- rare encounter selection is supported
- Bleed resolves correctly
- Straßenräuber Telegraphing still works
- loot tables match configured content
- Grenzmarken can be persisted when granted
- item upgrades remain persistent after reward flow
## 13. Definition of Done
The player can remain on the Verbrannte Straße and experience:
```text
different encounters
different mechanics
different rewards
first upgrades
visibly increasing strength
```
The location now feels like a real piece of the game rather than a technical test area.
## 14. Handoff
Next:
```text
Playable Slice 0.8 Wachtposten & First Elite
```

View File

@@ -0,0 +1,238 @@
# Ashen Realms Playable Slice 0.8: Wachtposten & First Elite
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.7 Complete Verbrannte Straße
**Scope:** First progression inside a region and first elite challenge
**Primary Location:** Verlassener Wachtposten
**Next Slice:** Playable Slice 0.9 Aschengrube & First Boss
## 1. Goal
Introduce the first meaningful progression from one dangerous location to another.
The player should reach a new location, encounter stronger enemies, discover an elite target, improve equipment, and return strong enough to defeat it.
## 2. New location
Add:
```text
Verlassener Wachtposten
```
Connection:
```text
Verbrannte Straße
Verlassener Wachtposten
```
Travel duration:
```text
approximately 15 seconds
```
Travel remains server-authoritative.
## 3. Location functions
The Wachtposten should support:
```text
Jagd beginnen
Wachtposten untersuchen
NPC visible
travel back to Verbrannte Straße
future travel toward Aschengrube
```
The NPC may already be visually present, but full NPC/quest interaction is deferred to Slice 0.10.
## 4. Encounter pool
Use the existing world/content design as the source of truth.
Core enemies:
```text
Straßenräuber
Plünderer-Späher
Plünderer-Veteran
Plündererhauptmann
```
## 5. Plünderer-Späher
Role:
- lighter humanoid enemy
- familiar combat foundation
- part of Wachtposten progression
Use persisted content and data-driven mechanics.
## 6. Plünderer-Veteran
Role:
- first clearly tougher regular humanoid
- combines existing mechanics
Mechanics:
```text
Heavy Attack
+
defensive stance
```
The defensive stance may temporarily increase armor or otherwise use the established deterministic defensive mechanic.
Avoid adding a generic scripting engine.
## 7. Plündererhauptmann
Role:
```text
ELITE
```
The elite combines 23 already learned mechanics.
The elite should be:
- difficult or impractical for an unupgraded starting character
- manageable after meaningful Tier-1 upgrades
- farmable for a recognizable desirable reward
## 8. Elite combat duration
Target:
```text
610 rounds
```
This is a balancing goal, not a hard mechanical rule.
## 9. Elite loot
The elite must have a clear reason to be farmed.
Use the existing balancing/content data for its target rewards.
Do not invent a separate random loot pool if the project already defines the relevant Tier-1 upgrade source.
A desirable weapon such as the Aschenklinge may serve as a primary target where consistent with existing content.
## 10. Danger Rating
By this slice, danger ratings should use real effective player stats.
The backend should derive them from:
```text
CharacterStatsService
+
enemy / encounter power
```
Supported ratings:
```text
WEAK
MATCH
STRONG
VERY_DANGEROUS
DEADLY
```
Use the balancing thresholds as the initial calibration.
Do not calculate this in Angular.
## 11. Encounter UI
Elite encounters must remain part of the reusable EncounterCard system.
They may receive:
```text
ELITE label
stronger border treatment
higher visual emphasis
```
Do not create a completely separate elite hunting UI.
## 12. World UI
Add the Wachtposten as a real travel node.
The world screen should communicate:
- current location
- Verbrannte Straße connection
- Wachtposten connection
- current danger/recommended range
- future Aschengrube direction as appropriate
## 13. Explicit non-goals
Do not implement yet:
```text
full quest flow
merchant
boss
area completion
Dämmerwald
```
## 14. Required tests
Verify:
- location and connection persistence
- travel works in both intended directions
- encounter pool is location-driven
- Plünderer-Veteran mechanics work
- elite encounter classification is persisted
- Danger Rating uses authoritative effective character stats
- elite loot is server-generated
- an undergeared character receives a stronger danger rating than an upgraded one where expected
## 15. Definition of Done
The player can experience:
```text
reach Wachtposten
see stronger enemies
discover Elite
struggle or fail
farm upgrades
return
defeat Elite
```
This is the first direct playable proof of:
> Become stronger and return.
## 16. Handoff
Next:
```text
Playable Slice 0.9 Aschengrube & First Boss
```

View File

@@ -0,0 +1,245 @@
# Ashen Realms Playable Slice 0.9: Aschengrube & First Boss
**Status:** Ready for implementation
**Prerequisite:** Playable Slice 0.8 Wachtposten & First Elite
**Scope:** First region climax and first boss
**Primary Location:** Aschengrube
**Boss:** Hauptmann der Aschenbande
**Next Slice:** Playable Slice 0.10 First NPC & Quest
## 1. Goal
Implement the first true regional progression check.
The player reaches the hardest Aschenfelder location, fights stronger enemies, challenges the first boss, receives guaranteed meaningful progress, and discovers the future path toward the Dämmerwald.
## 2. New location
Add:
```text
Aschengrube
```
Connection:
```text
Verlassener Wachtposten
Aschengrube
```
Use the existing world/content design for travel duration, danger, artwork, and location metadata.
## 3. Regular encounter pool
Core encounters:
```text
Aschenwühler
Plünderer-Veteran
Verbrannter Jagdhund
```
Use the existing balancing and content definitions.
The location should feel more dangerous than the previous two hunting areas.
## 4. Boss access
The boss should not be rolled as a normal random hunt encounter.
The Aschengrube exposes a dedicated location action:
```text
Hauptmann herausfordern
```
This establishes the reusable concept of a boss/location action without creating a boss-specific one-off controller.
## 5. Boss
Implement:
```text
Hauptmann der Aschenbande
Level 3
BOSS
```
Mechanics:
```text
normal attack
telegraphed Heavy Attack
defensive phase / armor increase
below 30% HP:
more aggressive behavior
```
Use reusable combat mechanics rather than a bespoke boss script framework.
## 6. Boss duration
Target:
```text
814 rounds
```
This is a balancing target.
The boss should not be realistically comfortable for a fresh character, but should become manageable through the Tier-1 progression available in the Aschenfelder.
## 7. Boss reward rule
Introduce the first boss guarantee system.
### Roll A guaranteed progression
Always grant:
```text
1 high-quality Tier-1 item
```
Guaranteed pool:
```text
Verstärkte Lederjacke
Wachmannsbeinkleid
Aschenstiefel
Zeichen der Grenzwacht
```
### Roll B additional special drops
```text
20% Aschenklinge
8% Anhänger des verbrannten Hauptmanns
```
Guaranteed additional rewards:
```text
5070 Silver
80 XP
5 Grenzmarken
```
The guaranteed progression roll and special rolls are separate.
## 8. Loot architecture extension
Extend the existing loot system to support:
```text
guaranteed item pool
+
independent optional rolls
```
Do not implement the boss reward directly inside the controller.
The behavior must remain data-driven and reusable.
## 9. Boss completion persistence
Persist first boss completion / regional progression state.
The exact data structure should follow the existing project conventions.
At minimum, the server must know that the character has defeated the regional boss.
Do not rely on Angular state.
## 10. Future path discovery
After the first boss victory, expose the future progression hint:
```text
Ein Pfad in Richtung Dämmerwald wurde entdeckt.
```
The Dämmerwald itself does not need to be playable yet.
The world state may mark a future node/connection as discovered or unlocked according to the existing architecture.
## 11. Boss UI
The boss encounter should visually communicate that it is a major challenge.
Use:
- existing combat screen
- boss label
- stronger framing
- clear Telegraphing
- phase/status information
- existing combat log/event system
Do not build a separate game mode.
## 12. Explicit non-goals
Do not implement yet:
```text
Dämmerwald content
complex boss scripting DSL
multiple boss instances
raid mechanics
group combat
boss matchmaking
```
## 13. Required tests
Verify:
- boss can only be started through a valid server-side boss/location action
- boss combat uses persisted authoritative stats
- defensive phase changes combat behavior
- low-HP aggression triggers correctly
- boss victory persists
- boss reward is granted exactly once
- guaranteed Tier-1 item always appears
- special drop rolls remain independent
- XP, Silver and Grenzmarken match content rules
- page refresh does not duplicate boss reward
- future path discovery persists
## 14. Definition of Done
The player can:
```text
reach Aschengrube
fight regular encounters
challenge boss
read and react to mechanics
win
receive guaranteed progression item
receive boss rewards
discover route toward Dämmerwald
```
A boss victory must never end without meaningful progress.
## 15. Handoff
Next:
```text
Playable Slice 0.10 First NPC & Quest
```

View File

@@ -0,0 +1,685 @@
# 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.