diff --git a/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts
new file mode 100644
index 0000000..4d234b5
--- /dev/null
+++ b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts
@@ -0,0 +1,82 @@
+import { MigrationInterface, QueryRunner } from 'typeorm';
+
+/**
+ * Adds the local location view content to `location_definitions`.
+ *
+ * The existing `description`/`artwork_path` columns are deliberately left
+ * alone — the map and hunt screens still render them. Backfill defaults keep
+ * existing rows valid; the seed replaces them with authored content.
+ */
+export class CreateLocalLocationView1788700000000
+ implements MigrationInterface
+{
+ public async up(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'',
+ );
+
+ // Existing rows fall back to their map-level content until the seed runs,
+ // so the view never renders an empty breadcrumb or a missing artwork.
+ await queryRunner.query(
+ 'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'',
+ );
+
+ await queryRunner.query(
+ 'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'',
+ );
+ await queryRunner.query(
+ 'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'',
+ );
+ }
+
+ public async down(queryRunner: QueryRunner): Promise {
+ await queryRunner.query(
+ 'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "local_description"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "location_type"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"',
+ );
+ await queryRunner.query(
+ 'ALTER TABLE "location_definitions" DROP COLUMN "region_name"',
+ );
+ }
+}
diff --git a/apps/api/src/database/migrations/local-location-view.migration.spec.ts b/apps/api/src/database/migrations/local-location-view.migration.spec.ts
new file mode 100644
index 0000000..0d3bafe
--- /dev/null
+++ b/apps/api/src/database/migrations/local-location-view.migration.spec.ts
@@ -0,0 +1,71 @@
+import 'reflect-metadata';
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { getMetadataArgsStorage } from 'typeorm';
+import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
+import { LocationDefinition } from '../../world/entities/location-definition.entity';
+
+const MIGRATION_SQL = readFileSync(
+ join(__dirname, '1788700000000-CreateLocalLocationView.ts'),
+ 'utf8',
+);
+
+function columnNames(target: unknown): string[] {
+ return getMetadataArgsStorage()
+ .columns.filter((column) => column.target === target)
+ .map((column) => column.options.name)
+ .filter((name): name is string => typeof name === 'string');
+}
+
+describe('local location view schema', () => {
+ const newLocationColumns = [
+ 'region_name',
+ 'region_tier_label',
+ 'location_type',
+ 'local_description',
+ 'local_artwork_path',
+ 'local_points_of_interest',
+ 'local_primary_actions',
+ 'local_reward_preview',
+ ];
+
+ it.each(newLocationColumns)(
+ 'maps %s on LocationDefinition',
+ (name: string) => {
+ expect(columnNames(LocationDefinition)).toContain(name);
+ },
+ );
+
+ it.each(newLocationColumns)('adds %s in the migration', (name: string) => {
+ expect(MIGRATION_SQL).toContain(
+ `ALTER TABLE "location_definitions" ADD COLUMN "${name}"`,
+ );
+ expect(MIGRATION_SQL).toContain(
+ `ALTER TABLE "location_definitions" DROP COLUMN "${name}"`,
+ );
+ });
+
+ it('adds the monster icon path in both the entity and the migration', () => {
+ expect(columnNames(MonsterDefinition)).toContain('icon_path');
+ expect(MIGRATION_SQL).toContain(
+ 'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"',
+ );
+ expect(MIGRATION_SQL).toContain(
+ 'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
+ );
+ });
+
+ it('keeps the map-level columns untouched so the world screen is unaffected', () => {
+ expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"');
+ expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"');
+ expect(columnNames(LocationDefinition)).toEqual(
+ expect.arrayContaining(['description', 'artwork_path', 'region_key']),
+ );
+ });
+
+ it('backfills existing rows so no location renders an empty breadcrumb', () => {
+ expect(MIGRATION_SQL).toContain(
+ 'UPDATE "location_definitions" SET "region_name" = "region_key"',
+ );
+ });
+});
diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts
new file mode 100644
index 0000000..1a18927
--- /dev/null
+++ b/apps/api/src/database/seeds/local-location.content.ts
@@ -0,0 +1,210 @@
+import type {
+ LocationPointOfInterestContent,
+ LocationPrimaryActionContent,
+ LocationRewardPreviewContent,
+ LocationType,
+} from '../../world/local-location.types';
+
+/**
+ * Authored local-view content per location (plan §7–§11).
+ *
+ * Coordinates are percentages of the artwork box, so a hotspot stays on the
+ * same painted detail at every viewport width. They are tuned against the real
+ * artwork in `apps/web/public/images/backgrounds/`, not against the
+ * composition mockup in `docs/references/`.
+ */
+export interface LocalLocationContent {
+ regionName: string;
+ regionTierLabel: string;
+ locationType: LocationType;
+ localDescription: string;
+ localArtworkPath: string;
+ localPointsOfInterest: LocationPointOfInterestContent[];
+ localPrimaryActions: LocationPrimaryActionContent[];
+ localRewardPreview: LocationRewardPreviewContent[];
+}
+
+export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'HUNTING_GROUND',
+ localDescription:
+ 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde. Verbrannte Karren, zerbrochene Waffen und verstummte Schreie säumen den Pfad in die Aschenfelder.',
+ localArtworkPath: '/images/backgrounds/Aschestrasse.png',
+ // Anchored to painted detail in `Aschestrasse.png`: the burning horizon down
+ // the road, the standing gravestone on the left verge, the cracked stones in
+ // the near foreground, and the broken cart wheel on the right.
+ localPointsOfInterest: [
+ {
+ key: 'hunt-area',
+ title: 'Jagdgebiet',
+ actionLabel: 'Jagd beginnen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ xPercent: 57,
+ yPercent: 33,
+ enabled: true,
+ },
+ {
+ key: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ actionLabel: 'Untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ xPercent: 40,
+ yPercent: 82,
+ enabled: true,
+ resultTitle: 'Verdächtige Spuren',
+ resultText:
+ 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.',
+ },
+ {
+ key: 'search-abandoned-wagon',
+ title: 'Verlassener Wagen',
+ actionLabel: 'Durchsuchen',
+ type: 'SEARCH',
+ iconKey: 'search',
+ xPercent: 86,
+ yPercent: 70,
+ enabled: true,
+ resultTitle: 'Verlassener Wagen',
+ resultText:
+ 'Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.',
+ },
+ {
+ key: 'wounded-scout',
+ title: 'Verwundeter Kundschafter',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 17,
+ yPercent: 62,
+ enabled: true,
+ resultTitle: 'Verwundeter Kundschafter',
+ resultText:
+ '„Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen."',
+ },
+ ],
+ localPrimaryActions: [
+ {
+ key: 'start-hunt',
+ label: 'Jagd beginnen',
+ description: 'Im Gebiet jagen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ enabled: true,
+ },
+ {
+ key: 'investigate-tracks',
+ label: 'Spuren untersuchen',
+ description: 'Hinweise finden',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ enabled: true,
+ poiKey: 'inspect-tracks',
+ },
+ {
+ key: 'search-surroundings',
+ label: 'Umgebung durchsuchen',
+ description: 'Beute finden',
+ type: 'SEARCH',
+ iconKey: 'search',
+ enabled: true,
+ poiKey: 'search-abandoned-wagon',
+ },
+ {
+ key: 'open-map',
+ label: 'Zur Karte',
+ description: 'Gebiet wechseln',
+ type: 'MAP',
+ iconKey: 'map',
+ enabled: true,
+ },
+ ],
+ // Only categories the loot tables on this road actually back: silver and
+ // experience from every kill, gear from the raiders, pelts from the beasts.
+ // Named items stay out — the view may not promise a drop the roll does not
+ // guarantee (spec §8, "Mögliche Belohnungen").
+ localRewardPreview: [
+ { key: 'silver', label: 'Silber', iconKey: 'silver' },
+ { key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
+ { key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
+ { key: 'material', label: 'Material', iconKey: 'material' },
+ ],
+};
+
+export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'TRANSITION',
+ localDescription:
+ 'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
+ localArtworkPath: '/images/backgrounds/Suedtor.png',
+ localPointsOfInterest: [
+ {
+ key: 'gate-notice',
+ title: 'Aushangtafel',
+ actionLabel: 'Lesen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ xPercent: 22,
+ yPercent: 42,
+ enabled: true,
+ resultTitle: 'Aushangtafel',
+ resultText:
+ 'Verwitterte Anschläge flattern im Wind. Ein frischer Zettel warnt vor Plünderern auf der Verbrannten Straße und verspricht Silber für jeden erlegten Räuber.',
+ },
+ {
+ key: 'gate-watch',
+ title: 'Torwache',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 45,
+ yPercent: 52,
+ enabled: true,
+ resultTitle: 'Torwache',
+ resultText:
+ '„Hinter dem Tor endet Graufurts Schutz. Wer nach Süden geht, geht auf eigene Gefahr — und kommt selten so zurück, wie er gegangen ist."',
+ },
+ {
+ key: 'south-road',
+ title: 'Straße nach Süden',
+ actionLabel: 'Zur Karte',
+ type: 'MAP',
+ iconKey: 'map',
+ xPercent: 66,
+ yPercent: 72,
+ enabled: true,
+ },
+ ],
+ localPrimaryActions: [
+ {
+ key: 'talk-to-watch',
+ label: 'Wache ansprechen',
+ description: 'Lage erfragen',
+ type: 'NPC',
+ iconKey: 'speak',
+ enabled: true,
+ poiKey: 'gate-watch',
+ },
+ {
+ key: 'read-notice',
+ label: 'Aushang lesen',
+ description: 'Hinweise finden',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ enabled: true,
+ poiKey: 'gate-notice',
+ },
+ {
+ key: 'open-map',
+ label: 'Zur Karte',
+ description: 'Gebiet wechseln',
+ type: 'MAP',
+ iconKey: 'map',
+ enabled: true,
+ },
+ ],
+ localRewardPreview: [],
+};
diff --git a/apps/api/src/database/seeds/vertical-slice.constants.ts b/apps/api/src/database/seeds/vertical-slice.constants.ts
index b7b8248..45c0543 100644
--- a/apps/api/src/database/seeds/vertical-slice.constants.ts
+++ b/apps/api/src/database/seeds/vertical-slice.constants.ts
@@ -2,3 +2,5 @@ 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';
+export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
+export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts
index c24015f..4628510 100644
--- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts
+++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts
@@ -17,6 +17,8 @@ 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';
+const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
+const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
class InMemoryRepository {
readonly rows: Row[] = [];
@@ -153,8 +155,8 @@ describe('seedVisibleVerticalSlice', () => {
}),
);
- expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
- expect(monsterRepository.rows).toHaveLength(2);
+ expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
+ expect(monsterRepository.rows).toHaveLength(4);
expect(monsterRepository.rows).toEqual(
expect.arrayContaining([
expect.objectContaining({
@@ -181,6 +183,20 @@ describe('seedVisibleVerticalSlice', () => {
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
}),
+ expect.objectContaining({
+ key: 'wild-road-dog',
+ name: 'Verwilderter Straßenhund',
+ level: 1,
+ artworkPath: '/images/monsters/wild-road-dog.png',
+ iconPath: '/images/combat/icons/wild-road-dog-128.png',
+ }),
+ expect.objectContaining({
+ key: 'charred-looter',
+ name: 'Verkohlter Plünderer',
+ level: 2,
+ artworkPath: '/images/monsters/charred-looter.png',
+ iconPath: '/images/combat/icons/charred-looter-128.png',
+ }),
]),
);
@@ -189,17 +205,114 @@ describe('seedVisibleVerticalSlice', () => {
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_ID,
- weight: 70,
+ weight: 40,
+ }),
+ expect.objectContaining({
+ locationId: BURNED_ROAD_ID,
+ monsterId: WILD_ROAD_DOG_MONSTER_ID,
+ weight: 30,
}),
expect.objectContaining({
locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_ID,
- weight: 30,
+ weight: 20,
+ }),
+ expect.objectContaining({
+ locationId: BURNED_ROAD_ID,
+ monsterId: CHARRED_LOOTER_MONSTER_ID,
+ weight: 10,
}),
]),
['locationId', 'monsterId'],
);
- expect(locationMonsterRepository.rows).toHaveLength(2);
+ expect(locationMonsterRepository.rows).toHaveLength(4);
+ });
+
+ it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => {
+ const locationRepository = new InMemoryRepository();
+ const dataSource = createDataSource(
+ locationRepository,
+ new InMemoryRepository(),
+ new InMemoryRepository(),
+ new InMemoryRepository(),
+ new InMemoryRepository(),
+ );
+
+ await seedVisibleVerticalSlice(dataSource);
+
+ const burnedRoad = locationRepository.rows.find(
+ (row) => row.key === 'burned-road',
+ ) as Row;
+
+ expect(burnedRoad).toEqual(
+ expect.objectContaining({
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'HUNTING_GROUND',
+ localArtworkPath: '/images/backgrounds/Aschestrasse.png',
+ }),
+ );
+ expect(burnedRoad.localDescription).toContain(
+ 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
+ );
+
+ const pointsOfInterest = burnedRoad.localPointsOfInterest as {
+ key: string;
+ type: string;
+ }[];
+ expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
+ 'hunt-area',
+ 'inspect-tracks',
+ 'search-abandoned-wagon',
+ 'wounded-scout',
+ ]);
+ expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
+ 'HUNT',
+ 'INVESTIGATE',
+ 'SEARCH',
+ 'NPC',
+ ]);
+
+ const primaryActions = burnedRoad.localPrimaryActions as {
+ label: string;
+ }[];
+ expect(primaryActions.map((action) => action.label)).toEqual([
+ 'Jagd beginnen',
+ 'Spuren untersuchen',
+ 'Umgebung durchsuchen',
+ 'Zur Karte',
+ ]);
+ });
+
+ it('gives the Südtor its own local content so a second location needs no new component', async () => {
+ const locationRepository = new InMemoryRepository();
+ const dataSource = createDataSource(
+ locationRepository,
+ new InMemoryRepository(),
+ new InMemoryRepository(),
+ new InMemoryRepository(),
+ new InMemoryRepository(),
+ );
+
+ await seedVisibleVerticalSlice(dataSource);
+
+ const southGate = locationRepository.rows.find(
+ (row) => row.key === 'south-gate',
+ ) as Row;
+
+ expect(southGate).toEqual(
+ expect.objectContaining({
+ locationType: 'TRANSITION',
+ localArtworkPath: '/images/backgrounds/Suedtor.png',
+ }),
+ );
+ expect(southGate.localPointsOfInterest).toHaveLength(3);
+ // A transition location offers no hunt, so no HUNT hotspot may appear.
+ expect(
+ (southGate.localPointsOfInterest as { type: string }[]).some(
+ (poi) => poi.type === 'HUNT',
+ ),
+ ).toBe(false);
});
it('preserves existing location IDs and uses them for the directed connections', async () => {
diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts
index 92add28..36176c4 100644
--- a/apps/api/src/database/seeds/vertical-slice.seed.ts
+++ b/apps/api/src/database/seeds/vertical-slice.seed.ts
@@ -9,16 +9,22 @@ 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 { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
import {
ASH_RAT_LOOT_TABLE_ID,
ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants';
-import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
+import {
+ BURNED_ROAD_LOCAL_CONTENT,
+ SOUTH_GATE_LOCAL_CONTENT,
+} from './local-location.content';
import {
ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID,
+ CHARRED_LOOTER_MONSTER_ID,
ROAD_BANDIT_MONSTER_ID,
SOUTH_GATE_ID,
+ WILD_ROAD_DOG_MONSTER_ID,
} from './vertical-slice.constants';
export async function seedVisibleVerticalSlice(
@@ -47,6 +53,7 @@ export async function seedVisibleVerticalSlice(
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
+ ...SOUTH_GATE_LOCAL_CONTENT,
},
{
id: BURNED_ROAD_ID,
@@ -61,17 +68,14 @@ export async function seedVisibleVerticalSlice(
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
+ ...BURNED_ROAD_LOCAL_CONTENT,
},
];
- let southGateId = SOUTH_GATE_ID;
- let burnedRoadId = BURNED_ROAD_ID;
+ const locationIds = new Map();
for (const location of locations) {
- const existing = await locationRepository.findOneBy({
- key: location.key,
- });
+ const existing = await locationRepository.findOneBy({ key: location.key });
const { id, key, ...definition } = location;
- const persistedId = existing?.id ?? id;
if (existing) {
await locationRepository.update(existing.id, definition);
@@ -79,13 +83,12 @@ export async function seedVisibleVerticalSlice(
await locationRepository.insert(location);
}
- if (key === 'south-gate') {
- southGateId = persistedId;
- } else {
- burnedRoadId = persistedId;
- }
+ locationIds.set(key, existing?.id ?? id);
}
+ const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
+ const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
+
await connectionRepository.upsert(
[
{
@@ -128,6 +131,24 @@ export async function seedVisibleVerticalSlice(
silverMin: 4,
silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png',
+ iconPath: '/images/combat/icons/ash-rat-128.png',
+ lootTableId: ASH_RAT_LOOT_TABLE_ID,
+ },
+ {
+ id: WILD_ROAD_DOG_MONSTER_ID,
+ key: 'wild-road-dog',
+ name: 'Verwilderter Straßenhund',
+ level: 1,
+ maxHp: 55,
+ attack: 7,
+ armor: 0,
+ experienceReward: 11,
+ silverMin: 5,
+ silverMax: 9,
+ artworkPath: '/images/monsters/wild-road-dog.png',
+ iconPath: '/images/combat/icons/wild-road-dog-128.png',
+ // Shares the beast table: both are scorched road animals that leave a
+ // pelt behind. A table of its own waits for content that differs.
lootTableId: ASH_RAT_LOOT_TABLE_ID,
},
{
@@ -142,18 +163,33 @@ export async function seedVisibleVerticalSlice(
silverMin: 9,
silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png',
+ iconPath: '/images/combat/icons/road-bandit-128.png',
+ lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
+ },
+ {
+ id: CHARRED_LOOTER_MONSTER_ID,
+ key: 'charred-looter',
+ name: 'Verkohlter Plünderer',
+ level: 2,
+ maxHp: 85,
+ attack: 11,
+ armor: 6,
+ experienceReward: 20,
+ silverMin: 12,
+ silverMax: 19,
+ artworkPath: '/images/monsters/charred-looter.png',
+ iconPath: '/images/combat/icons/charred-looter-128.png',
+ // Shares the raider table: same gear, taken from the same caravans.
lootTableId: ROAD_BANDIT_LOOT_TABLE_ID,
},
];
- let ashRatId = ASH_RAT_MONSTER_ID;
- let roadBanditId = ROAD_BANDIT_MONSTER_ID;
+ const monsterIds = new Map();
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);
@@ -161,30 +197,27 @@ export async function seedVisibleVerticalSlice(
await monsterRepository.insert(monster);
}
- if (key === 'ash-rat') {
- ashRatId = persistedId;
- } else {
- roadBanditId = persistedId;
- }
+ monsterIds.set(key, existingMonster?.id ?? id);
}
+ // Weights read as "how often you meet this on the road". They also drive the
+ // location's danger rating, which is computed from the weighted average of
+ // the pool rather than from its single worst entry.
+ const encounterWeights: Readonly> = {
+ 'ash-rat': 40,
+ 'wild-road-dog': 30,
+ 'road-bandit': 20,
+ 'charred-looter': 10,
+ };
+
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,
- },
- ],
+ Object.entries(encounterWeights).map(([key, weight]) => ({
+ locationId: burnedRoadId,
+ monsterId: monsterIds.get(key) as string,
+ weight,
+ encounterType: EncounterType.NORMAL,
+ enabled: true,
+ })),
['locationId', 'monsterId'],
);
diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts
index 5bf3286..efc79aa 100644
--- a/apps/api/src/monsters/entities/monster-definition.entity.ts
+++ b/apps/api/src/monsters/entities/monster-definition.entity.ts
@@ -46,6 +46,12 @@ export class MonsterDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string;
+ // Round medallion portrait used wherever a monster appears at icon size
+ // (encounter preview in the local location view). `artworkPath` stays the
+ // wide combat/hunt portrait.
+ @Column({ name: 'icon_path', type: 'varchar', length: 255 })
+ iconPath!: string;
+
@Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
lootTableId!: string | null;
diff --git a/apps/api/src/world/entities/location-definition.entity.ts b/apps/api/src/world/entities/location-definition.entity.ts
index b6e86a2..146daf8 100644
--- a/apps/api/src/world/entities/location-definition.entity.ts
+++ b/apps/api/src/world/entities/location-definition.entity.ts
@@ -8,6 +8,12 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { Character } from '../../characters/entities/character.entity';
+import type {
+ LocationPointOfInterestContent,
+ LocationPrimaryActionContent,
+ LocationRewardPreviewContent,
+ LocationType,
+} from '../local-location.types';
import { LocationConnection } from './location-connection.entity';
@Entity({ name: 'location_definitions' })
@@ -46,6 +52,35 @@ export class LocationDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string;
+ // --- Local location view (spec §4, §10) -----------------------------------
+ // `description`/`artworkPath` above stay untouched: the map and the hunt
+ // screen keep rendering them. The `local*` columns below feed the local
+ // location view, which needs a longer scene description and a wide artwork.
+
+ @Column({ name: 'region_name', type: 'varchar', length: 150 })
+ regionName!: string;
+
+ @Column({ name: 'region_tier_label', type: 'varchar', length: 50 })
+ regionTierLabel!: string;
+
+ @Column({ name: 'location_type', type: 'varchar', length: 50 })
+ locationType!: LocationType;
+
+ @Column({ name: 'local_description', type: 'text' })
+ localDescription!: string;
+
+ @Column({ name: 'local_artwork_path', type: 'varchar', length: 255 })
+ localArtworkPath!: string;
+
+ @Column({ name: 'local_points_of_interest', type: 'jsonb' })
+ localPointsOfInterest!: LocationPointOfInterestContent[];
+
+ @Column({ name: 'local_primary_actions', type: 'jsonb' })
+ localPrimaryActions!: LocationPrimaryActionContent[];
+
+ @Column({ name: 'local_reward_preview', type: 'jsonb' })
+ localRewardPreview!: LocationRewardPreviewContent[];
+
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date;
diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts
new file mode 100644
index 0000000..cd54a2a
--- /dev/null
+++ b/apps/api/src/world/local-location-interaction.spec.ts
@@ -0,0 +1,160 @@
+import { Repository } from 'typeorm';
+import { Character } from '../characters/entities/character.entity';
+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';
+import type { LocationPointOfInterestContent } from './local-location.types';
+import { WorldDomainError } from './world.errors';
+import { WorldService } from './world.service';
+
+const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
+
+const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
+ {
+ key: 'hunt-area',
+ title: 'Jagdgebiet',
+ actionLabel: 'Jagd beginnen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ xPercent: 52,
+ yPercent: 44,
+ enabled: true,
+ },
+ {
+ key: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ actionLabel: 'Untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ xPercent: 32,
+ yPercent: 78,
+ enabled: true,
+ resultTitle: 'Verdächtige Spuren',
+ resultText:
+ 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
+ },
+ {
+ key: 'sealed-crypt',
+ title: 'Versiegelte Krypta',
+ type: 'DUNGEON',
+ iconKey: 'search',
+ xPercent: 90,
+ yPercent: 20,
+ enabled: false,
+ resultTitle: 'Versiegelte Krypta',
+ resultText: 'Noch verschlossen.',
+ },
+];
+
+const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
+ {
+ key: 'gate-watch',
+ title: 'Torwache',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 45,
+ yPercent: 52,
+ enabled: true,
+ resultTitle: 'Torwache',
+ resultText: 'Nur am Südtor zu hören.',
+ },
+];
+
+function location(
+ id: string,
+ pointsOfInterest: LocationPointOfInterestContent[],
+): LocationDefinition {
+ return {
+ id,
+ localPointsOfInterest: pointsOfInterest,
+ } as LocationDefinition;
+}
+
+function createService(
+ currentLocation: LocationDefinition,
+ completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }),
+) {
+ return new WorldService(
+ { completeTravelIfDue } as unknown as TravelService,
+ {
+ findOne: jest.fn().mockResolvedValue({
+ id: CHARACTER_ID,
+ currentLocationId: currentLocation.id,
+ currentLocation,
+ }),
+ } as unknown as Repository,
+ { find: jest.fn() } as unknown as Repository,
+ { find: jest.fn() } as unknown as Repository,
+ );
+}
+
+async function expectRejected(promise: Promise): Promise {
+ await expect(promise).rejects.toBeInstanceOf(WorldDomainError);
+ await expect(promise).rejects.toMatchObject({
+ code: 'LOCATION_INTERACTION_UNAVAILABLE',
+ });
+}
+
+describe('WorldService.runLocalInteraction', () => {
+ it('returns the authored result of an enabled interaction at the current location', async () => {
+ const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
+
+ await expect(
+ service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
+ ).resolves.toEqual({
+ interactionKey: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ text: 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
+ });
+ });
+
+ it('settles travel before resolving which location the character stands at', async () => {
+ const completeTravelIfDue = jest
+ .fn()
+ .mockResolvedValue({ status: 'IDLE' });
+ const service = createService(
+ location(BURNED_ROAD_ID, BURNED_ROAD_POIS),
+ completeTravelIfDue,
+ );
+
+ await service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks');
+
+ expect(completeTravelIfDue).toHaveBeenCalledWith(CHARACTER_ID);
+ });
+
+ it('rejects an interaction that belongs to a different location', async () => {
+ const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS));
+
+ await expectRejected(
+ service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
+ );
+ });
+
+ it('rejects an unknown interaction key', async () => {
+ const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
+
+ await expectRejected(
+ service.runLocalInteraction(CHARACTER_ID, 'open-vault'),
+ );
+ });
+
+ it('rejects a disabled interaction', async () => {
+ const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
+
+ await expectRejected(
+ service.runLocalInteraction(CHARACTER_ID, 'sealed-crypt'),
+ );
+ });
+
+ it('rejects a navigation hotspot that has no result to reveal', async () => {
+ const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
+
+ await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area'));
+ });
+});
diff --git a/apps/api/src/world/local-location.types.ts b/apps/api/src/world/local-location.types.ts
new file mode 100644
index 0000000..9a7a72f
--- /dev/null
+++ b/apps/api/src/world/local-location.types.ts
@@ -0,0 +1,126 @@
+/**
+ * Content and transport types for the local location view (spec §10).
+ *
+ * A location's local presentation is content, not code: points of interest,
+ * primary actions and the reward preview are stored as JSONB on
+ * `LocationDefinition` so a new location renders through the same components
+ * by supplying different data.
+ */
+
+export type LocationInteractionType =
+ | 'HUNT'
+ | 'INVESTIGATE'
+ | 'SEARCH'
+ | 'NPC'
+ | 'MAP'
+ | 'TRAVEL'
+ | 'SHOP'
+ | 'QUEST'
+ | 'BOSS'
+ | 'DUNGEON';
+
+export type LocationType =
+ | 'SAFE_HUB'
+ | 'TRANSITION'
+ | 'HUNTING_GROUND'
+ | 'QUEST_LOCATION'
+ | 'OUTPOST'
+ | 'ELITE_ZONE'
+ | 'BOSS_LOCATION'
+ | 'DUNGEON_ENTRANCE';
+
+/**
+ * Stored shape of a point of interest. `resultTitle`/`resultText` never leave
+ * the server through the location payload — they are revealed only by the
+ * interaction endpoint, which first verifies the character actually stands
+ * here (plan §5).
+ */
+export interface LocationPointOfInterestContent {
+ key: string;
+ title: string;
+ actionLabel?: string;
+ type: LocationInteractionType;
+ iconKey: string;
+ xPercent: number;
+ yPercent: number;
+ enabled: boolean;
+ resultTitle?: string;
+ resultText?: string;
+}
+
+/**
+ * Stored shape of a primary action. Interaction-backed actions carry `poiKey`
+ * instead of their own result text, so a POI and the action bar entry that
+ * duplicates it can never drift apart.
+ */
+export interface LocationPrimaryActionContent {
+ key: string;
+ label: string;
+ description?: string;
+ type: LocationInteractionType;
+ iconKey: string;
+ enabled: boolean;
+ poiKey?: string;
+}
+
+export interface LocationRewardPreviewContent {
+ key: string;
+ label: string;
+ iconKey: string;
+}
+
+export interface LocalLocationPointOfInterestDto {
+ key: string;
+ title: string;
+ actionLabel?: string;
+ type: LocationInteractionType;
+ iconKey: string;
+ xPercent: number;
+ yPercent: number;
+ enabled: boolean;
+}
+
+export interface LocalLocationPrimaryActionDto {
+ key: string;
+ label: string;
+ description?: string;
+ type: LocationInteractionType;
+ iconKey: string;
+ enabled: boolean;
+ poiKey?: string;
+}
+
+export interface EncounterPreviewDto {
+ key: string;
+ name: string;
+ level: number;
+ iconPath: string;
+}
+
+export interface RewardPreviewDto {
+ key: string;
+ label: string;
+ iconKey: string;
+}
+
+export interface LocationInteractionResultDto {
+ interactionKey: string;
+ title: string;
+ text: string;
+}
+
+/** Strips server-only result text before a POI is sent to the client. */
+export function toPointOfInterestDto(
+ poi: LocationPointOfInterestContent,
+): LocalLocationPointOfInterestDto {
+ return {
+ key: poi.key,
+ title: poi.title,
+ ...(poi.actionLabel === undefined ? {} : { actionLabel: poi.actionLabel }),
+ type: poi.type,
+ iconKey: poi.iconKey,
+ xPercent: poi.xPercent,
+ yPercent: poi.yPercent,
+ enabled: poi.enabled,
+ };
+}
diff --git a/apps/api/src/world/world.controller.spec.ts b/apps/api/src/world/world.controller.spec.ts
new file mode 100644
index 0000000..9ecea5d
--- /dev/null
+++ b/apps/api/src/world/world.controller.spec.ts
@@ -0,0 +1,35 @@
+import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
+import { WorldController } from './world.controller';
+import { WorldService } from './world.service';
+
+describe('WorldController', () => {
+ it('resolves the current location for the acting character', () => {
+ const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' });
+ const controller = new WorldController({
+ getCurrentLocation,
+ } as unknown as WorldService);
+
+ void controller.getCurrentLocation();
+
+ expect(getCurrentLocation).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
+ });
+
+ it('forwards only the interaction key, never a caller-supplied location', () => {
+ const runLocalInteraction = jest.fn().mockResolvedValue({
+ interactionKey: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ text: 'Frische Stiefelabdrücke.',
+ });
+ const controller = new WorldController({
+ runLocalInteraction,
+ } as unknown as WorldService);
+
+ void controller.runLocalInteraction('inspect-tracks');
+
+ expect(runLocalInteraction).toHaveBeenCalledWith(
+ DEMO_CHARACTER_ID,
+ 'inspect-tracks',
+ );
+ expect(runLocalInteraction).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/apps/api/src/world/world.controller.ts b/apps/api/src/world/world.controller.ts
index 6c28032..262e4cd 100644
--- a/apps/api/src/world/world.controller.ts
+++ b/apps/api/src/world/world.controller.ts
@@ -1,5 +1,6 @@
-import { Controller, Get } from '@nestjs/common';
+import { Controller, Get, Param, Post } from '@nestjs/common';
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
+import { LocationInteractionResultDto } from './local-location.types';
import { WorldService } from './world.service';
@Controller('world')
@@ -10,4 +11,19 @@ export class WorldController {
getCurrentLocation() {
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
}
+
+ /**
+ * The interaction is addressed by key alone. There is deliberately no
+ * location parameter: the server resolves the location from the character,
+ * so the route cannot be pointed at somewhere the player is not.
+ */
+ @Post('current-location/interactions/:interactionKey')
+ runLocalInteraction(
+ @Param('interactionKey') interactionKey: string,
+ ): Promise {
+ return this.worldService.runLocalInteraction(
+ DEMO_CHARACTER_ID,
+ interactionKey,
+ );
+ }
}
diff --git a/apps/api/src/world/world.errors.ts b/apps/api/src/world/world.errors.ts
new file mode 100644
index 0000000..bfdb8e9
--- /dev/null
+++ b/apps/api/src/world/world.errors.ts
@@ -0,0 +1,25 @@
+import { HttpException } from '@nestjs/common';
+
+export class WorldDomainError extends HttpException {
+ constructor(
+ public readonly code: string,
+ status: number,
+ message: string,
+ ) {
+ super({ statusCode: status, code, message }, status);
+ }
+}
+
+/**
+ * Raised for any interaction key the current location does not offer — an
+ * unknown key, a key belonging to another location, or one that is disabled.
+ * They share a code on purpose: the client learns "not here", not which of the
+ * three it was.
+ */
+export function locationInteractionUnavailable(): WorldDomainError {
+ return new WorldDomainError(
+ 'LOCATION_INTERACTION_UNAVAILABLE',
+ 400,
+ 'This interaction is not available at the current location.',
+ );
+}
diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts
index edb37d1..9b692a8 100644
--- a/apps/api/src/world/world.service.spec.ts
+++ b/apps/api/src/world/world.service.spec.ts
@@ -9,10 +9,122 @@ 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';
+import type {
+ LocationPointOfInterestContent,
+ LocationPrimaryActionContent,
+} from './local-location.types';
import { WorldService } from './world.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
+const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
+ {
+ key: 'gate-watch',
+ title: 'Torwache',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 45,
+ yPercent: 52,
+ enabled: true,
+ resultTitle: 'Torwache',
+ resultText: 'Geheimer Servertext.',
+ },
+];
+
+const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
+ {
+ key: 'hunt-area',
+ title: 'Jagdgebiet',
+ actionLabel: 'Jagd beginnen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ xPercent: 52,
+ yPercent: 44,
+ enabled: true,
+ },
+ {
+ key: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ actionLabel: 'Untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ xPercent: 32,
+ yPercent: 78,
+ enabled: true,
+ resultTitle: 'Verdächtige Spuren',
+ resultText: 'Frische Stiefelabdrücke führen nach Osten.',
+ },
+ {
+ key: 'sealed-crypt',
+ title: 'Versiegelte Krypta',
+ actionLabel: 'Öffnen',
+ type: 'DUNGEON',
+ iconKey: 'search',
+ xPercent: 90,
+ yPercent: 20,
+ enabled: false,
+ resultTitle: 'Versiegelte Krypta',
+ resultText: 'Noch verschlossen.',
+ },
+];
+
+const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [
+ {
+ key: 'start-hunt',
+ label: 'Jagd beginnen',
+ description: 'Im Gebiet jagen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ enabled: true,
+ },
+ {
+ key: 'investigate-tracks',
+ label: 'Spuren untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ enabled: true,
+ poiKey: 'inspect-tracks',
+ },
+];
+
+function character(locationId: string, location: LocationDefinition) {
+ return {
+ id: CHARACTER_ID,
+ baseAttack: 6,
+ baseHp: 100,
+ currentLocationId: locationId,
+ currentLocation: location,
+ };
+}
+
+function poolEntry(
+ name: string,
+ weight: number,
+ stats: { level: number; attack: number; armor: number; maxHp: number },
+) {
+ return {
+ weight,
+ monster: {
+ key: name.toLowerCase(),
+ name,
+ level: stats.level,
+ attack: stats.attack,
+ armor: stats.armor,
+ maxHp: stats.maxHp,
+ iconPath: `/images/monsters/icons/${name.toLowerCase()}-128.png`,
+ },
+ };
+}
+
+// Ash rats are common and harmless, bandits rare and dangerous. Judged by its
+// worst entry this pool reads STRONG; judged by what a traveller actually
+// meets it reads MATCH.
+const BURNED_ROAD_POOL = [
+ poolEntry('Aschenratte', 70, { level: 1, attack: 5, armor: 0, maxHp: 45 }),
+ poolEntry('Straßenräuber', 30, { level: 2, attack: 9, armor: 5, maxHp: 75 }),
+];
+
function currentLocation(): LocationDefinition {
return {
id: SOUTH_GATE_ID,
@@ -27,6 +139,14 @@ function currentLocation(): LocationDefinition {
isSafe: true,
huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp',
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'TRANSITION',
+ localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
+ localArtworkPath: '/images/backgrounds/Suedtor.png',
+ localPointsOfInterest: SOUTH_GATE_POIS,
+ localPrimaryActions: [],
+ localRewardPreview: [],
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
@@ -48,6 +168,14 @@ function burnedRoad(): LocationDefinition {
isSafe: false,
huntingEnabled: true,
artworkPath: '/assets/locations/burned-road.webp',
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'HUNTING_GROUND',
+ localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
+ localArtworkPath: '/images/backgrounds/Aschestrasse.png',
+ localPointsOfInterest: BURNED_ROAD_POIS,
+ localPrimaryActions: BURNED_ROAD_ACTIONS,
+ localRewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [],
@@ -69,11 +197,7 @@ describe('WorldService', () => {
} as unknown as TravelService;
const findCharacter = jest.fn().mockImplementation(() => {
callOrder.push('findCharacter');
- return Promise.resolve({
- id: CHARACTER_ID,
- currentLocationId: SOUTH_GATE_ID,
- currentLocation: location,
- });
+ return Promise.resolve(character(SOUTH_GATE_ID, location));
});
const characters = {
findOne: findCharacter,
@@ -130,6 +254,28 @@ describe('WorldService', () => {
isSafe: true,
huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp',
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'TRANSITION',
+ localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
+ localArtworkPath: '/images/backgrounds/Suedtor.png',
+ dangerRating: null,
+ recommendationLabel: '1',
+ pointsOfInterest: [
+ {
+ key: 'gate-watch',
+ title: 'Torwache',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 45,
+ yPercent: 52,
+ enabled: true,
+ },
+ ],
+ primaryActions: [],
+ encounterPreview: [],
+ rewardPreview: [],
connections: [
{
targetLocation: {
@@ -160,21 +306,12 @@ describe('WorldService', () => {
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,
- }),
+ findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
} as unknown as Repository;
const connections = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository;
- const findLocationMonsters = jest
- .fn()
- .mockResolvedValue([
- { monster: { name: 'Aschenratte' } },
- { monster: { name: 'Stra\u00dfenr\u00e4uber' } },
- ]);
+ const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository;
@@ -230,4 +367,131 @@ describe('WorldService', () => {
expect(findConnections).not.toHaveBeenCalled();
expect(findLocationMonsters).not.toHaveBeenCalled();
});
+
+ it('exposes the authored local view content of the current location', async () => {
+ const result = await loadBurnedRoad();
+
+ expect(result).toEqual(
+ expect.objectContaining({
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'HUNTING_GROUND',
+ localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
+ localArtworkPath: '/images/backgrounds/Aschestrasse.png',
+ recommendationLabel: '1–2',
+ rewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
+ }),
+ );
+ expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS);
+ });
+
+ it('renders a single recommended level without a range', async () => {
+ const result = await loadSouthGate();
+
+ expect(result.recommendationLabel).toBe('1');
+ });
+
+ it('serves every point of interest, including disabled ones, without leaking its result text', async () => {
+ const result = await loadBurnedRoad();
+
+ expect(result.pointsOfInterest).toEqual([
+ {
+ key: 'hunt-area',
+ title: 'Jagdgebiet',
+ actionLabel: 'Jagd beginnen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ xPercent: 52,
+ yPercent: 44,
+ enabled: true,
+ },
+ {
+ key: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ actionLabel: 'Untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ xPercent: 32,
+ yPercent: 78,
+ enabled: true,
+ },
+ {
+ key: 'sealed-crypt',
+ title: 'Versiegelte Krypta',
+ actionLabel: 'Öffnen',
+ type: 'DUNGEON',
+ iconKey: 'search',
+ xPercent: 90,
+ yPercent: 20,
+ enabled: false,
+ },
+ ]);
+ expect(JSON.stringify(result)).not.toContain('Frische Stiefelabdrücke');
+ expect(JSON.stringify(result)).not.toContain('Noch verschlossen');
+ });
+
+ it('derives the encounter preview from the location monster pool', async () => {
+ const result = await loadBurnedRoad();
+
+ expect(result.encounterPreview).toEqual([
+ {
+ key: 'aschenratte',
+ name: 'Aschenratte',
+ level: 1,
+ iconPath: '/images/monsters/icons/aschenratte-128.png',
+ },
+ {
+ key: 'straßenräuber',
+ name: 'Straßenräuber',
+ level: 2,
+ iconPath: '/images/monsters/icons/straßenräuber-128.png',
+ },
+ ]);
+ });
+
+ it('rates local danger from the weighted pool average rather than its worst entry', async () => {
+ const result = await loadBurnedRoad();
+
+ // The lone bandit rates STRONG against this character; weighted by how
+ // rarely it appears, the road as a whole is a fair match.
+ expect(result.dangerRating).toBe('MATCH');
+ });
+
+ it('reports no danger rating where nothing can be hunted', async () => {
+ const result = await loadSouthGate();
+
+ expect(result.dangerRating).toBeNull();
+ expect(result.encounterPreview).toEqual([]);
+ });
});
+
+async function loadBurnedRoad() {
+ return loadLocation(burnedRoad(), BURNED_ROAD_ID, BURNED_ROAD_POOL);
+}
+
+async function loadSouthGate() {
+ return loadLocation(currentLocation(), SOUTH_GATE_ID, []);
+}
+
+async function loadLocation(
+ location: LocationDefinition,
+ locationId: string,
+ pool: ReturnType[],
+) {
+ const service = new WorldService(
+ {
+ completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
+ } as unknown as TravelService,
+ {
+ findOne: jest.fn().mockResolvedValue(character(locationId, location)),
+ } as unknown as Repository,
+ {
+ find: jest.fn().mockResolvedValue([]),
+ } as unknown as Repository,
+ {
+ find: jest.fn().mockResolvedValue(pool),
+ } as unknown as Repository,
+ );
+
+ return service.getCurrentLocation(CHARACTER_ID);
+}
diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts
index 703b4fe..7e61b00 100644
--- a/apps/api/src/world/world.service.ts
+++ b/apps/api/src/world/world.service.ts
@@ -2,9 +2,24 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
+import {
+ calculateDangerRating,
+ DangerRating,
+} from '../hunting/danger-rating';
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';
+import {
+ EncounterPreviewDto,
+ LocalLocationPointOfInterestDto,
+ LocalLocationPrimaryActionDto,
+ LocationInteractionResultDto,
+ LocationType,
+ RewardPreviewDto,
+ toPointOfInterestDto,
+} from './local-location.types';
+import { locationInteractionUnavailable } from './world.errors';
export interface LocationSummary {
id: string;
@@ -30,6 +45,18 @@ export interface CurrentLocationResponse {
isSafe: boolean;
huntingEnabled: boolean;
artworkPath: string;
+ regionName: string;
+ regionTierLabel: string;
+ locationType: LocationType;
+ localDescription: string;
+ localArtworkPath: string;
+ /** `null` where nothing hostile can be met — the view reads that as safe. */
+ dangerRating: DangerRating | null;
+ recommendationLabel: string;
+ pointsOfInterest: LocalLocationPointOfInterestDto[];
+ primaryActions: LocalLocationPrimaryActionDto[];
+ encounterPreview: EncounterPreviewDto[];
+ rewardPreview: RewardPreviewDto[];
connections: CurrentLocationConnection[];
possibleMonsters: string[];
}
@@ -49,15 +76,7 @@ export class WorldService {
async getCurrentLocation(
characterId: string,
): Promise {
- await this.travelService.completeTravelIfDue(characterId);
-
- const character = await this.characters.findOne({
- where: { id: characterId },
- relations: { currentLocation: true },
- });
- if (!character) {
- throw new NotFoundException('Character not found.');
- }
+ const character = await this.loadCharacterAtCurrentLocation(characterId);
const connections = await this.connections.find({
where: { fromLocationId: character.currentLocationId, enabled: true },
@@ -65,8 +84,8 @@ export class WorldService {
});
const location = character.currentLocation;
- const possibleMonsters = location.huntingEnabled
- ? await this.getPossibleMonsters(location.id)
+ const pool = location.huntingEnabled
+ ? await this.getEncounterPool(location.id)
: [];
return {
@@ -81,6 +100,24 @@ export class WorldService {
isSafe: location.isSafe,
huntingEnabled: location.huntingEnabled,
artworkPath: location.artworkPath,
+ regionName: location.regionName,
+ regionTierLabel: location.regionTierLabel,
+ locationType: location.locationType,
+ localDescription: location.localDescription,
+ localArtworkPath: location.localArtworkPath,
+ dangerRating: this.toLocalDangerRating(character, pool),
+ recommendationLabel: this.toRecommendationLabel(location),
+ pointsOfInterest: location.localPointsOfInterest.map(
+ toPointOfInterestDto,
+ ),
+ primaryActions: location.localPrimaryActions,
+ encounterPreview: pool.map((entry) => ({
+ key: entry.monster.key,
+ name: entry.monster.name,
+ level: entry.monster.level,
+ iconPath: entry.monster.iconPath,
+ })),
+ rewardPreview: location.localRewardPreview,
connections: connections
.filter((connection) => connection.enabled)
.map((connection) => ({
@@ -92,17 +129,102 @@ export class WorldService {
travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance),
})),
- possibleMonsters,
+ possibleMonsters: pool.map((entry) => entry.monster.name),
};
}
- private async getPossibleMonsters(locationId: string): Promise {
- const pool = await this.locationMonsters.find({
+ /**
+ * Runs a short local interaction (investigate, search, talk) and reveals its
+ * authored result.
+ *
+ * The location is taken from the character, never from the request, so a
+ * caller cannot reach a hotspot it has not travelled to. Hotspots that only
+ * navigate (HUNT, MAP) carry no result text and are rejected here — the
+ * client routes those itself.
+ */
+ async runLocalInteraction(
+ characterId: string,
+ interactionKey: string,
+ ): Promise {
+ const character = await this.loadCharacterAtCurrentLocation(characterId);
+
+ const poi = character.currentLocation.localPointsOfInterest.find(
+ (candidate) => candidate.key === interactionKey,
+ );
+
+ if (!poi?.enabled || !poi.resultText) {
+ throw locationInteractionUnavailable();
+ }
+
+ return {
+ interactionKey: poi.key,
+ title: poi.resultTitle ?? poi.title,
+ text: poi.resultText,
+ };
+ }
+
+ /**
+ * Loads the character together with the location it actually stands at.
+ *
+ * Every local interaction resolves through here, so a request can never name
+ * the location it wants to act on (plan §5).
+ */
+ async loadCharacterAtCurrentLocation(
+ characterId: string,
+ ): Promise {
+ await this.travelService.completeTravelIfDue(characterId);
+
+ const character = await this.characters.findOne({
+ where: { id: characterId },
+ relations: { currentLocation: true },
+ });
+ if (!character) {
+ throw new NotFoundException('Character not found.');
+ }
+
+ return character;
+ }
+
+ private getEncounterPool(locationId: string): Promise {
+ return this.locationMonsters.find({
where: { locationId, enabled: true },
relations: { monster: true },
order: { weight: 'DESC' },
});
- return pool.map((entry) => entry.monster.name);
+ }
+
+ /**
+ * Rates the location by the encounter a traveller can typically expect: the
+ * pool's weight-averaged stats, not its single worst entry. A rare elite
+ * would otherwise make a beginner road read as lethal.
+ */
+ private toLocalDangerRating(
+ character: Character,
+ pool: LocationMonster[],
+ ): DangerRating | null {
+ const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
+ if (totalWeight === 0) {
+ return null;
+ }
+
+ const average = (pick: (entry: LocationMonster) => number) =>
+ pool.reduce((sum, entry) => sum + entry.weight * pick(entry), 0) /
+ totalWeight;
+
+ return calculateDangerRating(
+ { attack: character.baseAttack, armor: 0, hp: character.baseHp },
+ {
+ attack: average((entry) => entry.monster.attack),
+ armor: average((entry) => entry.monster.armor),
+ hp: average((entry) => entry.monster.maxHp),
+ },
+ );
+ }
+
+ private toRecommendationLabel(location: LocationDefinition): string {
+ return location.minRecommendedLevel === location.maxRecommendedLevel
+ ? `${location.minRecommendedLevel}`
+ : `${location.minRecommendedLevel}–${location.maxRecommendedLevel}`;
}
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
diff --git a/apps/web/public/images/monsters/runtime/charred-looter-560.jpg b/apps/web/public/images/monsters/runtime/charred-looter-560.jpg
new file mode 100644
index 0000000..8860ec0
Binary files /dev/null and b/apps/web/public/images/monsters/runtime/charred-looter-560.jpg differ
diff --git a/apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg b/apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg
new file mode 100644
index 0000000..05126ba
Binary files /dev/null and b/apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg differ
diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts
index 8a0c082..648992c 100644
--- a/apps/web/src/app/app.routes.ts
+++ b/apps/web/src/app/app.routes.ts
@@ -2,11 +2,18 @@ import { Routes } from '@angular/router';
import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [
- { path: '', pathMatch: 'full', redirectTo: 'world' },
+ { path: '', pathMatch: 'full', redirectTo: 'location' },
{
path: '',
component: AppShellComponent,
children: [
+ {
+ path: 'location',
+ loadComponent: () =>
+ import('./features/world/location-page/location-page.component').then(
+ (module) => module.LocationPageComponent,
+ ),
+ },
{
path: 'world',
loadComponent: () =>
@@ -30,5 +37,5 @@ export const routes: Routes = [
},
],
},
- { path: '**', redirectTo: 'world' },
+ { path: '**', redirectTo: 'location' },
];
diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts
index 4dd3f38..f33cc2a 100644
--- a/apps/web/src/app/app.spec.ts
+++ b/apps/web/src/app/app.spec.ts
@@ -20,6 +20,7 @@ describe('App', () => {
imports: [AppShellComponent],
providers: [
provideRouter([
+ { path: 'location', children: [] },
{ path: 'world', children: [] },
{ path: 'hunt', children: [] },
]),
@@ -65,6 +66,55 @@ describe('App', () => {
expect(element.textContent).not.toContain('Shop');
});
+ it('offers Ort as the first navigation entry, marked active on /location', async () => {
+ const fixture = TestBed.createComponent(AppShellComponent);
+ const router = TestBed.inject(Router);
+ await router.navigateByUrl('/location');
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ const element = fixture.nativeElement as HTMLElement;
+ const entries = element.querySelectorAll('[data-navigation]');
+ expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([
+ 'location',
+ 'world',
+ 'hunt',
+ 'quests',
+ 'inventory',
+ 'character',
+ ]);
+
+ const locationButton = element.querySelector(
+ '[data-navigation="location"]',
+ );
+ expect(locationButton?.disabled).toBe(false);
+ expect(locationButton?.getAttribute('aria-label')).toBe('Ort');
+ expect(locationButton?.getAttribute('aria-current')).toBe('page');
+ expect(
+ element.querySelector('[data-navigation="world"]')?.getAttribute('aria-current'),
+ ).toBeNull();
+ });
+
+ it('drops the shell context rail on /location, where the screen brings its own', async () => {
+ const fixture = TestBed.createComponent(AppShellComponent);
+ const router = TestBed.inject(Router);
+ await router.navigateByUrl('/location');
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull();
+ });
+
+ it('keeps the shell context rail on the map', async () => {
+ const fixture = TestBed.createComponent(AppShellComponent);
+ const router = TestBed.inject(Router);
+ await router.navigateByUrl('/world');
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(fixture.nativeElement.querySelector('app-context-panel')).not.toBeNull();
+ });
+
it('marks Jagd as the active navigation entry while on /hunt', async () => {
const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router);
@@ -104,11 +154,19 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320');
});
- it('redirects root and unknown routes to the world shell', () => {
+ it('lands root and unknown routes on the place the character is standing in', () => {
expect(routes.find((route) => route.path === '')).toMatchObject({
pathMatch: 'full',
- redirectTo: 'world',
+ redirectTo: 'location',
});
- expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'world' });
+ expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'location' });
+ });
+
+ it('keeps the map and hunt routes where they were', () => {
+ const shellChildren = routes.find((route) => route.children)?.children ?? [];
+
+ expect(shellChildren.map((route) => route.path)).toEqual(
+ expect.arrayContaining(['location', 'world', 'hunt']),
+ );
});
});
diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts
index f4ad3e7..a33118f 100644
--- a/apps/web/src/app/core/api/game-api.models.ts
+++ b/apps/web/src/app/core/api/game-api.models.ts
@@ -22,6 +22,69 @@ export interface CurrentLocationConnection {
danger: 'LOW' | 'HIGH';
}
+export type LocationInteractionType =
+ | 'HUNT'
+ | 'INVESTIGATE'
+ | 'SEARCH'
+ | 'NPC'
+ | 'MAP'
+ | 'TRAVEL'
+ | 'SHOP'
+ | 'QUEST'
+ | 'BOSS'
+ | 'DUNGEON';
+
+export type LocationType =
+ | 'SAFE_HUB'
+ | 'TRANSITION'
+ | 'HUNTING_GROUND'
+ | 'QUEST_LOCATION'
+ | 'OUTPOST'
+ | 'ELITE_ZONE'
+ | 'BOSS_LOCATION'
+ | 'DUNGEON_ENTRANCE';
+
+export interface LocationPointOfInterest {
+ key: string;
+ title: string;
+ actionLabel?: string;
+ type: LocationInteractionType;
+ iconKey: string;
+ xPercent: number;
+ yPercent: number;
+ enabled: boolean;
+}
+
+export interface LocationPrimaryAction {
+ key: string;
+ label: string;
+ description?: string;
+ type: LocationInteractionType;
+ iconKey: string;
+ enabled: boolean;
+ /** Set when the action reveals the same result as a hotspot on the artwork. */
+ poiKey?: string;
+}
+
+export interface EncounterPreview {
+ key: string;
+ name: string;
+ level: number;
+ iconPath: string;
+}
+
+export interface RewardPreview {
+ key: string;
+ label: string;
+ iconKey: string;
+}
+
+export interface LocationInteractionResult {
+ interactionKey: string;
+ title: string;
+ text: string;
+}
+
export interface CurrentLocationResponse {
id: string;
key: string;
@@ -34,6 +97,18 @@ export interface CurrentLocationResponse {
isSafe: boolean;
huntingEnabled: boolean;
artworkPath: string;
+ regionName: string;
+ regionTierLabel: string;
+ locationType: LocationType;
+ localDescription: string;
+ localArtworkPath: string;
+ /** `null` where nothing hostile can be met — the view reads that as safe. */
+ dangerRating: DangerRating | null;
+ recommendationLabel: string;
+ pointsOfInterest: LocationPointOfInterest[];
+ primaryActions: LocationPrimaryAction[];
+ encounterPreview: EncounterPreview[];
+ rewardPreview: RewardPreview[];
connections: CurrentLocationConnection[];
possibleMonsters: string[];
}
diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts
index bc3a992..ffc16ff 100644
--- a/apps/web/src/app/core/api/game-api.service.spec.ts
+++ b/apps/web/src/app/core/api/game-api.service.spec.ts
@@ -48,6 +48,24 @@ describe('GameApiService', () => {
request.flush({ status: 'IDLE' });
});
+ it('posts a local interaction by key alone, never naming a location', () => {
+ service.runLocationInteraction('inspect-tracks').subscribe();
+
+ const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks');
+ expect(request.request.method).toBe('POST');
+ expect(request.request.body).toEqual({});
+ request.flush({ interactionKey: 'inspect-tracks', title: 'Spuren', text: '…' });
+ });
+
+ it('escapes an interaction key so it cannot break out of its path segment', () => {
+ service.runLocationInteraction('a/../b').subscribe();
+
+ const request = http.expectOne(
+ '/api/world/current-location/interactions/a%2F..%2Fb',
+ );
+ request.flush({ interactionKey: 'a/../b', title: '', text: '' });
+ });
+
it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe();
diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts
index 66c8b56..a5686d1 100644
--- a/apps/web/src/app/core/api/game-api.service.ts
+++ b/apps/web/src/app/core/api/game-api.service.ts
@@ -8,6 +8,7 @@ import {
CurrentLocationResponse,
CurrentTravel,
HuntResult,
+ LocationInteractionResult,
} from './game-api.models';
@Injectable({ providedIn: 'root' })
@@ -22,6 +23,17 @@ export class GameApiService {
return this.http.get('/api/world/current-location');
}
+ /**
+ * Runs a local hotspot interaction. Only the key travels: the server pairs
+ * it with the character's actual location.
+ */
+ runLocationInteraction(interactionKey: string): Observable {
+ return this.http.post(
+ `/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`,
+ {},
+ );
+ }
+
startTravel(targetLocationId: string): Observable {
return this.http.post('/api/travel', { targetLocationId });
}
diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html
index 2926081..7bd7c3f 100644
--- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html
+++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html
@@ -117,17 +117,37 @@
}
-
- Zur Jagd
-
+
+
+ Weiter jagen
+
+
+ Zum Ort
+
+
} @else if (combat.status === 'LOST') {
Niederlage
{{ combat.player.name }} wurde im Kampf besiegt.
-
- Zur Jagd
-
+
+
+ Weiter jagen
+
+
+ Zum Ort
+
+
}
diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss
index be70953..e170302 100644
--- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss
+++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss
@@ -490,6 +490,18 @@
margin-block-start: var(--ar-space-2);
}
+.outcome__buttons {
+ display: flex;
+ gap: var(--ar-space-3);
+ justify-content: center;
+}
+
+.outcome__button--secondary {
+ border-color: var(--ar-border);
+ color: var(--ar-text-muted);
+ background: var(--ar-panel);
+}
+
.outcome__button:hover,
.combat__notice--error button:hover {
border-color: var(--ar-gold);
diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
index e1ea829..5e4bbd3 100644
--- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
+++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts
@@ -266,7 +266,7 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-attack]')).toBeNull();
});
- it('navigates to /hunt from the victory screen', async () => {
+ it('keeps the one-click hunt loop from the victory screen', async () => {
const fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement;
@@ -275,6 +275,25 @@ describe('CombatPageComponent', () => {
expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
});
+ it('also offers the way back to the location from the victory screen', async () => {
+ const fixture = await setup({ ...activeCombat, status: 'WON' });
+ const element = fixture.nativeElement as HTMLElement;
+
+ element.querySelector('[data-combat-to-location]')?.click();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/location']);
+ });
+
+ it('offers the same two ways out after a defeat', async () => {
+ const fixture = await setup({ ...activeCombat, status: 'LOST' });
+ const element = fixture.nativeElement as HTMLElement;
+
+ expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull();
+ element.querySelector('[data-combat-to-location]')?.click();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/location']);
+ });
+
it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.');
diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts
index 38c0b88..ff63cbd 100644
--- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts
+++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts
@@ -161,6 +161,13 @@ export class CombatPageComponent implements OnInit {
void this.router.navigate(['/hunt']);
}
+ // The location is the screen a fight resolves back into. It sits beside
+ // "Weiter jagen" rather than replacing it, so the hunt loop keeps its
+ // one-click rhythm.
+ protected goToLocation(): void {
+ void this.router.navigate(['/location']);
+ }
+
protected monsterSprite(monsterKey: string, artworkPath: string): string {
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath;
}
diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html
index 5ec2587..40be676 100644
--- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html
+++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html
@@ -7,7 +7,7 @@
Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
Gebiet, um nach Gegnern zu suchen.
- Zur Karte
+ Zurück zum Ort
} @else if (huntingStore.currentHunt(); as hunt) {
@@ -28,7 +28,7 @@
>
Neu suchen
- Zur Karte
+ Zurück zum Ort
} @else {
diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
index bc54ca2..0ff4a32 100644
--- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
+++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts
@@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store';
+import {
+ burnedRoadFixture,
+ southGateFixture,
+} from '../../world/current-location.fixture';
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 southGate = southGateFixture({ connections: [] });
-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 burnedRoad = burnedRoadFixture({ connections: [] });
const threeEncounterHunt: HuntResult = {
id: 'hunt-id',
@@ -150,7 +129,7 @@ describe('HuntPageComponent', () => {
return fixture;
}
- it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => {
+ it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a way back to the location', async () => {
const fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement;
@@ -162,11 +141,11 @@ describe('HuntPageComponent', () => {
),
).toBe(false);
- const toWorldButton = element.querySelector('[data-hunt-to-world]');
- expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
- toWorldButton?.click();
+ const backButton = element.querySelector('[data-hunt-to-location]');
+ expect(backButton?.textContent?.trim()).toBe('Zurück zum Ort');
+ backButton?.click();
- expect(router.navigate).toHaveBeenCalledWith(['/world']);
+ expect(router.navigate).toHaveBeenCalledWith(['/location']);
});
it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => {
diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
index 6bdb95b..5547472 100644
--- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
+++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts
@@ -44,8 +44,10 @@ export class HuntPageComponent implements OnInit {
}
}
- protected goToWorld(): void {
- void this.router.navigate(['/world']);
+ // Back out of the hunt returns to the place the hunt happens in, not to the
+ // map: the location is the screen the player left to get here.
+ protected goToLocation(): void {
+ void this.router.navigate(['/location']);
}
protected async onAttack(encounterId: string): Promise {
diff --git a/apps/web/src/app/features/world/current-location.fixture.ts b/apps/web/src/app/features/world/current-location.fixture.ts
new file mode 100644
index 0000000..c05d603
--- /dev/null
+++ b/apps/web/src/app/features/world/current-location.fixture.ts
@@ -0,0 +1,192 @@
+import type {
+ CurrentLocationResponse,
+ LocationPointOfInterest,
+ LocationPrimaryAction,
+} from '../../core/api/game-api.models';
+
+/**
+ * Test fixtures for `GET /api/world/current-location`.
+ *
+ * Shared rather than re-declared per spec: the payload backs the map, the
+ * hunt screen and the local location view, so a field added to the contract
+ * needs to be answered in exactly one place.
+ */
+
+export const BURNED_ROAD_POIS: LocationPointOfInterest[] = [
+ {
+ key: 'hunt-area',
+ title: 'Jagdgebiet',
+ actionLabel: 'Jagd beginnen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ xPercent: 52,
+ yPercent: 44,
+ enabled: true,
+ },
+ {
+ key: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ actionLabel: 'Untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ xPercent: 32,
+ yPercent: 78,
+ enabled: true,
+ },
+ {
+ key: 'search-abandoned-wagon',
+ title: 'Verlassener Wagen',
+ actionLabel: 'Durchsuchen',
+ type: 'SEARCH',
+ iconKey: 'search',
+ xPercent: 80,
+ yPercent: 68,
+ enabled: true,
+ },
+ {
+ key: 'wounded-scout',
+ title: 'Verwundeter Kundschafter',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 20,
+ yPercent: 60,
+ enabled: true,
+ },
+];
+
+export const BURNED_ROAD_ACTIONS: LocationPrimaryAction[] = [
+ {
+ key: 'start-hunt',
+ label: 'Jagd beginnen',
+ description: 'Im Gebiet jagen',
+ type: 'HUNT',
+ iconKey: 'hunt',
+ enabled: true,
+ },
+ {
+ key: 'investigate-tracks',
+ label: 'Spuren untersuchen',
+ description: 'Hinweise finden',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ enabled: true,
+ poiKey: 'inspect-tracks',
+ },
+ {
+ key: 'search-surroundings',
+ label: 'Umgebung durchsuchen',
+ description: 'Beute finden',
+ type: 'SEARCH',
+ iconKey: 'search',
+ enabled: true,
+ poiKey: 'search-abandoned-wagon',
+ },
+ {
+ key: 'open-map',
+ label: 'Zur Karte',
+ description: 'Gebiet wechseln',
+ type: 'MAP',
+ iconKey: 'map',
+ enabled: true,
+ },
+];
+
+export function southGateFixture(
+ overrides: Partial = {},
+): CurrentLocationResponse {
+ return {
+ 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',
+ regionName: 'Aschenfelder',
+ regionTierLabel: 'Gebiet 1',
+ locationType: 'TRANSITION',
+ localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
+ localArtworkPath: '/images/backgrounds/Suedtor.png',
+ dangerRating: null,
+ recommendationLabel: '1',
+ pointsOfInterest: [],
+ primaryActions: [],
+ encounterPreview: [],
+ rewardPreview: [],
+ connections: [
+ {
+ targetLocation: {
+ id: 'burned-road-id',
+ key: 'burned-road',
+ name: 'Verbrannte Straße',
+ },
+ travelDurationSeconds: 10,
+ danger: 'LOW',
+ },
+ ],
+ possibleMonsters: [],
+ ...overrides,
+ };
+}
+
+export function burnedRoadFixture(
+ overrides: Partial = {},
+): CurrentLocationResponse {
+ return southGateFixture({
+ id: 'burned-road-id',
+ key: 'burned-road',
+ name: 'Verbrannte Straße',
+ description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
+ maxRecommendedLevel: 2,
+ dangerLevel: 1,
+ isSafe: false,
+ huntingEnabled: true,
+ artworkPath: '/images/backgrounds/Aschestrasse.png',
+ locationType: 'HUNTING_GROUND',
+ localDescription:
+ 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
+ localArtworkPath: '/images/backgrounds/Aschestrasse.png',
+ dangerRating: 'MATCH',
+ recommendationLabel: '1–2',
+ pointsOfInterest: BURNED_ROAD_POIS,
+ primaryActions: BURNED_ROAD_ACTIONS,
+ encounterPreview: [
+ {
+ key: 'ash-rat',
+ name: 'Aschenratte',
+ level: 1,
+ iconPath: '/images/combat/icons/ash-rat-128.png',
+ },
+ {
+ key: 'road-bandit',
+ name: 'Straßenräuber',
+ level: 2,
+ iconPath: '/images/combat/icons/road-bandit-128.png',
+ },
+ ],
+ rewardPreview: [
+ { key: 'silver', label: 'Silber', iconKey: 'silver' },
+ { key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
+ { key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' },
+ { key: 'material', label: 'Material', iconKey: 'material' },
+ ],
+ connections: [
+ {
+ targetLocation: {
+ id: 'south-gate-id',
+ key: 'south-gate',
+ name: 'Südtor von Graufurt',
+ },
+ travelDurationSeconds: 10,
+ danger: 'LOW',
+ },
+ ],
+ possibleMonsters: ['Aschenratte', 'Straßenräuber'],
+ ...overrides,
+ });
+}
diff --git a/apps/web/src/app/features/world/local-location.store.spec.ts b/apps/web/src/app/features/world/local-location.store.spec.ts
new file mode 100644
index 0000000..7432a01
--- /dev/null
+++ b/apps/web/src/app/features/world/local-location.store.spec.ts
@@ -0,0 +1,139 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { TestBed } from '@angular/core/testing';
+import { Subject, of, throwError } from 'rxjs';
+import { vi } from 'vitest';
+import { GameApiService } from '../../core/api/game-api.service';
+import { LocalLocationStore } from './local-location.store';
+import { WorldStore } from './world.store';
+
+const trackResult = {
+ interactionKey: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ text: 'Frische Stiefelabdrücke führen nach Osten.',
+};
+
+function setup(api: Partial = {}, world: Partial = {}) {
+ TestBed.configureTestingModule({
+ providers: [
+ LocalLocationStore,
+ { provide: GameApiService, useValue: api },
+ { provide: WorldStore, useValue: { load: vi.fn(), ...world } },
+ ],
+ });
+
+ return TestBed.inject(LocalLocationStore);
+}
+
+describe('LocalLocationStore', () => {
+ it('loads the location through the world store rather than a second fetch path', async () => {
+ const load = vi.fn().mockResolvedValue(undefined);
+ const store = setup({}, { load });
+
+ await store.load();
+
+ expect(load).toHaveBeenCalledTimes(1);
+ });
+
+ it('opens the panel with the result the server returned', async () => {
+ const store = setup({
+ runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)),
+ });
+
+ await store.runInteraction('inspect-tracks');
+
+ expect(store.interactionResult()).toEqual(trackResult);
+ expect(store.interactionError()).toBeNull();
+ expect(store.interactionOpen()).toBe(true);
+ });
+
+ it('marks only the running interaction as pending', async () => {
+ const pending = new Subject();
+ const store = setup({
+ runLocationInteraction: vi.fn().mockReturnValue(pending),
+ });
+
+ const running = store.runInteraction('inspect-tracks');
+ expect(store.interactionPending()).toBe('inspect-tracks');
+
+ pending.next(trackResult);
+ pending.complete();
+ await running;
+
+ expect(store.interactionPending()).toBeNull();
+ });
+
+ it('ignores a second interaction while one is still running', async () => {
+ const pending = new Subject();
+ const runLocationInteraction = vi.fn().mockReturnValue(pending);
+ const store = setup({ runLocationInteraction });
+
+ const running = store.runInteraction('inspect-tracks');
+ await store.runInteraction('search-abandoned-wagon');
+
+ expect(runLocationInteraction).toHaveBeenCalledTimes(1);
+ expect(runLocationInteraction).toHaveBeenCalledWith('inspect-tracks');
+
+ pending.next(trackResult);
+ pending.complete();
+ await running;
+ });
+
+ it('translates a rejected interaction into a readable message without navigating', async () => {
+ const store = setup({
+ runLocationInteraction: vi.fn().mockReturnValue(
+ throwError(
+ () =>
+ new HttpErrorResponse({
+ status: 400,
+ error: { code: 'LOCATION_INTERACTION_UNAVAILABLE' },
+ }),
+ ),
+ ),
+ });
+
+ await store.runInteraction('inspect-tracks');
+
+ expect(store.interactionError()).toBe('Hier gibt es dazu nichts zu entdecken.');
+ expect(store.interactionResult()).toBeNull();
+ expect(store.interactionOpen()).toBe(true);
+ });
+
+ it('falls back to a generic message for an unmapped failure', async () => {
+ const store = setup({
+ runLocationInteraction: vi
+ .fn()
+ .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))),
+ });
+
+ await store.runInteraction('inspect-tracks');
+
+ expect(store.interactionError()).toBe('Diese Handlung ist gerade nicht möglich.');
+ });
+
+ it('closes the panel without clearing the location', async () => {
+ const store = setup({
+ runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)),
+ });
+
+ await store.runInteraction('inspect-tracks');
+ store.closeInteraction();
+
+ expect(store.interactionOpen()).toBe(false);
+ expect(store.interactionResult()).toBeNull();
+ });
+
+ it('resolves the hotspot an action mirrors', () => {
+ const store = setup();
+
+ expect(
+ store.interactionKeyOf({
+ key: 'investigate-tracks',
+ label: 'Spuren untersuchen',
+ type: 'INVESTIGATE',
+ iconKey: 'investigate',
+ enabled: true,
+ poiKey: 'inspect-tracks',
+ }),
+ ).toBe('inspect-tracks');
+ });
+});
diff --git a/apps/web/src/app/features/world/local-location.store.ts b/apps/web/src/app/features/world/local-location.store.ts
new file mode 100644
index 0000000..10428f2
--- /dev/null
+++ b/apps/web/src/app/features/world/local-location.store.ts
@@ -0,0 +1,97 @@
+import { HttpErrorResponse } from '@angular/common/http';
+import { Injectable, computed, inject, signal } from '@angular/core';
+import { firstValueFrom } from 'rxjs';
+import {
+ LocationInteractionResult,
+ LocationPointOfInterest,
+ LocationPrimaryAction,
+} from '../../core/api/game-api.models';
+import { GameApiService } from '../../core/api/game-api.service';
+import { WorldStore } from './world.store';
+
+const GENERIC_INTERACTION_ERROR = 'Diese Handlung ist gerade nicht möglich.';
+
+// Mirrors the codes in `apps/api/src/world/world.errors.ts`.
+const INTERACTION_ERROR_MESSAGES: Readonly> = {
+ LOCATION_INTERACTION_UNAVAILABLE: 'Hier gibt es dazu nichts zu entdecken.',
+ CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.',
+};
+
+/**
+ * State for the local location view.
+ *
+ * Location data comes from `WorldStore`, not from a second fetch path: that
+ * store already settles due travel before answering, so arriving at a place
+ * and looking around cannot disagree about where the character is. This store
+ * adds only what is local to the screen — the open interaction result.
+ */
+@Injectable({ providedIn: 'root' })
+export class LocalLocationStore {
+ private readonly api = inject(GameApiService);
+ private readonly worldStore = inject(WorldStore);
+
+ private readonly interactionResultState = signal(null);
+ private readonly interactionErrorState = signal(null);
+ private readonly interactionPendingState = signal(null);
+
+ readonly location = this.worldStore.currentLocation;
+ readonly loading = this.worldStore.loading;
+ readonly error = this.worldStore.error;
+
+ readonly interactionResult = this.interactionResultState.asReadonly();
+ readonly interactionError = this.interactionErrorState.asReadonly();
+ /** Key of the interaction currently in flight, so only that control busies. */
+ readonly interactionPending = this.interactionPendingState.asReadonly();
+ readonly interactionOpen = computed(
+ () => this.interactionResultState() !== null || this.interactionErrorState() !== null,
+ );
+
+ load(): Promise {
+ return this.worldStore.load();
+ }
+
+ /**
+ * Runs a hotspot or action that reveals text. Navigation types (HUNT, MAP)
+ * never reach here — the page routes those itself.
+ */
+ async runInteraction(interactionKey: string): Promise {
+ if (this.interactionPendingState() !== null) {
+ return;
+ }
+
+ this.interactionPendingState.set(interactionKey);
+ this.interactionResultState.set(null);
+ this.interactionErrorState.set(null);
+
+ try {
+ this.interactionResultState.set(
+ await firstValueFrom(this.api.runLocationInteraction(interactionKey)),
+ );
+ } catch (error) {
+ this.interactionErrorState.set(this.toErrorMessage(error));
+ } finally {
+ this.interactionPendingState.set(null);
+ }
+ }
+
+ closeInteraction(): void {
+ this.interactionResultState.set(null);
+ this.interactionErrorState.set(null);
+ }
+
+ /** The hotspot an action mirrors, so an action bar entry can highlight it. */
+ interactionKeyOf(
+ action: LocationPrimaryAction | LocationPointOfInterest,
+ ): string | undefined {
+ return 'poiKey' in action ? action.poiKey : action.key;
+ }
+
+ private toErrorMessage(error: unknown): string {
+ if (error instanceof HttpErrorResponse) {
+ const code = (error.error as { code?: string } | null)?.code;
+ return (code && INTERACTION_ERROR_MESSAGES[code]) || GENERIC_INTERACTION_ERROR;
+ }
+
+ return GENERIC_INTERACTION_ERROR;
+ }
+}
diff --git a/apps/web/src/app/features/world/location-icon/location-icon.component.ts b/apps/web/src/app/features/world/location-icon/location-icon.component.ts
new file mode 100644
index 0000000..dbec8ad
--- /dev/null
+++ b/apps/web/src/app/features/world/location-icon/location-icon.component.ts
@@ -0,0 +1,68 @@
+import { Component, Input } from '@angular/core';
+
+/**
+ * Line glyphs for local location content, addressed by the `iconKey` the
+ * server stores alongside a hotspot, action or reward.
+ *
+ * Drawn rather than loaded: these sit on top of artwork at sizes from 16px to
+ * 40px, where a downscaled bitmap turns to mush, and a new location can use an
+ * existing key without anyone exporting an asset. Swapping a key for a painted
+ * medallion later is a one-line change here.
+ */
+const GLYPHS: Readonly> = {
+ // Crossed hunting arrows.
+ hunt: 'M4 20 19 5M15 5h4v4M20 20 5 5M9 5H5v4',
+ // Magnifying glass over a trail.
+ investigate: 'M11 4a6 6 0 1 0 0 12 6 6 0 0 0 0-12M15.5 15.5 21 21',
+ // Lidded chest.
+ search: 'M3 9h18v11H3zM3 9l2-4h14l2 4M12 9v11M10 12h4',
+ // Speech bubble.
+ speak: 'M4 5h16v11h-9l-5 4v-4H4z',
+ // Compass rose.
+ map: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M15.5 8.5l-2 5-5 2 2-5z',
+ // Struck coin.
+ silver: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 7l1.7 3.3L17 12l-3.3 1.7L12 17l-1.7-3.3L7 12l3.3-1.7z',
+ // Rising rune.
+ experience: 'M12 3 4 12l8 9 8-9zM12 8v8M9 11l3-3 3 3',
+ // Blade over a shield.
+ equipment: 'M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6zM12 8v7M10 10l2-2 2 2',
+ // Bundled pelt.
+ material: 'M4 8c4-3 12-3 16 0l-2 11H6zM9 8v11M15 8v11',
+ // Travel marker, for locations whose hotspots lead onward.
+ travel: 'M12 3a6 6 0 0 1 6 6c0 4.5-6 12-6 12S6 13.5 6 9a6 6 0 0 1 6-6M12 7a2 2 0 1 0 0 4 2 2 0 0 0 0-4',
+};
+
+const FALLBACK_GLYPH = 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 8v5M12 16h.01';
+
+@Component({
+ selector: 'app-location-icon',
+ template: `
+
+
+
+ `,
+ styles: `
+ :host {
+ display: inline-flex;
+ inline-size: 1.5rem;
+ block-size: 1.5rem;
+ }
+
+ svg {
+ inline-size: 100%;
+ block-size: 100%;
+ fill: none;
+ stroke: currentcolor;
+ stroke-width: 1.4;
+ stroke-linecap: round;
+ stroke-linejoin: round;
+ }
+ `,
+})
+export class LocationIconComponent {
+ @Input({ required: true }) iconKey!: string;
+
+ protected get glyph(): string {
+ return GLYPHS[this.iconKey] ?? FALLBACK_GLYPH;
+ }
+}
diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html
new file mode 100644
index 0000000..69ff7ee
--- /dev/null
+++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html
@@ -0,0 +1,28 @@
+@if (result || error) {
+
+ @if (result) {
+
{{ result.title }}
+
{{ result.text }}
+ } @else {
+
Nichts zu entdecken
+
+ {{ error }}
+
+ }
+
+
+ Schließen
+
+
+}
diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss
new file mode 100644
index 0000000..7153d64
--- /dev/null
+++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss
@@ -0,0 +1,64 @@
+:host {
+ position: absolute;
+ inset-block-end: var(--ar-space-5);
+ inset-inline: 0;
+ display: grid;
+ justify-items: center;
+ pointer-events: none;
+}
+
+.interaction-panel {
+ display: grid;
+ gap: var(--ar-space-3);
+ justify-items: start;
+ inline-size: min(38rem, calc(100% - var(--ar-space-6)));
+ padding: var(--ar-space-4) var(--ar-space-5);
+ border: 1px solid var(--ar-border-highlight);
+ border-radius: var(--ar-radius-md);
+ background:
+ linear-gradient(180deg, rgb(201 164 95 / 0.07), transparent 42%),
+ rgb(13 15 17 / 0.96);
+ box-shadow: var(--ar-shadow-raised);
+ pointer-events: auto;
+}
+
+.interaction-panel__title {
+ margin: 0;
+ color: var(--ar-gold);
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 1.25rem;
+ font-weight: 400;
+ letter-spacing: 0.01em;
+}
+
+.interaction-panel__text {
+ margin: 0;
+ color: var(--ar-text);
+ font-size: 0.95rem;
+ line-height: 1.55;
+}
+
+.interaction-panel__text--error {
+ color: var(--ar-danger);
+}
+
+.interaction-panel__close {
+ justify-self: end;
+ padding: var(--ar-space-2) var(--ar-space-5);
+ border: 1px solid var(--ar-border);
+ border-radius: var(--ar-radius-sm);
+ color: var(--ar-text);
+ background: var(--ar-panel);
+ font: inherit;
+ font-size: var(--ar-font-sm);
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ transition:
+ border-color var(--ar-motion-fast),
+ color var(--ar-motion-fast);
+}
+
+.interaction-panel__close:hover {
+ border-color: var(--ar-border-highlight);
+ color: var(--ar-gold);
+}
diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts
new file mode 100644
index 0000000..5af9cc4
--- /dev/null
+++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts
@@ -0,0 +1,86 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { LocationInteractionPanelComponent } from './location-interaction-panel.component';
+
+async function setup(inputs: {
+ result?: { interactionKey: string; title: string; text: string } | null;
+ error?: string | null;
+}): Promise<{
+ fixture: ComponentFixture;
+ closed: number;
+ element: HTMLElement;
+}> {
+ await TestBed.configureTestingModule({
+ imports: [LocationInteractionPanelComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(LocationInteractionPanelComponent);
+ fixture.componentRef.setInput('result', inputs.result ?? null);
+ fixture.componentRef.setInput('error', inputs.error ?? null);
+
+ let closed = 0;
+ fixture.componentInstance.closePanel.subscribe(() => {
+ closed += 1;
+ });
+ fixture.detectChanges();
+
+ return {
+ fixture,
+ get closed() {
+ return closed;
+ },
+ element: fixture.nativeElement as HTMLElement,
+ };
+}
+
+describe('LocationInteractionPanelComponent', () => {
+ it('shows the title and text the server returned', async () => {
+ const { element } = await setup({
+ result: {
+ interactionKey: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ text: 'Frische Stiefelabdrücke führen nach Osten.',
+ },
+ });
+
+ expect(element.textContent).toContain('Verdächtige Spuren');
+ expect(element.textContent).toContain('Frische Stiefelabdrücke führen nach Osten.');
+ });
+
+ it('shows an NPC line through the same panel, with no branching choices', async () => {
+ const { element } = await setup({
+ result: {
+ interactionKey: 'wounded-scout',
+ title: 'Verwundeter Kundschafter',
+ text: '„Die Straße ist nicht mehr sicher."',
+ },
+ });
+
+ expect(element.textContent).toContain('Verwundeter Kundschafter');
+ // Exactly one control: close. A dialogue tree is out of scope here.
+ expect(element.querySelectorAll('button')).toHaveLength(1);
+ });
+
+ it('reports a rejected interaction in place instead of navigating away', async () => {
+ const { element } = await setup({ error: 'Hier gibt es dazu nichts zu entdecken.' });
+
+ expect(element.querySelector('[role="alert"]')?.textContent).toContain(
+ 'Hier gibt es dazu nichts zu entdecken.',
+ );
+ });
+
+ it('emits close without touching the router', async () => {
+ const panel = await setup({
+ result: { interactionKey: 'k', title: 'T', text: 'X' },
+ });
+
+ panel.element.querySelector('[data-interaction-close]')?.click();
+
+ expect(panel.closed).toBe(1);
+ });
+
+ it('renders nothing while no interaction is open', async () => {
+ const { element } = await setup({});
+
+ expect(element.querySelector('[data-interaction-panel]')).toBeNull();
+ });
+});
diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts
new file mode 100644
index 0000000..2acaf84
--- /dev/null
+++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts
@@ -0,0 +1,21 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { LocationInteractionResult } from '../../../core/api/game-api.models';
+
+/**
+ * Result of a short local interaction, shown over the scene.
+ *
+ * Deliberately one panel for investigate, search and talk alike, and
+ * deliberately without choices: this slice reveals authored text and closes.
+ * The location stays visible behind it so the player never leaves the place
+ * they are standing in.
+ */
+@Component({
+ selector: 'app-location-interaction-panel',
+ templateUrl: './location-interaction-panel.component.html',
+ styleUrl: './location-interaction-panel.component.scss',
+})
+export class LocationInteractionPanelComponent {
+ @Input() result: LocationInteractionResult | null = null;
+ @Input() error: string | null = null;
+ @Output() readonly closePanel = new EventEmitter();
+}
diff --git a/apps/web/src/app/features/world/location-page/location-page.component.html b/apps/web/src/app/features/world/location-page/location-page.component.html
new file mode 100644
index 0000000..1c9beb4
--- /dev/null
+++ b/apps/web/src/app/features/world/location-page/location-page.component.html
@@ -0,0 +1,72 @@
+
+ @if (store.location(); as location) {
+
+
+
+
+
+ @if (runtimeArtwork(location.localArtworkPath); as runtime) {
+
+ }
+
+
+
+
+ @for (poi of location.pointsOfInterest; track poi.key) {
+
+ }
+
+
+
+
+
+
+ @for (action of location.primaryActions; track action.key) {
+
+
+ {{ action.label }}
+ @if (action.description) {
+
+ {{ isPending(action.poiKey ?? action.key) ? 'Einen Moment…' : action.description }}
+
+ }
+
+ }
+
+
+
+
+ } @else if (store.error(); as error) {
+
+ Ort konnte nicht geladen werden.
+ {{ error }}
+ Erneut versuchen
+
+ } @else {
+
+ Der Ort wird geladen…
+
+ }
+
diff --git a/apps/web/src/app/features/world/location-page/location-page.component.scss b/apps/web/src/app/features/world/location-page/location-page.component.scss
new file mode 100644
index 0000000..aa58052
--- /dev/null
+++ b/apps/web/src/app/features/world/location-page/location-page.component.scss
@@ -0,0 +1,205 @@
+// The app shell sizes itself with `min-block-size` everywhere (a floor, not a
+// ceiling: apps/web/src/app/layout/app-shell/app-shell.component.scss), so
+// `block-size: 100%` here would resolve against an indefinite ancestor and
+// fall back to auto — a `minmax(0, 1fr)` row below would then track content
+// size instead of clamping, letting the artwork push the action bar off
+// screen. Giving this page its own definite, viewport-bounded height fixes
+// that without touching the shell, which other screens still size freely.
+//
+// Reserve terms are the shell chrome's own `min-block-size` values plus this
+// page's own padding, so the number tracks its sources rather than sitting as
+// an opaque constant — if the top bar or footer ever needs more room than its
+// current floor (a longer name, a wrapped nav row), bump the matching term
+// here too:
+// apps/web/src/app/layout/top-bar/top-bar.component.scss (5.6rem)
+// + apps/web/src/app/layout/game-footer/game-footer.component.scss (3.3rem)
+// + this page's own padding, 2 × var(--ar-space-5) (3rem)
+// A small safety margin (0.5rem) absorbs sub-pixel/line-height drift so a
+// few px of unplanned growth doesn't immediately reopen the clipping bug.
+$app-shell-chrome-reserve: calc(5.6rem + 3.3rem + 3rem + 0.5rem);
+
+:host {
+ display: block;
+ block-size: calc(100dvh - #{$app-shell-chrome-reserve});
+ min-block-size: 0;
+}
+
+.location-page {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(14rem, 17rem);
+ gap: var(--ar-space-4);
+ block-size: 100%;
+ min-block-size: 0;
+}
+
+.location-page__main {
+ // Header and action bar take what they need; the artwork absorbs the rest,
+ // which is what keeps it dominant instead of one card among many.
+ display: grid;
+ grid-template-rows: auto minmax(0, 1fr) auto;
+ gap: var(--ar-space-3);
+ min-block-size: 0;
+}
+
+.location-page__header {
+ display: grid;
+ gap: 0.2rem;
+}
+
+.location-page__title {
+ margin: 0;
+ color: #e9dcc0;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: clamp(1.8rem, 2.6vw, 2.6rem);
+ font-weight: 400;
+ letter-spacing: 0.01em;
+ line-height: 1.05;
+ text-shadow: 0 0.2rem 0.8rem rgb(0 0 0 / 0.8);
+}
+
+.location-page__breadcrumb {
+ display: flex;
+ gap: var(--ar-space-2);
+ margin: 0;
+ color: var(--ar-text-muted);
+ font-size: var(--ar-font-sm);
+ letter-spacing: 0.05em;
+}
+
+.location-page__separator {
+ color: var(--ar-gold);
+}
+
+.location-page__description {
+ max-inline-size: 44rem;
+ margin: var(--ar-space-2) 0 0;
+ color: var(--ar-text-muted);
+ font-size: 0.9rem;
+ line-height: 1.5;
+}
+
+.location-page__scene {
+ position: relative;
+ inline-size: 100%;
+ // Fills the `minmax(0, 1fr)` row exactly — the row is a real, bounded size
+ // now that `.location-page` has a definite height, so the artwork needs no
+ // aspect-ratio of its own; `object-fit: cover` on the does the crop.
+ min-block-size: 14rem;
+ overflow: hidden;
+ border: 1px solid var(--ar-border);
+ border-radius: var(--ar-radius-md);
+ box-shadow: var(--ar-shadow-raised);
+}
+
+.location-page__artwork,
+.location-page__artwork img {
+ display: block;
+ inline-size: 100%;
+ block-size: 100%;
+}
+
+.location-page__artwork img {
+ object-fit: cover;
+}
+
+// Hotspots are positioned against this box, not against the , so the
+// percentages stay true to the painted image under `object-fit: cover`.
+.location-page__hotspots {
+ position: absolute;
+ inset: 0;
+}
+
+.location-page__actions {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(0, 1fr));
+ gap: var(--ar-space-3);
+}
+
+.location-action {
+ display: grid;
+ justify-items: center;
+ gap: 0.25rem;
+ padding: var(--ar-space-3) var(--ar-space-2);
+ border: 1px solid var(--ar-border);
+ border-radius: var(--ar-radius-sm);
+ color: var(--ar-text);
+ background: linear-gradient(180deg, rgb(201 164 95 / 0.06), transparent 55%), var(--ar-panel);
+ font: inherit;
+ text-align: center;
+ transition:
+ border-color var(--ar-motion-fast),
+ color var(--ar-motion-fast),
+ background var(--ar-motion-fast);
+}
+
+.location-action app-location-icon {
+ color: var(--ar-gold);
+}
+
+.location-action__label {
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: clamp(0.85rem, 0.95vw, 1rem);
+ text-wrap: balance;
+ white-space: nowrap;
+}
+
+.location-action__hint {
+ color: var(--ar-text-muted);
+ font-size: 0.72rem;
+}
+
+.location-action:not(:disabled):hover {
+ border-color: var(--ar-border-highlight);
+ background: linear-gradient(180deg, rgb(201 164 95 / 0.12), transparent 55%), var(--ar-panel);
+}
+
+.location-action:not(:disabled):hover .location-action__label {
+ color: var(--ar-gold);
+}
+
+.location-action:disabled {
+ opacity: 0.5;
+}
+
+.location-page__notice {
+ display: grid;
+ align-content: center;
+ justify-items: center;
+ gap: var(--ar-space-3);
+ grid-column: 1 / -1;
+ color: var(--ar-text-muted);
+}
+
+.location-page__notice-detail {
+ margin: 0;
+ font-size: var(--ar-font-sm);
+}
+
+.location-page__notice button {
+ padding: var(--ar-space-2) var(--ar-space-5);
+ border: 1px solid var(--ar-border);
+ border-radius: var(--ar-radius-sm);
+ color: var(--ar-text);
+ background: var(--ar-panel);
+ font: inherit;
+}
+
+@media (width < 1100px) {
+ .location-page {
+ grid-template-columns: minmax(0, 1fr);
+ // Main first, sized to its own natural minimum (scene floors at its
+ // min-block-size); sidebar gets whatever remains and scrolls internally.
+ // The reverse order starved main entirely — an `auto` track claims its
+ // full max-content height before a later `1fr` track sees any space, so
+ // the sidebar's tall content once pushed the action bar off (0px) while
+ // consuming the whole column itself.
+ grid-template-rows: auto minmax(8rem, 1fr);
+ }
+
+ .location-page__scene {
+ // Narrower columns give the artwork more natural height at 100% width
+ // (it has no aspect-ratio of its own below 1100px); floor it lower so
+ // the sidebar keeps a visible sliver instead of being squeezed to 0.
+ min-block-size: 10rem;
+ }
+}
diff --git a/apps/web/src/app/features/world/location-page/location-page.component.spec.ts b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts
new file mode 100644
index 0000000..555f3c4
--- /dev/null
+++ b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts
@@ -0,0 +1,269 @@
+import { signal } from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { Router, provideRouter } from '@angular/router';
+import { vi } from 'vitest';
+import type {
+ CurrentLocationResponse,
+ LocationInteractionResult,
+} from '../../../core/api/game-api.models';
+import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
+import { LocalLocationStore } from '../local-location.store';
+import { LocationPageComponent } from './location-page.component';
+
+describe('LocationPageComponent', () => {
+ let location: ReturnType>;
+ let error: ReturnType>;
+ let interactionResult: ReturnType>;
+ let interactionError: ReturnType>;
+ let interactionPending: ReturnType>;
+ let store: {
+ location: typeof location;
+ loading: ReturnType>;
+ error: typeof error;
+ interactionResult: typeof interactionResult;
+ interactionError: typeof interactionError;
+ interactionPending: typeof interactionPending;
+ load: ReturnType;
+ runInteraction: ReturnType;
+ closeInteraction: ReturnType;
+ };
+
+ async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) {
+ location = signal(current);
+ error = signal(null);
+ interactionResult = signal(null);
+ interactionError = signal(null);
+ interactionPending = signal(null);
+ store = {
+ location,
+ loading: signal(false),
+ error,
+ interactionResult,
+ interactionError,
+ interactionPending,
+ load: vi.fn().mockResolvedValue(undefined),
+ runInteraction: vi.fn().mockResolvedValue(undefined),
+ closeInteraction: vi.fn(),
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [LocationPageComponent],
+ providers: [provideRouter([]), { provide: LocalLocationStore, useValue: store }],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(LocationPageComponent);
+ const router = TestBed.inject(Router);
+ vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ fixture.detectChanges();
+
+ return { fixture, router, element: fixture.nativeElement as HTMLElement };
+ }
+
+ it('names the place and its region straight away', async () => {
+ const { element } = await setup();
+
+ expect(element.querySelector('h1')?.textContent).toContain('Verbrannte Straße');
+ expect(element.textContent).toContain('Gebiet 1');
+ expect(element.textContent).toContain('Aschenfelder');
+ expect(element.textContent).toContain(
+ 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
+ );
+ });
+
+ it('renders the location artwork, not the composition mockup', async () => {
+ const { element } = await setup();
+ const image = element.querySelector('img') as HTMLImageElement;
+
+ expect(image.getAttribute('src')).toBe('/images/backgrounds/Aschestrasse.png');
+ expect(image.getAttribute('alt')).toBe('Ortsansicht: Verbrannte Straße');
+ });
+
+ it('places every hotspot on the artwork', async () => {
+ const { element } = await setup();
+ const hotspots = element.querySelectorAll('app-location-poi');
+
+ expect(hotspots).toHaveLength(4);
+ expect([...hotspots].map((poi) => poi.querySelector('button')?.dataset['poi'])).toEqual([
+ 'hunt-area',
+ 'inspect-tracks',
+ 'search-abandoned-wagon',
+ 'wounded-scout',
+ ]);
+ });
+
+ it('renders the four primary actions in the authored order', async () => {
+ const { element } = await setup();
+ const actions = element.querySelectorAll('[data-action]');
+
+ expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([
+ 'Jagd beginnen',
+ 'Spuren untersuchen',
+ 'Umgebung durchsuchen',
+ 'Zur Karte',
+ ]);
+ });
+
+ it('renders the context sidebar', async () => {
+ const { element } = await setup();
+
+ expect(element.querySelector('app-location-sidebar')).not.toBeNull();
+ });
+
+ it('hands a hunt hotspot to the existing hunt screen without rolling encounters', async () => {
+ const { element, router } = await setup();
+
+ element.querySelector('[data-poi="hunt-area"]')?.click();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
+ expect(store.runInteraction).not.toHaveBeenCalled();
+ });
+
+ it('sends the hunt action to the hunt screen too', async () => {
+ const { element, router } = await setup();
+
+ element.querySelector('[data-action="start-hunt"]')?.click();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/hunt']);
+ });
+
+ it('opens the existing map route without completing any travel itself', async () => {
+ const { element, router } = await setup();
+
+ element.querySelector('[data-action="open-map"]')?.click();
+
+ expect(router.navigate).toHaveBeenCalledWith(['/world']);
+ expect(store.runInteraction).not.toHaveBeenCalled();
+ });
+
+ it('asks the server what investigating the tracks reveals', async () => {
+ const { element, router } = await setup();
+
+ element.querySelector('[data-poi="inspect-tracks"]')?.click();
+
+ expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks');
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('asks the server what searching the wagon reveals', async () => {
+ const { element } = await setup();
+
+ element.querySelector('[data-poi="search-abandoned-wagon"]')?.click();
+
+ expect(store.runInteraction).toHaveBeenCalledWith('search-abandoned-wagon');
+ });
+
+ it('asks the server what the scout says', async () => {
+ const { element } = await setup();
+
+ element.querySelector('[data-poi="wounded-scout"]')?.click();
+
+ expect(store.runInteraction).toHaveBeenCalledWith('wounded-scout');
+ });
+
+ it('routes an action through the hotspot it mirrors, so both show the same text', async () => {
+ const { element } = await setup();
+
+ element.querySelector('[data-action="investigate-tracks"]')?.click();
+
+ expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks');
+ });
+
+ it('shows the interaction result over the still-visible location', async () => {
+ const { fixture, element } = await setup();
+
+ interactionResult.set({
+ interactionKey: 'inspect-tracks',
+ title: 'Verdächtige Spuren',
+ text: 'Frische Stiefelabdrücke führen nach Osten.',
+ });
+ fixture.detectChanges();
+
+ expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain(
+ 'Frische Stiefelabdrücke führen nach Osten.',
+ );
+ // The scene is not replaced by the panel.
+ expect(element.querySelector('img')).not.toBeNull();
+ expect(element.querySelectorAll('app-location-poi')).toHaveLength(4);
+ });
+
+ it('closes the panel without navigating', async () => {
+ const { fixture, element, router } = await setup();
+
+ interactionResult.set({ interactionKey: 'k', title: 'T', text: 'X' });
+ fixture.detectChanges();
+ element.querySelector('[data-interaction-close]')?.click();
+
+ expect(store.closeInteraction).toHaveBeenCalledTimes(1);
+ expect(router.navigate).not.toHaveBeenCalled();
+ });
+
+ it('disables the controls while an interaction is running', async () => {
+ const { fixture, element } = await setup();
+
+ interactionPending.set('inspect-tracks');
+ fixture.detectChanges();
+
+ const actions = element.querySelectorAll('[data-action]');
+ expect([...actions].every((action) => action.disabled)).toBe(true);
+ expect(element.textContent).toContain('Einen Moment…');
+ });
+
+ it('renders a restrained loading state that shows no misplaced hotspots', async () => {
+ const { element } = await setup(null);
+
+ expect(element.querySelector('[role="status"]')?.textContent).toContain(
+ 'Der Ort wird geladen',
+ );
+ expect(element.querySelectorAll('app-location-poi')).toHaveLength(0);
+ });
+
+ it('offers a retry when the location could not be loaded', async () => {
+ const { fixture, element } = await setup(null);
+
+ error.set('Weltzustand konnte nicht geladen werden.');
+ fixture.detectChanges();
+
+ expect(element.querySelector('[role="alert"]')?.textContent).toContain(
+ 'Ort konnte nicht geladen werden.',
+ );
+
+ element.querySelector('[data-location-retry]')?.click();
+ expect(store.load).toHaveBeenCalled();
+ });
+
+ it('renders a second location from its own data alone', async () => {
+ const { element } = await setup(southGateFixture(southGateContent()));
+
+ expect(element.querySelector('h1')?.textContent).toContain('Südtor von Graufurt');
+ expect(element.querySelectorAll('app-location-poi')).toHaveLength(1);
+ expect(element.querySelectorAll('[data-action]')).toHaveLength(1);
+ });
+});
+
+function southGateContent(): Partial {
+ return {
+ pointsOfInterest: [
+ {
+ key: 'gate-watch',
+ title: 'Torwache',
+ actionLabel: 'Sprechen',
+ type: 'NPC',
+ iconKey: 'speak',
+ xPercent: 45,
+ yPercent: 52,
+ enabled: true,
+ },
+ ],
+ primaryActions: [
+ {
+ key: 'talk-to-watch',
+ label: 'Wache ansprechen',
+ description: 'Lage erfragen',
+ type: 'NPC',
+ iconKey: 'speak',
+ enabled: true,
+ poiKey: 'gate-watch',
+ },
+ ],
+ };
+}
diff --git a/apps/web/src/app/features/world/location-page/location-page.component.ts b/apps/web/src/app/features/world/location-page/location-page.component.ts
new file mode 100644
index 0000000..6a0eaa9
--- /dev/null
+++ b/apps/web/src/app/features/world/location-page/location-page.component.ts
@@ -0,0 +1,96 @@
+import { Component, OnInit, inject } from '@angular/core';
+import { Router } from '@angular/router';
+import {
+ LocationInteractionType,
+ LocationPointOfInterest,
+ LocationPrimaryAction,
+} from '../../../core/api/game-api.models';
+import { LocalLocationStore } from '../local-location.store';
+import { LocationInteractionPanelComponent } from '../location-interaction-panel/location-interaction-panel.component';
+import { LocationIconComponent } from '../location-icon/location-icon.component';
+import { LocationPoiComponent } from '../location-poi/location-poi.component';
+import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.component';
+
+const RUNTIME_ARTWORK: Readonly> = {
+ '/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg',
+ '/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg',
+};
+
+/**
+ * The screen the player stands on between activities.
+ *
+ * It renders whatever the current location's content describes and owns no
+ * knowledge of any particular place. Hunting and travel are not reimplemented
+ * here: HUNT and MAP hotspots hand off to the existing screens, and everything
+ * that reveals text goes through the server-authoritative interaction endpoint.
+ */
+@Component({
+ selector: 'app-location-page',
+ imports: [
+ LocationIconComponent,
+ LocationInteractionPanelComponent,
+ LocationPoiComponent,
+ LocationSidebarComponent,
+ ],
+ templateUrl: './location-page.component.html',
+ styleUrl: './location-page.component.scss',
+})
+export class LocationPageComponent implements OnInit {
+ protected readonly store = inject(LocalLocationStore);
+ private readonly router = inject(Router);
+
+ ngOnInit(): void {
+ if (this.store.location() === null) {
+ void this.store.load();
+ }
+ }
+
+ protected retry(): void {
+ void this.store.load();
+ }
+
+ protected activatePoi(poi: LocationPointOfInterest): void {
+ this.dispatch(poi.type, poi.key);
+ }
+
+ protected activateAction(action: LocationPrimaryAction): void {
+ this.dispatch(action.type, action.poiKey ?? action.key);
+ }
+
+ protected runtimeArtwork(artworkPath: string): string | undefined {
+ return RUNTIME_ARTWORK[artworkPath];
+ }
+
+ /** True while this control's own interaction is in flight. */
+ protected isPending(key: string | undefined): boolean {
+ return key !== undefined && this.store.interactionPending() === key;
+ }
+
+ protected get busy(): boolean {
+ return this.store.interactionPending() !== null;
+ }
+
+ /**
+ * Routes a hotspot or action by its interaction type. Navigation types leave
+ * for the screen that owns them; every other type asks the server what
+ * happened. Unimplemented types are ignored rather than faked.
+ */
+ private dispatch(type: LocationInteractionType, interactionKey: string): void {
+ switch (type) {
+ case 'HUNT':
+ void this.router.navigate(['/hunt']);
+ return;
+ case 'MAP':
+ case 'TRAVEL':
+ void this.router.navigate(['/world']);
+ return;
+ case 'INVESTIGATE':
+ case 'SEARCH':
+ case 'NPC':
+ void this.store.runInteraction(interactionKey);
+ return;
+ default:
+ return;
+ }
+ }
+}
diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.html b/apps/web/src/app/features/world/location-poi/location-poi.component.html
new file mode 100644
index 0000000..721530d
--- /dev/null
+++ b/apps/web/src/app/features/world/location-poi/location-poi.component.html
@@ -0,0 +1,17 @@
+
+
+
+
+ {{ poi.title }}
+ @if (poi.actionLabel) {
+ {{ poi.actionLabel }}
+ }
+
diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.scss b/apps/web/src/app/features/world/location-poi/location-poi.component.scss
new file mode 100644
index 0000000..b19d4cf
--- /dev/null
+++ b/apps/web/src/app/features/world/location-poi/location-poi.component.scss
@@ -0,0 +1,88 @@
+:host {
+ position: absolute;
+ // The host carries left/top from the percentage coordinates; this centres
+ // the marker on that point instead of hanging it off the top-left corner.
+ transform: translate(-50%, -50%);
+}
+
+.location-poi {
+ display: grid;
+ justify-items: center;
+ gap: 0.2rem;
+ padding: 0.35rem;
+ border: 0;
+ border-radius: var(--ar-radius-md);
+ color: var(--ar-text);
+ background: transparent;
+ font: inherit;
+ text-align: center;
+ text-shadow: 0 0.15rem 0.55rem rgb(0 0 0 / 0.95);
+ transition:
+ transform var(--ar-motion-fast),
+ filter var(--ar-motion-fast);
+}
+
+.location-poi__medallion {
+ display: grid;
+ place-items: center;
+ inline-size: 2.6rem;
+ block-size: 2.6rem;
+ border: 0.13rem solid var(--ar-gold);
+ border-radius: 50%;
+ color: #e8d5a8;
+ background:
+ radial-gradient(circle at 50% 38%, rgb(201 164 95 / 0.22), transparent 62%),
+ radial-gradient(circle, #221c14 30%, #14171a 76%);
+ box-shadow:
+ 0 0 0 0.14rem rgb(6 8 9 / 0.8),
+ 0 0.3rem 0.9rem rgb(0 0 0 / 0.6),
+ 0 0 0.85rem rgb(201 164 95 / 0.28);
+}
+
+.location-poi__title {
+ max-inline-size: 11rem;
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 0.95rem;
+ line-height: 1.2;
+}
+
+.location-poi__action {
+ color: var(--ar-gold);
+ font-size: var(--ar-font-sm);
+ letter-spacing: 0.02em;
+}
+
+.location-poi:not(:disabled):hover,
+.location-poi:not(:disabled):focus-visible {
+ transform: translateY(-0.1rem);
+}
+
+.location-poi:not(:disabled):hover .location-poi__medallion,
+.location-poi:not(:disabled):focus-visible .location-poi__medallion {
+ border-color: #e1bd72;
+ box-shadow:
+ 0 0 0 0.14rem rgb(6 8 9 / 0.8),
+ 0 0.3rem 0.9rem rgb(0 0 0 / 0.6),
+ 0 0 1.15rem rgb(225 189 114 / 0.72);
+}
+
+.location-poi:focus-visible {
+ outline: 2px solid var(--ar-blue);
+ outline-offset: 0.15rem;
+}
+
+.location-poi:disabled {
+ filter: grayscale(0.7);
+ opacity: 0.55;
+}
+
+.location-poi--busy {
+ opacity: 0.75;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .location-poi:not(:disabled):hover,
+ .location-poi:not(:disabled):focus-visible {
+ transform: none;
+ }
+}
diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts
new file mode 100644
index 0000000..27b057d
--- /dev/null
+++ b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts
@@ -0,0 +1,116 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import type { LocationPointOfInterest } from '../../../core/api/game-api.models';
+import { LocationPoiComponent } from './location-poi.component';
+
+const wagon: LocationPointOfInterest = {
+ key: 'search-abandoned-wagon',
+ title: 'Verlassener Wagen',
+ actionLabel: 'Durchsuchen',
+ type: 'SEARCH',
+ iconKey: 'search',
+ xPercent: 80,
+ yPercent: 68,
+ enabled: true,
+};
+
+async function setup(
+ poi: LocationPointOfInterest = wagon,
+ busy = false,
+): Promise<{
+ fixture: ComponentFixture;
+ activated: LocationPointOfInterest[];
+ button: HTMLButtonElement;
+}> {
+ await TestBed.configureTestingModule({
+ imports: [LocationPoiComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(LocationPoiComponent);
+ fixture.componentRef.setInput('poi', poi);
+ fixture.componentRef.setInput('busy', busy);
+
+ const activated: LocationPointOfInterest[] = [];
+ fixture.componentInstance.activate.subscribe((value) => activated.push(value));
+ fixture.detectChanges();
+
+ return {
+ fixture,
+ activated,
+ button: fixture.nativeElement.querySelector('button') as HTMLButtonElement,
+ };
+}
+
+describe('LocationPoiComponent', () => {
+ it('anchors itself with the percentage coordinates so it scales with the artwork', async () => {
+ const { fixture } = await setup();
+ const host = fixture.nativeElement as HTMLElement;
+
+ expect(host.style.left).toBe('80%');
+ expect(host.style.top).toBe('68%');
+ });
+
+ it('renders title and action label', async () => {
+ const { fixture } = await setup();
+ const text = (fixture.nativeElement as HTMLElement).textContent ?? '';
+
+ expect(text).toContain('Verlassener Wagen');
+ expect(text).toContain('Durchsuchen');
+ });
+
+ it('emits the whole hotspot when clicked', async () => {
+ const { button, activated } = await setup();
+
+ button.click();
+
+ expect(activated).toEqual([wagon]);
+ });
+
+ it('is reachable and activatable from the keyboard', async () => {
+ const { button, activated } = await setup();
+
+ // A native button gives Enter and Space for free; assert it stayed a
+ // button rather than becoming a click-only div.
+ expect(button.tagName).toBe('BUTTON');
+ expect(button.tabIndex).toBe(0);
+
+ button.focus();
+ expect(document.activeElement).toBe(button);
+
+ button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' }));
+ button.click();
+
+ expect(activated).toHaveLength(1);
+ });
+
+ it('does nothing when the hotspot is disabled', async () => {
+ const { button, activated } = await setup({ ...wagon, enabled: false });
+
+ expect(button.disabled).toBe(true);
+ button.click();
+
+ expect(activated).toEqual([]);
+ });
+
+ it('does nothing while an interaction is already running', async () => {
+ const { button, activated } = await setup(wagon, true);
+
+ button.click();
+
+ expect(activated).toEqual([]);
+ });
+
+ it('describes itself with both title and action for screen readers', async () => {
+ const { button } = await setup();
+
+ expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen: Durchsuchen');
+ });
+
+ it('falls back to the title alone when a hotspot has no action label', async () => {
+ const { button } = await setup({
+ ...wagon,
+ actionLabel: undefined,
+ });
+
+ expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen');
+ });
+});
diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.ts
new file mode 100644
index 0000000..1cecbf8
--- /dev/null
+++ b/apps/web/src/app/features/world/location-poi/location-poi.component.ts
@@ -0,0 +1,34 @@
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { LocationPointOfInterest } from '../../../core/api/game-api.models';
+import { LocationIconComponent } from '../location-icon/location-icon.component';
+
+/**
+ * One hotspot pinned to the location artwork.
+ *
+ * Purely presentational: it renders the marker, reports activation and knows
+ * nothing about what the interaction does. The page decides whether a hotspot
+ * navigates or opens the interaction panel.
+ */
+@Component({
+ selector: 'app-location-poi',
+ imports: [LocationIconComponent],
+ templateUrl: './location-poi.component.html',
+ styleUrl: './location-poi.component.scss',
+ host: {
+ // Percentages of the artwork box, so a marker keeps sitting on the same
+ // painted detail at every viewport width.
+ '[style.left.%]': 'poi.xPercent',
+ '[style.top.%]': 'poi.yPercent',
+ },
+})
+export class LocationPoiComponent {
+ @Input({ required: true }) poi!: LocationPointOfInterest;
+ @Input() busy = false;
+ @Output() readonly activate = new EventEmitter();
+
+ protected onActivate(): void {
+ if (this.poi.enabled && !this.busy) {
+ this.activate.emit(this.poi);
+ }
+ }
+}
diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html
new file mode 100644
index 0000000..ac644aa
--- /dev/null
+++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html
@@ -0,0 +1,73 @@
+
diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss
new file mode 100644
index 0000000..bf991cb
--- /dev/null
+++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss
@@ -0,0 +1,130 @@
+:host {
+ display: block;
+ min-block-size: 0;
+}
+
+.location-sidebar {
+ display: grid;
+ align-content: start;
+ gap: var(--ar-space-4);
+ block-size: 100%;
+ padding: var(--ar-space-4);
+ overflow-y: auto;
+ border: 1px solid var(--ar-border);
+ border-radius: var(--ar-radius-md);
+ background:
+ linear-gradient(180deg, rgb(201 164 95 / 0.05), transparent 30%), var(--ar-panel);
+}
+
+.location-sidebar__identity {
+ display: grid;
+ gap: var(--ar-space-3);
+ margin: 0;
+}
+
+.location-sidebar__identity dt {
+ color: var(--ar-text-muted);
+ font-size: 0.7rem;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.location-sidebar__identity dd {
+ margin: 0.15rem 0 0;
+ color: var(--ar-text);
+ font-size: 0.95rem;
+}
+
+.location-sidebar__name {
+ font-family: Georgia, 'Times New Roman', serif;
+ font-size: 1.1rem;
+}
+
+.location-sidebar__recommendation {
+ color: var(--ar-success);
+ font-weight: 600;
+}
+
+.location-sidebar__safe {
+ color: var(--ar-success);
+ font-weight: 600;
+}
+
+.location-sidebar__block {
+ display: grid;
+ gap: var(--ar-space-2);
+ padding-block-start: var(--ar-space-4);
+ border-block-start: 1px solid var(--ar-border);
+}
+
+.location-sidebar__block h3 {
+ margin: 0;
+ color: var(--ar-text-muted);
+ font-size: 0.7rem;
+ font-weight: 600;
+ letter-spacing: 0.09em;
+ text-transform: uppercase;
+}
+
+.location-sidebar__block ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.location-sidebar__encounters {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr));
+ gap: var(--ar-space-2);
+}
+
+.location-sidebar__encounters li {
+ display: grid;
+ justify-items: center;
+ gap: 0.2rem;
+ text-align: center;
+}
+
+.location-sidebar__encounters img {
+ inline-size: 3rem;
+ block-size: 3rem;
+ border: 1px solid var(--ar-border);
+ border-radius: 50%;
+}
+
+.location-sidebar__encounters span {
+ color: var(--ar-text-muted);
+ font-size: 0.65rem;
+ line-height: 1.25;
+}
+
+.location-sidebar__note {
+ margin: 0;
+ color: var(--ar-text-muted);
+ font-size: 0.68rem;
+ font-style: italic;
+}
+
+.location-sidebar__interactions,
+.location-sidebar__rewards {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--ar-space-2);
+}
+
+.location-sidebar__interactions li,
+.location-sidebar__rewards li {
+ display: grid;
+ grid-template-columns: auto minmax(0, 1fr);
+ align-items: center;
+ gap: var(--ar-space-2);
+ color: var(--ar-text);
+ font-size: var(--ar-font-sm);
+}
+
+.location-sidebar__interactions app-location-icon,
+.location-sidebar__rewards app-location-icon {
+ inline-size: 1.35rem;
+ block-size: 1.35rem;
+ color: var(--ar-gold);
+}
diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts
new file mode 100644
index 0000000..0c7c39d
--- /dev/null
+++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts
@@ -0,0 +1,97 @@
+import { TestBed } from '@angular/core/testing';
+import type { CurrentLocationResponse } from '../../../core/api/game-api.models';
+import { burnedRoadFixture, southGateFixture } from '../current-location.fixture';
+import { LocationSidebarComponent } from './location-sidebar.component';
+
+async function render(location: CurrentLocationResponse): Promise {
+ await TestBed.configureTestingModule({
+ imports: [LocationSidebarComponent],
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(LocationSidebarComponent);
+ fixture.componentRef.setInput('location', location);
+ fixture.detectChanges();
+
+ return fixture.nativeElement as HTMLElement;
+}
+
+describe('LocationSidebarComponent', () => {
+ it('states where the player is and what kind of place it is', async () => {
+ const element = await render(burnedRoadFixture());
+ const text = element.textContent ?? '';
+
+ expect(text).toContain('Aschenfelder');
+ expect(text).toContain('Verbrannte Straße');
+ expect(text).toContain('Jagdgebiet');
+ expect(text).toContain('1–2');
+ });
+
+ it('shows the danger rating as the shared badge', async () => {
+ const element = await render(burnedRoadFixture());
+
+ expect(element.querySelector('app-danger-badge')?.textContent).toContain('Passend');
+ });
+
+ it('calls a location with no hostile pool safe instead of inventing a rating', async () => {
+ const element = await render(southGateFixture());
+
+ expect(element.querySelector('app-danger-badge')).toBeNull();
+ expect(element.textContent).toContain('Sicher');
+ });
+
+ it('previews the encounters and marks them as preview only', async () => {
+ const element = await render(burnedRoadFixture());
+ const encounters = element.querySelectorAll('[data-sidebar="encounters"] li');
+
+ expect([...encounters].map((item) => item.textContent?.trim())).toEqual([
+ 'Aschenratte',
+ 'Straßenräuber',
+ ]);
+ expect(element.textContent).toContain('die Jagd würfelt eigenständig');
+ });
+
+ it('lists each interaction kind once, taken from the enabled hotspots', async () => {
+ const element = await render(burnedRoadFixture());
+ const interactions = element.querySelectorAll('[data-sidebar="interactions"] li');
+
+ expect([...interactions].map((item) => item.textContent?.trim())).toEqual([
+ 'Jagd beginnen',
+ 'Untersuchen',
+ 'Durchsuchen',
+ 'Sprechen',
+ ]);
+ });
+
+ it('never advertises an interaction whose hotspot is disabled', async () => {
+ const location = burnedRoadFixture();
+ const element = await render({
+ ...location,
+ pointsOfInterest: location.pointsOfInterest.map((poi) =>
+ poi.type === 'NPC' ? { ...poi, enabled: false } : poi,
+ ),
+ });
+
+ expect(element.querySelector('[data-sidebar="interactions"]')?.textContent).not.toContain(
+ 'Sprechen',
+ );
+ });
+
+ it('shows the reward categories the location actually backs', async () => {
+ const element = await render(burnedRoadFixture());
+ const rewards = element.querySelectorAll('[data-sidebar="rewards"] li');
+
+ expect([...rewards].map((item) => item.textContent?.trim())).toEqual([
+ 'Silber',
+ 'Erfahrung',
+ 'Ausrüstung',
+ 'Material',
+ ]);
+ });
+
+ it('omits empty blocks entirely rather than rendering headings with nothing under them', async () => {
+ const element = await render(southGateFixture());
+
+ expect(element.querySelector('[data-sidebar="encounters"]')).toBeNull();
+ expect(element.querySelector('[data-sidebar="rewards"]')).toBeNull();
+ });
+});
diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts
new file mode 100644
index 0000000..cbfb826
--- /dev/null
+++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts
@@ -0,0 +1,59 @@
+import { Component, Input } from '@angular/core';
+import { CurrentLocationResponse } from '../../../core/api/game-api.models';
+import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component';
+import { LocationIconComponent } from '../location-icon/location-icon.component';
+
+const LOCATION_TYPE_LABELS: Readonly> = {
+ SAFE_HUB: 'Zuflucht',
+ TRANSITION: 'Übergang',
+ HUNTING_GROUND: 'Jagdgebiet',
+ QUEST_LOCATION: 'Questort',
+ OUTPOST: 'Außenposten',
+ ELITE_ZONE: 'Elitegebiet',
+ BOSS_LOCATION: 'Bossort',
+ DUNGEON_ENTRANCE: 'Verlieseingang',
+};
+
+interface InteractionSummary {
+ label: string;
+ iconKey: string;
+}
+
+/**
+ * What this place means in play: identity, danger, who lives here, what can be
+ * done and what it pays. Every block is fed from the location payload, so a
+ * new location fills the same sidebar without a code change.
+ */
+@Component({
+ selector: 'app-location-sidebar',
+ imports: [DangerBadgeComponent, LocationIconComponent],
+ templateUrl: './location-sidebar.component.html',
+ styleUrl: './location-sidebar.component.scss',
+})
+export class LocationSidebarComponent {
+ @Input({ required: true }) location!: CurrentLocationResponse;
+
+ protected get locationTypeLabel(): string {
+ return LOCATION_TYPE_LABELS[this.location.locationType] ?? this.location.locationType;
+ }
+
+ /**
+ * Distinct interaction kinds available here, taken from the hotspots that
+ * are actually enabled — the list can never advertise more than the scene
+ * offers.
+ */
+ protected get interactions(): InteractionSummary[] {
+ const seen = new Map();
+
+ for (const poi of this.location.pointsOfInterest) {
+ if (poi.enabled && !seen.has(poi.type)) {
+ seen.set(poi.type, {
+ label: poi.actionLabel ?? poi.title,
+ iconKey: poi.iconKey,
+ });
+ }
+ }
+
+ return [...seen.values()];
+ }
+}
diff --git a/apps/web/src/app/features/world/world-page.component.spec.ts b/apps/web/src/app/features/world/world-page.component.spec.ts
index ae0ce72..3426b4b 100644
--- a/apps/web/src/app/features/world/world-page.component.spec.ts
+++ b/apps/web/src/app/features/world/world-page.component.spec.ts
@@ -1,11 +1,14 @@
import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
+import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest';
import type {
CurrentLocationConnection,
CurrentLocationResponse,
CurrentTravel,
+ LocationSummary,
} from '../../core/api/game-api.models';
+import { burnedRoadFixture, southGateFixture } from './current-location.fixture';
import { WorldStore } from './world.store';
import { WorldPageComponent } from './world-page.component';
@@ -19,40 +22,9 @@ const burnedRoadConnection: CurrentLocationConnection = {
danger: 'LOW',
};
-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: [burnedRoadConnection],
- possibleMonsters: [],
-};
+const southGate = southGateFixture({ connections: [burnedRoadConnection] });
-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: ['goblin', 'skeleton'],
- connections: [
- {
- targetLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },
- travelDurationSeconds: 10,
- danger: 'LOW',
- },
- ],
-};
+const burnedRoad = burnedRoadFixture();
describe('WorldPageComponent', () => {
let selectedConnection: ReturnType>;
@@ -63,9 +35,11 @@ describe('WorldPageComponent', () => {
remainingSeconds: ReturnType>;
loading: ReturnType>;
error: ReturnType>;
+ arrived: ReturnType>;
load: () => Promise;
selectConnection: (connection: CurrentLocationConnection | null) => void;
startTravel: () => Promise;
+ acknowledgeArrival: () => void;
};
beforeEach(async () => {
@@ -77,16 +51,18 @@ describe('WorldPageComponent', () => {
remainingSeconds: signal(null),
loading: signal(false),
error: signal(null),
+ arrived: signal(null),
load: vi.fn(() => Promise.resolve()),
selectConnection: vi.fn((connection: CurrentLocationConnection | null) =>
selectedConnection.set(connection),
),
startTravel: vi.fn(() => Promise.resolve()),
+ acknowledgeArrival: vi.fn(() => store.arrived.set(null)),
};
await TestBed.configureTestingModule({
imports: [WorldPageComponent],
- providers: [{ provide: WorldStore, useValue: store }],
+ providers: [provideRouter([]), { provide: WorldStore, useValue: store }],
}).compileComponents();
});
@@ -206,4 +182,40 @@ describe('WorldPageComponent', () => {
expect(store.load).toHaveBeenCalledTimes(2);
});
+
+ it('opens the location view once a journey has finished', () => {
+ const fixture = TestBed.createComponent(WorldPageComponent);
+ const router = TestBed.inject(Router);
+ const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true);
+ fixture.detectChanges();
+
+ expect(navigate).not.toHaveBeenCalled();
+
+ store.arrived.set({
+ id: 'burned-road-id',
+ key: 'burned-road',
+ name: 'Verbrannte Straße',
+ });
+ fixture.detectChanges();
+
+ expect(navigate).toHaveBeenCalledWith(['/location']);
+ // Acknowledged, so a later change detection cycle cannot navigate twice.
+ expect(store.acknowledgeArrival).toHaveBeenCalledTimes(1);
+ expect(navigate).toHaveBeenCalledTimes(1);
+ });
+
+ it('stays on the map while a journey is still running', () => {
+ store.currentTravel.set({
+ status: 'TRAVELLING',
+ originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' },
+ targetLocation: burnedRoadConnection.targetLocation,
+ startedAt: '2026-08-20T10:00:00.000Z',
+ arrivesAt: '2026-08-20T10:00:10.000Z',
+ });
+ const fixture = TestBed.createComponent(WorldPageComponent);
+ const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true);
+ fixture.detectChanges();
+
+ expect(navigate).not.toHaveBeenCalled();
+ });
});
diff --git a/apps/web/src/app/features/world/world-page.component.ts b/apps/web/src/app/features/world/world-page.component.ts
index 0e65124..7378820 100644
--- a/apps/web/src/app/features/world/world-page.component.ts
+++ b/apps/web/src/app/features/world/world-page.component.ts
@@ -1,4 +1,5 @@
-import { Component, OnInit, inject } from '@angular/core';
+import { Component, OnInit, effect, inject } from '@angular/core';
+import { Router } from '@angular/router';
import { CurrentLocationConnection } from '../../core/api/game-api.models';
import { LocationNodeComponent } from './location-node.component';
import { TravelPanelComponent } from './travel-panel.component';
@@ -12,6 +13,18 @@ import { WorldStore } from './world.store';
})
export class WorldPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore);
+ private readonly router = inject(Router);
+
+ constructor() {
+ // A finished journey ends at the place, not back on the map. The server
+ // still owns the arrival itself; this only decides which screen shows it.
+ effect(() => {
+ if (this.worldStore.arrived()) {
+ this.worldStore.acknowledgeArrival();
+ void this.router.navigate(['/location']);
+ }
+ });
+ }
ngOnInit(): void {
void this.worldStore.load();
diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts
index 169defa..589c26c 100644
--- a/apps/web/src/app/features/world/world.store.spec.ts
+++ b/apps/web/src/app/features/world/world.store.spec.ts
@@ -8,6 +8,7 @@ import type {
CurrentTravel,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
+import { southGateFixture } from './current-location.fixture';
import { WorldStore } from './world.store';
const character: CharacterResponse = {
@@ -22,19 +23,11 @@ const character: CharacterResponse = {
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
};
-const currentLocation: CurrentLocationResponse = {
+const currentLocation = southGateFixture({
id: 'origin-id',
- key: 'south-gate',
name: 'Südtor',
description: 'Der Ausgang zur Wildnis.',
- regionKey: 'ashen-fields',
- minRecommendedLevel: 1,
- maxRecommendedLevel: 1,
dangerLevel: 1,
- isSafe: true,
- huntingEnabled: false,
- artworkPath: '/images/backgrounds/Suedtor.png',
- possibleMonsters: [],
connections: [
{
targetLocation: {
@@ -46,7 +39,7 @@ const currentLocation: CurrentLocationResponse = {
danger: 'LOW',
},
],
-};
+});
const travelling: CurrentTravel = {
status: 'TRAVELLING',
@@ -208,6 +201,32 @@ describe('WorldStore', () => {
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
});
+ it('reports the arrival only once the new location has been re-read', async () => {
+ api.getCurrentTravel
+ .mockReturnValueOnce(of(travelling))
+ .mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation }));
+
+ await store.load();
+ expect(store.arrived()).toBeNull();
+
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ expect(store.arrived()).toEqual(travelling.targetLocation);
+ expect(api.getCurrentLocation).toHaveBeenCalledTimes(2);
+
+ store.acknowledgeArrival();
+ expect(store.arrived()).toBeNull();
+ });
+
+ it('never reports an arrival while the journey is still running', async () => {
+ api.getCurrentTravel.mockReturnValue(of(travelling));
+
+ await store.load();
+ await vi.advanceTimersByTimeAsync(5_000);
+
+ expect(store.arrived()).toBeNull();
+ });
+
it('clears selection and rejects a second start while authoritative completion reload is pending', async () => {
const pendingCharacter = new Subject();
const pendingLocation = new Subject();
diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts
index 95d9c52..9f7ce4c 100644
--- a/apps/web/src/app/features/world/world.store.ts
+++ b/apps/web/src/app/features/world/world.store.ts
@@ -6,6 +6,7 @@ import {
CurrentLocationConnection,
CurrentLocationResponse,
CurrentTravel,
+ LocationSummary,
} from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service';
@@ -27,6 +28,7 @@ export class WorldStore implements OnDestroy {
private readonly selectedConnectionState = signal(null);
private readonly currentTravelState = signal(null);
private readonly remainingSecondsState = signal(null);
+ private readonly arrivedState = signal(null);
private readonly loadingState = signal(false);
private readonly errorState = signal(null);
private countdownTimer: ReturnType | undefined;
@@ -39,6 +41,8 @@ export class WorldStore implements OnDestroy {
readonly selectedConnection = this.selectedConnectionState.asReadonly();
readonly currentTravel = this.currentTravelState.asReadonly();
readonly remainingSeconds = this.remainingSecondsState.asReadonly();
+ /** Set once a journey has finished and the new location has been re-read. */
+ readonly arrived = this.arrivedState.asReadonly();
readonly loading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly();
@@ -77,6 +81,11 @@ export class WorldStore implements OnDestroy {
this.selectedConnectionState.set(connection);
}
+ /** Clears the arrival flag once a screen has acted on it. */
+ acknowledgeArrival(): void {
+ this.arrivedState.set(null);
+ }
+
/**
* Re-reads the character from the server, e.g. after a combat granted XP and
* silver. Never mutates the values locally: the server owns them (spec §35).
@@ -172,6 +181,10 @@ export class WorldStore implements OnDestroy {
await this.reloadAuthoritativeState();
if (!this.destroyed) {
this.currentTravelState.set({ status: 'IDLE' });
+ // Raised only after the server-owned current location has been
+ // re-read, so whoever reacts to an arrival sees the new place. The
+ // store does not navigate itself; routing stays with the screen.
+ this.arrivedState.set(travel.targetLocation);
}
} finally {
if (!this.destroyed) {
diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.html b/apps/web/src/app/layout/app-shell/app-shell.component.html
index 07ff3cd..b8ae48c 100644
--- a/apps/web/src/app/layout/app-shell/app-shell.component.html
+++ b/apps/web/src/app/layout/app-shell/app-shell.component.html
@@ -1,12 +1,12 @@
-
+
- @if (!inCombat()) {
+ @if (showContextPanel()) {
}
diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.ts b/apps/web/src/app/layout/app-shell/app-shell.component.ts
index c8160f4..726e1f5 100644
--- a/apps/web/src/app/layout/app-shell/app-shell.component.ts
+++ b/apps/web/src/app/layout/app-shell/app-shell.component.ts
@@ -19,9 +19,18 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
styleUrl: './app-shell.component.scss',
})
export class AppShellComponent {
+ private readonly router = inject(Router);
+
protected readonly worldStore = inject(WorldStore);
// The fight has its own log rail and wants the width, and the area info
// belongs to the world view anyway, so the rail is dropped during combat.
- protected readonly inCombat = isActive('/combat', inject(Router));
+ private readonly inCombat = isActive('/combat', this.router);
+
+ // The location view brings its own, far richer context sidebar. Showing the
+ // shell's generic area panel next to it would say the same thing twice and
+ // squeeze the artwork the screen is built around.
+ private readonly atLocation = isActive('/location', this.router);
+
+ protected readonly showContextPanel = () => !this.inCombat() && !this.atLocation();
}
diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html
index 563ea58..3c894d0 100644
--- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html
+++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html
@@ -1,4 +1,22 @@
+
+
+
+
+
+ Ort
+
+
{
it('returns the background-free cut-out for a known monster key', () => {
expect(monsterCutoutPath('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png');
expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.png');
+ expect(monsterCutoutPath('wild-road-dog')).toBe(
+ '/images/combat/sprites/wild-road-dog-760.png',
+ );
+ expect(monsterCutoutPath('charred-looter')).toBe(
+ '/images/combat/sprites/charred-looter-620.png',
+ );
});
it('returns undefined for a monster without a cut-out', () => {
@@ -27,6 +33,9 @@ describe('monsterIconPath', () => {
it('returns the medallion icon for a known monster key', () => {
expect(monsterIconPath('ash-rat')).toBe('/images/combat/icons/ash-rat-128.png');
expect(monsterIconPath('road-bandit')).toBe('/images/combat/icons/road-bandit-128.png');
+ expect(monsterIconPath('charred-looter')).toBe(
+ '/images/combat/icons/charred-looter-128.png',
+ );
});
it('returns undefined for a monster without an icon', () => {
diff --git a/apps/web/src/app/shared/monster-artwork.ts b/apps/web/src/app/shared/monster-artwork.ts
index 764bd87..680cb85 100644
--- a/apps/web/src/app/shared/monster-artwork.ts
+++ b/apps/web/src/app/shared/monster-artwork.ts
@@ -30,6 +30,8 @@ const MONSTER_ICON: Readonly> = {
const COMBAT_MONSTER_SCALE: Readonly> = {
'ash-rat': 0.46,
'road-bandit': 0.82,
+ 'wild-road-dog': 0.58,
+ 'charred-looter': 0.86,
};
const DEFAULT_MONSTER_SCALE = 0.6;