Merge branch 'worktree-local-location-view'

This commit is contained in:
Bastian Wagner
2026-08-20 16:42:57 +02:00
60 changed files with 3744 additions and 170 deletions

View File

@@ -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<void> {
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<void> {
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"',
);
}
}

View File

@@ -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"',
);
});
});

View File

@@ -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: [],
};

View File

@@ -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';

View File

@@ -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 () => {

View File

@@ -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<string, string>();
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<string, string>();
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<Record<string, number>> = {
'ash-rat': 40,
'wild-road-dog': 30,
'road-bandit': 20,
'charred-looter': 10,
};
await locationMonsterRepository.upsert(
[
{
Object.entries(encounterWeights).map(([key, weight]) => ({
locationId: burnedRoadId,
monsterId: ashRatId,
weight: 70,
monsterId: monsterIds.get(key) as string,
weight,
encounterType: EncounterType.NORMAL,
enabled: true,
},
{
locationId: burnedRoadId,
monsterId: roadBanditId,
weight: 30,
encounterType: EncounterType.NORMAL,
enabled: true,
},
],
})),
['locationId', 'monsterId'],
);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
);
}
async function expectRejected(promise: Promise<unknown>): Promise<void> {
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'));
});
});

View File

@@ -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,
};
}

View File

@@ -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);
});
});

View File

@@ -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<LocationInteractionResultDto> {
return this.worldService.runLocalInteraction(
DEMO_CHARACTER_ID,
interactionKey,
);
}
}

View File

@@ -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.',
);
}

View File

@@ -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<Character>;
const connections = {
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>;
const findLocationMonsters = jest
.fn()
.mockResolvedValue([
{ monster: { name: 'Aschenratte' } },
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
]);
const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
@@ -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: '12',
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<typeof poolEntry>[],
) {
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<Character>,
{
find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>,
{
find: jest.fn().mockResolvedValue(pool),
} as unknown as Repository<LocationMonster>,
);
return service.getCurrentLocation(CHARACTER_ID);
}

View File

@@ -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<CurrentLocationResponse> {
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<string[]> {
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<LocationInteractionResultDto> {
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<Character> {
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<LocationMonster[]> {
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' {

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@@ -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' },
];

View File

@@ -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<HTMLButtonElement>('[data-navigation]');
expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([
'location',
'world',
'hunt',
'quests',
'inventory',
'character',
]);
const locationButton = element.querySelector<HTMLButtonElement>(
'[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']),
);
});
});

View File

@@ -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[];
}

View File

@@ -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();

View File

@@ -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<CurrentLocationResponse>('/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<LocationInteractionResult> {
return this.http.post<LocationInteractionResult>(
`/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`,
{},
);
}
startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
}

View File

@@ -117,17 +117,37 @@
</section>
}
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
Weiter jagen
</button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
</button>
</div>
</div>
} @else if (combat.status === 'LOST') {
<div class="outcome outcome--lost" data-combat-result="LOST">
<h2 class="outcome__title">Niederlage</h2>
<p>{{ combat.player.name }} wurde im Kampf besiegt.</p>
<div class="outcome__buttons">
<button type="button" class="outcome__button" data-combat-to-hunt (click)="goToHunt()">
Zur Jagd
Weiter jagen
</button>
<button
type="button"
class="outcome__button outcome__button--secondary"
data-combat-to-location
(click)="goToLocation()"
>
Zum Ort
</button>
</div>
</div>
}
</div>

View File

@@ -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);

View File

@@ -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<HTMLButtonElement>('[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<HTMLButtonElement>('[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.');

View File

@@ -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;
}

View File

@@ -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.
</p>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
<button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
</section>
} @else if (huntingStore.currentHunt(); as hunt) {
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
@@ -28,7 +28,7 @@
>
Neu suchen
</button>
<button type="button" data-hunt-to-world (click)="goToWorld()">Zur Karte</button>
<button type="button" data-hunt-to-location (click)="goToLocation()">Zurück zum Ort</button>
</div>
</section>
} @else {

View File

@@ -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<HTMLButtonElement>('[data-hunt-to-world]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte');
toWorldButton?.click();
const backButton = element.querySelector<HTMLButtonElement>('[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 () => {

View File

@@ -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<void> {

View File

@@ -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> = {},
): 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> = {},
): 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: '12',
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,
});
}

View File

@@ -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<GameApiService> = {}, world: Partial<WorldStore> = {}) {
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<typeof trackResult>();
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<typeof trackResult>();
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');
});
});

View File

@@ -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<Record<string, string>> = {
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<LocationInteractionResult | null>(null);
private readonly interactionErrorState = signal<string | null>(null);
private readonly interactionPendingState = signal<string | null>(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<void> {
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<void> {
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;
}
}

View File

@@ -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<Record<string, string>> = {
// 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: `
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path [attr.d]="glyph" />
</svg>
`,
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;
}
}

View File

@@ -0,0 +1,28 @@
@if (result || error) {
<div
class="interaction-panel"
role="dialog"
aria-modal="false"
[attr.aria-label]="result ? result.title : 'Handlung nicht möglich'"
data-interaction-panel
>
@if (result) {
<h2 class="interaction-panel__title">{{ result.title }}</h2>
<p class="interaction-panel__text">{{ result.text }}</p>
} @else {
<h2 class="interaction-panel__title">Nichts zu entdecken</h2>
<p class="interaction-panel__text interaction-panel__text--error" role="alert">
{{ error }}
</p>
}
<button
class="interaction-panel__close"
type="button"
data-interaction-close
(click)="closePanel.emit()"
>
Schließen
</button>
</div>
}

View File

@@ -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);
}

View File

@@ -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<LocationInteractionPanelComponent>;
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<HTMLButtonElement>('[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();
});
});

View File

@@ -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<void>();
}

View File

@@ -0,0 +1,72 @@
<section class="location-page" aria-label="Ortsansicht">
@if (store.location(); as location) {
<div class="location-page__main">
<header class="location-page__header">
<h1 class="location-page__title">{{ location.name }}</h1>
<p class="location-page__breadcrumb">
<span>{{ location.regionTierLabel }}</span>
<span class="location-page__separator" aria-hidden="true"></span>
<span>{{ location.regionName }}</span>
</p>
<p class="location-page__description">{{ location.localDescription }}</p>
</header>
<div class="location-page__scene">
<picture class="location-page__artwork">
@if (runtimeArtwork(location.localArtworkPath); as runtime) {
<source [srcset]="runtime" type="image/jpeg" />
}
<img [src]="location.localArtworkPath" [alt]="'Ortsansicht: ' + location.name" />
</picture>
<div class="location-page__hotspots">
@for (poi of location.pointsOfInterest; track poi.key) {
<app-location-poi
[poi]="poi"
[busy]="busy"
(activate)="activatePoi($event)"
/>
}
</div>
<app-location-interaction-panel
[result]="store.interactionResult()"
[error]="store.interactionError()"
(closePanel)="store.closeInteraction()"
/>
</div>
<nav class="location-page__actions" aria-label="Ortsaktionen">
@for (action of location.primaryActions; track action.key) {
<button
class="location-action"
type="button"
[disabled]="!action.enabled || busy"
[attr.data-action]="action.key"
(click)="activateAction(action)"
>
<app-location-icon [iconKey]="action.iconKey" />
<span class="location-action__label">{{ action.label }}</span>
@if (action.description) {
<span class="location-action__hint">
{{ isPending(action.poiKey ?? action.key) ? 'Einen Moment…' : action.description }}
</span>
}
</button>
}
</nav>
</div>
<app-location-sidebar class="location-page__sidebar" [location]="location" />
} @else if (store.error(); as error) {
<section class="location-page__notice" role="alert">
<p>Ort konnte nicht geladen werden.</p>
<p class="location-page__notice-detail">{{ error }}</p>
<button type="button" data-location-retry (click)="retry()">Erneut versuchen</button>
</section>
} @else {
<section class="location-page__notice" aria-live="polite">
<p role="status">Der Ort wird geladen…</p>
</section>
}
</section>

View File

@@ -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 <img> 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 <picture>, 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;
}
}

View File

@@ -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<typeof signal<CurrentLocationResponse | null>>;
let error: ReturnType<typeof signal<string | null>>;
let interactionResult: ReturnType<typeof signal<LocationInteractionResult | null>>;
let interactionError: ReturnType<typeof signal<string | null>>;
let interactionPending: ReturnType<typeof signal<string | null>>;
let store: {
location: typeof location;
loading: ReturnType<typeof signal<boolean>>;
error: typeof error;
interactionResult: typeof interactionResult;
interactionError: typeof interactionError;
interactionPending: typeof interactionPending;
load: ReturnType<typeof vi.fn>;
runInteraction: ReturnType<typeof vi.fn>;
closeInteraction: ReturnType<typeof vi.fn>;
};
async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) {
location = signal(current);
error = signal<string | null>(null);
interactionResult = signal<LocationInteractionResult | null>(null);
interactionError = signal<string | null>(null);
interactionPending = signal<string | null>(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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<HTMLButtonElement>('[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<CurrentLocationResponse> {
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',
},
],
};
}

View File

@@ -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<Record<string, string>> = {
'/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;
}
}
}

View File

@@ -0,0 +1,17 @@
<button
class="location-poi"
type="button"
[class.location-poi--busy]="busy"
[disabled]="!poi.enabled || busy"
[attr.data-poi]="poi.key"
[attr.aria-label]="poi.actionLabel ? poi.title + ': ' + poi.actionLabel : poi.title"
(click)="onActivate()"
>
<span class="location-poi__medallion">
<app-location-icon [iconKey]="poi.iconKey" />
</span>
<span class="location-poi__title">{{ poi.title }}</span>
@if (poi.actionLabel) {
<span class="location-poi__action">{{ poi.actionLabel }}</span>
}
</button>

View File

@@ -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;
}
}

View File

@@ -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<LocationPoiComponent>;
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');
});
});

View File

@@ -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<LocationPointOfInterest>();
protected onActivate(): void {
if (this.poi.enabled && !this.busy) {
this.activate.emit(this.poi);
}
}
}

View File

@@ -0,0 +1,73 @@
<aside class="location-sidebar" aria-label="Ortsinformationen">
<dl class="location-sidebar__identity">
<div>
<dt>Gebiet</dt>
<dd>{{ location.regionName }}</dd>
</div>
<div>
<dt>Ort</dt>
<dd class="location-sidebar__name">{{ location.name }}</dd>
</div>
<div>
<dt>Ortstyp</dt>
<dd>{{ locationTypeLabel }}</dd>
</div>
<div>
<dt>Empfohlene Stufe</dt>
<dd class="location-sidebar__recommendation">{{ location.recommendationLabel }}</dd>
</div>
<div>
<dt>Gefahr</dt>
<dd>
@if (location.dangerRating; as rating) {
<app-danger-badge [rating]="rating" />
} @else {
<span class="location-sidebar__safe">Sicher</span>
}
</dd>
</div>
</dl>
@if (location.encounterPreview.length) {
<section class="location-sidebar__block" data-sidebar="encounters">
<h3>Mögliche Begegnungen</h3>
<ul class="location-sidebar__encounters">
@for (encounter of location.encounterPreview; track encounter.key) {
<li>
<img [src]="encounter.iconPath" [alt]="''" width="48" height="48" />
<span>{{ encounter.name }}</span>
</li>
}
</ul>
<p class="location-sidebar__note">Vorschau — die Jagd würfelt eigenständig.</p>
</section>
}
@if (interactions.length) {
<section class="location-sidebar__block" data-sidebar="interactions">
<h3>Verfügbare Interaktionen</h3>
<ul class="location-sidebar__interactions">
@for (interaction of interactions; track interaction.label) {
<li>
<app-location-icon [iconKey]="interaction.iconKey" />
<span>{{ interaction.label }}</span>
</li>
}
</ul>
</section>
}
@if (location.rewardPreview.length) {
<section class="location-sidebar__block" data-sidebar="rewards">
<h3>Mögliche Belohnungen</h3>
<ul class="location-sidebar__rewards">
@for (reward of location.rewardPreview; track reward.key) {
<li [attr.title]="reward.label">
<app-location-icon [iconKey]="reward.iconKey" />
<span>{{ reward.label }}</span>
</li>
}
</ul>
</section>
}
</aside>

View File

@@ -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);
}

View File

@@ -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<HTMLElement> {
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('12');
});
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();
});
});

View File

@@ -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<Record<string, string>> = {
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<string, InteractionSummary>();
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()];
}
}

View File

@@ -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<typeof signal<CurrentLocationConnection | null>>;
@@ -63,9 +35,11 @@ describe('WorldPageComponent', () => {
remainingSeconds: ReturnType<typeof signal<number | null>>;
loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>;
arrived: ReturnType<typeof signal<LocationSummary | null>>;
load: () => Promise<void>;
selectConnection: (connection: CurrentLocationConnection | null) => void;
startTravel: () => Promise<void>;
acknowledgeArrival: () => void;
};
beforeEach(async () => {
@@ -77,16 +51,18 @@ describe('WorldPageComponent', () => {
remainingSeconds: signal<number | null>(null),
loading: signal(false),
error: signal<string | null>(null),
arrived: signal<LocationSummary | null>(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();
});
});

View File

@@ -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();

View File

@@ -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<CharacterResponse>();
const pendingLocation = new Subject<CurrentLocationResponse>();

View File

@@ -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<CurrentLocationConnection | null>(null);
private readonly currentTravelState = signal<CurrentTravel | null>(null);
private readonly remainingSecondsState = signal<number | null>(null);
private readonly arrivedState = signal<LocationSummary | null>(null);
private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | 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) {

View File

@@ -1,12 +1,12 @@
<div class="app-shell">
<app-top-bar [character]="worldStore.character()" />
<div class="app-shell__content" [class.app-shell__content--no-context]="inCombat()">
<div class="app-shell__content" [class.app-shell__content--no-context]="!showContextPanel()">
<app-side-navigation />
<main class="app-shell__main" aria-label="Spielinhalt">
<router-outlet />
</main>
@if (!inCombat()) {
@if (showContextPanel()) {
<app-context-panel />
}
</div>

View File

@@ -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();
}

View File

@@ -1,4 +1,22 @@
<nav class="side-navigation" aria-label="Spielnavigation">
<button
class="side-navigation__item"
type="button"
routerLink="/location"
routerLinkActive="side-navigation__item--active"
[routerLinkActiveOptions]="{ exact: true }"
ariaCurrentWhenActive="page"
data-navigation="location"
aria-label="Ort"
>
<!-- Drawn rather than loaded: no medallion for "Ort" has been painted yet.
Framed to sit alongside the existing icons until one exists. -->
<span class="side-navigation__glyph">
<app-location-icon iconKey="travel" />
</span>
<span>Ort</span>
</button>
<button
class="side-navigation__item"
type="button"

View File

@@ -37,6 +37,24 @@
border-radius: 50%;
}
// Same footprint as the painted icons, so a drawn glyph does not shift the row.
.side-navigation__glyph {
display: grid;
flex: none;
place-items: center;
inline-size: 2.25rem;
block-size: 2.25rem;
border: 1px solid var(--ar-border-highlight);
border-radius: 50%;
color: var(--ar-gold);
background: radial-gradient(circle, #221c14 30%, #14171a 78%);
}
.side-navigation__glyph app-location-icon {
inline-size: 1.25rem;
block-size: 1.25rem;
}
.side-navigation__item--active {
border-inline-start-color: var(--ar-blue);
color: var(--ar-text);

View File

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

View File

@@ -16,6 +16,12 @@ describe('monsterCutoutPath', () => {
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', () => {

View File

@@ -30,6 +30,8 @@ const MONSTER_ICON: Readonly<Record<string, string>> = {
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
'ash-rat': 0.46,
'road-bandit': 0.82,
'wild-road-dog': 0.58,
'charred-looter': 0.86,
};
const DEFAULT_MONSTER_SCALE = 0.6;