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 BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; 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 BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; 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 { class InMemoryRepository {
readonly rows: Row[] = []; readonly rows: Row[] = [];
@@ -153,8 +155,8 @@ describe('seedVisibleVerticalSlice', () => {
}), }),
); );
expect(monsterRepository.insert).toHaveBeenCalledTimes(2); expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
expect(monsterRepository.rows).toHaveLength(2); expect(monsterRepository.rows).toHaveLength(4);
expect(monsterRepository.rows).toEqual( expect(monsterRepository.rows).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
@@ -181,6 +183,20 @@ describe('seedVisibleVerticalSlice', () => {
silverMax: 15, silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png', 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({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: ASH_RAT_MONSTER_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({ expect.objectContaining({
locationId: BURNED_ROAD_ID, locationId: BURNED_ROAD_ID,
monsterId: ROAD_BANDIT_MONSTER_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'], ['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 () => { 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 { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity';
import { LocationDefinition } from '../../world/entities/location-definition.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity';
import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content';
import { import {
ASH_RAT_LOOT_TABLE_ID, ASH_RAT_LOOT_TABLE_ID,
ROAD_BANDIT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID,
} from './item.constants'; } 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 { import {
ASH_RAT_MONSTER_ID, ASH_RAT_MONSTER_ID,
BURNED_ROAD_ID, BURNED_ROAD_ID,
CHARRED_LOOTER_MONSTER_ID,
ROAD_BANDIT_MONSTER_ID, ROAD_BANDIT_MONSTER_ID,
SOUTH_GATE_ID, SOUTH_GATE_ID,
WILD_ROAD_DOG_MONSTER_ID,
} from './vertical-slice.constants'; } from './vertical-slice.constants';
export async function seedVisibleVerticalSlice( export async function seedVisibleVerticalSlice(
@@ -47,6 +53,7 @@ export async function seedVisibleVerticalSlice(
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png', artworkPath: '/images/backgrounds/Suedtor.png',
...SOUTH_GATE_LOCAL_CONTENT,
}, },
{ {
id: BURNED_ROAD_ID, id: BURNED_ROAD_ID,
@@ -61,17 +68,14 @@ export async function seedVisibleVerticalSlice(
isSafe: false, isSafe: false,
huntingEnabled: true, huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png', 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) { for (const location of locations) {
const existing = await locationRepository.findOneBy({ const existing = await locationRepository.findOneBy({ key: location.key });
key: location.key,
});
const { id, key, ...definition } = location; const { id, key, ...definition } = location;
const persistedId = existing?.id ?? id;
if (existing) { if (existing) {
await locationRepository.update(existing.id, definition); await locationRepository.update(existing.id, definition);
@@ -79,13 +83,12 @@ export async function seedVisibleVerticalSlice(
await locationRepository.insert(location); await locationRepository.insert(location);
} }
if (key === 'south-gate') { locationIds.set(key, existing?.id ?? id);
southGateId = persistedId;
} else {
burnedRoadId = persistedId;
}
} }
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
await connectionRepository.upsert( await connectionRepository.upsert(
[ [
{ {
@@ -128,6 +131,24 @@ export async function seedVisibleVerticalSlice(
silverMin: 4, silverMin: 4,
silverMax: 7, silverMax: 7,
artworkPath: '/images/monsters/ash-rat.png', 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, lootTableId: ASH_RAT_LOOT_TABLE_ID,
}, },
{ {
@@ -142,18 +163,33 @@ export async function seedVisibleVerticalSlice(
silverMin: 9, silverMin: 9,
silverMax: 15, silverMax: 15,
artworkPath: '/images/monsters/road-bandit.png', 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, 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) { for (const monster of monsters) {
const existingMonster = await monsterRepository.findOneBy({ const existingMonster = await monsterRepository.findOneBy({
key: monster.key, key: monster.key,
}); });
const { id, key, ...definition } = monster; const { id, key, ...definition } = monster;
const persistedId = existingMonster?.id ?? id;
if (existingMonster) { if (existingMonster) {
await monsterRepository.update(existingMonster.id, definition); await monsterRepository.update(existingMonster.id, definition);
@@ -161,30 +197,27 @@ export async function seedVisibleVerticalSlice(
await monsterRepository.insert(monster); await monsterRepository.insert(monster);
} }
if (key === 'ash-rat') { monsterIds.set(key, existingMonster?.id ?? id);
ashRatId = persistedId;
} else {
roadBanditId = persistedId;
}
} }
// 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( await locationMonsterRepository.upsert(
[ Object.entries(encounterWeights).map(([key, weight]) => ({
{
locationId: burnedRoadId, locationId: burnedRoadId,
monsterId: ashRatId, monsterId: monsterIds.get(key) as string,
weight: 70, weight,
encounterType: EncounterType.NORMAL, encounterType: EncounterType.NORMAL,
enabled: true, enabled: true,
}, })),
{
locationId: burnedRoadId,
monsterId: roadBanditId,
weight: 30,
encounterType: EncounterType.NORMAL,
enabled: true,
},
],
['locationId', 'monsterId'], ['locationId', 'monsterId'],
); );

View File

@@ -46,6 +46,12 @@ export class MonsterDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 }) @Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string; 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 }) @Column({ name: 'loot_table_id', type: 'uuid', nullable: true })
lootTableId!: string | null; lootTableId!: string | null;

View File

@@ -8,6 +8,12 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } from 'typeorm';
import { Character } from '../../characters/entities/character.entity'; import { Character } from '../../characters/entities/character.entity';
import type {
LocationPointOfInterestContent,
LocationPrimaryActionContent,
LocationRewardPreviewContent,
LocationType,
} from '../local-location.types';
import { LocationConnection } from './location-connection.entity'; import { LocationConnection } from './location-connection.entity';
@Entity({ name: 'location_definitions' }) @Entity({ name: 'location_definitions' })
@@ -46,6 +52,35 @@ export class LocationDefinition {
@Column({ name: 'artwork_path', type: 'varchar', length: 255 }) @Column({ name: 'artwork_path', type: 'varchar', length: 255 })
artworkPath!: string; 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' }) @CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
createdAt!: Date; 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 { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
import { LocationInteractionResultDto } from './local-location.types';
import { WorldService } from './world.service'; import { WorldService } from './world.service';
@Controller('world') @Controller('world')
@@ -10,4 +11,19 @@ export class WorldController {
getCurrentLocation() { getCurrentLocation() {
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID); 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 { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity'; import { LocationDefinition } from './entities/location-definition.entity';
import type {
LocationPointOfInterestContent,
LocationPrimaryActionContent,
} from './local-location.types';
import { WorldService } from './world.service'; import { WorldService } from './world.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; 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 { function currentLocation(): LocationDefinition {
return { return {
id: SOUTH_GATE_ID, id: SOUTH_GATE_ID,
@@ -27,6 +139,14 @@ function currentLocation(): LocationDefinition {
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp', 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'), createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [], characters: [],
@@ -48,6 +168,14 @@ function burnedRoad(): LocationDefinition {
isSafe: false, isSafe: false,
huntingEnabled: true, huntingEnabled: true,
artworkPath: '/assets/locations/burned-road.webp', 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'), createdAt: new Date('2026-08-18T09:00:00.000Z'),
updatedAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'),
characters: [], characters: [],
@@ -69,11 +197,7 @@ describe('WorldService', () => {
} as unknown as TravelService; } as unknown as TravelService;
const findCharacter = jest.fn().mockImplementation(() => { const findCharacter = jest.fn().mockImplementation(() => {
callOrder.push('findCharacter'); callOrder.push('findCharacter');
return Promise.resolve({ return Promise.resolve(character(SOUTH_GATE_ID, location));
id: CHARACTER_ID,
currentLocationId: SOUTH_GATE_ID,
currentLocation: location,
});
}); });
const characters = { const characters = {
findOne: findCharacter, findOne: findCharacter,
@@ -130,6 +254,28 @@ describe('WorldService', () => {
isSafe: true, isSafe: true,
huntingEnabled: false, huntingEnabled: false,
artworkPath: '/assets/locations/south-gate.webp', 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: [ connections: [
{ {
targetLocation: { targetLocation: {
@@ -160,21 +306,12 @@ describe('WorldService', () => {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService; } as unknown as TravelService;
const characters = { const characters = {
findOne: jest.fn().mockResolvedValue({ findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
id: CHARACTER_ID,
currentLocationId: BURNED_ROAD_ID,
currentLocation: location,
}),
} as unknown as Repository<Character>; } as unknown as Repository<Character>;
const connections = { const connections = {
find: jest.fn().mockResolvedValue([]), find: jest.fn().mockResolvedValue([]),
} as unknown as Repository<LocationConnection>; } as unknown as Repository<LocationConnection>;
const findLocationMonsters = jest const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
.fn()
.mockResolvedValue([
{ monster: { name: 'Aschenratte' } },
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
]);
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
} as unknown as Repository<LocationMonster>; } as unknown as Repository<LocationMonster>;
@@ -230,4 +367,131 @@ describe('WorldService', () => {
expect(findConnections).not.toHaveBeenCalled(); expect(findConnections).not.toHaveBeenCalled();
expect(findLocationMonsters).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 { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm'; import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity'; import { Character } from '../characters/entities/character.entity';
import {
calculateDangerRating,
DangerRating,
} from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { LocationConnection } from './entities/location-connection.entity'; 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 { export interface LocationSummary {
id: string; id: string;
@@ -30,6 +45,18 @@ export interface CurrentLocationResponse {
isSafe: boolean; isSafe: boolean;
huntingEnabled: boolean; huntingEnabled: boolean;
artworkPath: string; 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[]; connections: CurrentLocationConnection[];
possibleMonsters: string[]; possibleMonsters: string[];
} }
@@ -49,15 +76,7 @@ export class WorldService {
async getCurrentLocation( async getCurrentLocation(
characterId: string, characterId: string,
): Promise<CurrentLocationResponse> { ): Promise<CurrentLocationResponse> {
await this.travelService.completeTravelIfDue(characterId); const character = await this.loadCharacterAtCurrentLocation(characterId);
const character = await this.characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Character not found.');
}
const connections = await this.connections.find({ const connections = await this.connections.find({
where: { fromLocationId: character.currentLocationId, enabled: true }, where: { fromLocationId: character.currentLocationId, enabled: true },
@@ -65,8 +84,8 @@ export class WorldService {
}); });
const location = character.currentLocation; const location = character.currentLocation;
const possibleMonsters = location.huntingEnabled const pool = location.huntingEnabled
? await this.getPossibleMonsters(location.id) ? await this.getEncounterPool(location.id)
: []; : [];
return { return {
@@ -81,6 +100,24 @@ export class WorldService {
isSafe: location.isSafe, isSafe: location.isSafe,
huntingEnabled: location.huntingEnabled, huntingEnabled: location.huntingEnabled,
artworkPath: location.artworkPath, 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 connections: connections
.filter((connection) => connection.enabled) .filter((connection) => connection.enabled)
.map((connection) => ({ .map((connection) => ({
@@ -92,17 +129,102 @@ export class WorldService {
travelDurationSeconds: connection.travelDurationSeconds, travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance), 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 }, where: { locationId, enabled: true },
relations: { monster: true }, relations: { monster: true },
order: { weight: 'DESC' }, 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' { 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'; import { AppShellComponent } from './layout/app-shell/app-shell.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', pathMatch: 'full', redirectTo: 'world' }, { path: '', pathMatch: 'full', redirectTo: 'location' },
{ {
path: '', path: '',
component: AppShellComponent, component: AppShellComponent,
children: [ children: [
{
path: 'location',
loadComponent: () =>
import('./features/world/location-page/location-page.component').then(
(module) => module.LocationPageComponent,
),
},
{ {
path: 'world', path: 'world',
loadComponent: () => 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], imports: [AppShellComponent],
providers: [ providers: [
provideRouter([ provideRouter([
{ path: 'location', children: [] },
{ path: 'world', children: [] }, { path: 'world', children: [] },
{ path: 'hunt', children: [] }, { path: 'hunt', children: [] },
]), ]),
@@ -65,6 +66,55 @@ describe('App', () => {
expect(element.textContent).not.toContain('Shop'); 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 () => { it('marks Jagd as the active navigation entry while on /hunt', async () => {
const fixture = TestBed.createComponent(AppShellComponent); const fixture = TestBed.createComponent(AppShellComponent);
const router = TestBed.inject(Router); const router = TestBed.inject(Router);
@@ -104,11 +154,19 @@ describe('App', () => {
expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); 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({ expect(routes.find((route) => route.path === '')).toMatchObject({
pathMatch: 'full', 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'; 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 { export interface CurrentLocationResponse {
id: string; id: string;
key: string; key: string;
@@ -34,6 +97,18 @@ export interface CurrentLocationResponse {
isSafe: boolean; isSafe: boolean;
huntingEnabled: boolean; huntingEnabled: boolean;
artworkPath: string; 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[]; connections: CurrentLocationConnection[];
possibleMonsters: string[]; possibleMonsters: string[];
} }

View File

@@ -48,6 +48,24 @@ describe('GameApiService', () => {
request.flush({ status: 'IDLE' }); 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', () => { it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => {
service.startCombat('encounter-uuid').subscribe(); service.startCombat('encounter-uuid').subscribe();

View File

@@ -8,6 +8,7 @@ import {
CurrentLocationResponse, CurrentLocationResponse,
CurrentTravel, CurrentTravel,
HuntResult, HuntResult,
LocationInteractionResult,
} from './game-api.models'; } from './game-api.models';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -22,6 +23,17 @@ export class GameApiService {
return this.http.get<CurrentLocationResponse>('/api/world/current-location'); 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> { startTravel(targetLocationId: string): Observable<CurrentTravel> {
return this.http.post<CurrentTravel>('/api/travel', { targetLocationId }); return this.http.post<CurrentTravel>('/api/travel', { targetLocationId });
} }

View File

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

View File

@@ -490,6 +490,18 @@
margin-block-start: var(--ar-space-2); 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, .outcome__button:hover,
.combat__notice--error button:hover { .combat__notice--error button:hover {
border-color: var(--ar-gold); border-color: var(--ar-gold);

View File

@@ -266,7 +266,7 @@ describe('CombatPageComponent', () => {
expect(element.querySelector('[data-combat-attack]')).toBeNull(); 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 fixture = await setup({ ...activeCombat, status: 'WON' });
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -275,6 +275,25 @@ describe('CombatPageComponent', () => {
expect(router.navigate).toHaveBeenCalledWith(['/hunt']); 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 () => { it('shows an error and retries loading the combat', async () => {
const fixture = await setup(null); const fixture = await setup(null);
combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); combatStore.error.set('Dieser Kampf wurde nicht gefunden.');

View File

@@ -161,6 +161,13 @@ export class CombatPageComponent implements OnInit {
void this.router.navigate(['/hunt']); 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 { protected monsterSprite(monsterKey: string, artworkPath: string): string {
return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; 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 Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres
Gebiet, um nach Gegnern zu suchen. Gebiet, um nach Gegnern zu suchen.
</p> </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> </section>
} @else if (huntingStore.currentHunt(); as hunt) { } @else if (huntingStore.currentHunt(); as hunt) {
<section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name"> <section class="hunt-page__results" [attr.aria-label]="'Begegnungen bei ' + location.name">
@@ -28,7 +28,7 @@
> >
Neu suchen Neu suchen
</button> </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> </div>
</section> </section>
} @else { } @else {

View File

@@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models';
import { CombatStore } from '../../combat/combat.store'; import { CombatStore } from '../../combat/combat.store';
import {
burnedRoadFixture,
southGateFixture,
} from '../../world/current-location.fixture';
import { WorldStore } from '../../world/world.store'; import { WorldStore } from '../../world/world.store';
import { HuntingStore } from '../hunting.store'; import { HuntingStore } from '../hunting.store';
import { HuntPageComponent } from './hunt-page.component'; import { HuntPageComponent } from './hunt-page.component';
const southGate: CurrentLocationResponse = { const southGate = southGateFixture({ connections: [] });
id: 'south-gate-id',
key: 'south-gate',
name: 'Südtor von Graufurt',
description: 'Der letzte sichere Schritt vor den Aschenfeldern.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
connections: [],
possibleMonsters: [],
};
const burnedRoad: CurrentLocationResponse = { const burnedRoad = burnedRoadFixture({ connections: [] });
...southGate,
id: 'burned-road-id',
key: 'burned-road',
name: 'Verbrannte Straße',
description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.',
isSafe: false,
huntingEnabled: true,
artworkPath: '/images/backgrounds/Aschestrasse.png',
possibleMonsters: ['Aschenratte', 'Straßenräuber'],
connections: [],
};
const threeEncounterHunt: HuntResult = { const threeEncounterHunt: HuntResult = {
id: 'hunt-id', id: 'hunt-id',
@@ -150,7 +129,7 @@ describe('HuntPageComponent', () => {
return fixture; 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 fixture = await setup(southGate);
const element = fixture.nativeElement as HTMLElement; const element = fixture.nativeElement as HTMLElement;
@@ -162,11 +141,11 @@ describe('HuntPageComponent', () => {
), ),
).toBe(false); ).toBe(false);
const toWorldButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-world]'); const backButton = element.querySelector<HTMLButtonElement>('[data-hunt-to-location]');
expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte'); expect(backButton?.textContent?.trim()).toBe('Zurück zum Ort');
toWorldButton?.click(); 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 () => { 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 { // Back out of the hunt returns to the place the hunt happens in, not to the
void this.router.navigate(['/world']); // 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> { 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 { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { Router, provideRouter } from '@angular/router';
import { vi } from 'vitest'; import { vi } from 'vitest';
import type { import type {
CurrentLocationConnection, CurrentLocationConnection,
CurrentLocationResponse, CurrentLocationResponse,
CurrentTravel, CurrentTravel,
LocationSummary,
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
import { burnedRoadFixture, southGateFixture } from './current-location.fixture';
import { WorldStore } from './world.store'; import { WorldStore } from './world.store';
import { WorldPageComponent } from './world-page.component'; import { WorldPageComponent } from './world-page.component';
@@ -19,40 +22,9 @@ const burnedRoadConnection: CurrentLocationConnection = {
danger: 'LOW', danger: 'LOW',
}; };
const southGate: CurrentLocationResponse = { const southGate = southGateFixture({ connections: [burnedRoadConnection] });
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 burnedRoad: CurrentLocationResponse = { const burnedRoad = burnedRoadFixture();
...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',
},
],
};
describe('WorldPageComponent', () => { describe('WorldPageComponent', () => {
let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>; let selectedConnection: ReturnType<typeof signal<CurrentLocationConnection | null>>;
@@ -63,9 +35,11 @@ describe('WorldPageComponent', () => {
remainingSeconds: ReturnType<typeof signal<number | null>>; remainingSeconds: ReturnType<typeof signal<number | null>>;
loading: ReturnType<typeof signal<boolean>>; loading: ReturnType<typeof signal<boolean>>;
error: ReturnType<typeof signal<string | null>>; error: ReturnType<typeof signal<string | null>>;
arrived: ReturnType<typeof signal<LocationSummary | null>>;
load: () => Promise<void>; load: () => Promise<void>;
selectConnection: (connection: CurrentLocationConnection | null) => void; selectConnection: (connection: CurrentLocationConnection | null) => void;
startTravel: () => Promise<void>; startTravel: () => Promise<void>;
acknowledgeArrival: () => void;
}; };
beforeEach(async () => { beforeEach(async () => {
@@ -77,16 +51,18 @@ describe('WorldPageComponent', () => {
remainingSeconds: signal<number | null>(null), remainingSeconds: signal<number | null>(null),
loading: signal(false), loading: signal(false),
error: signal<string | null>(null), error: signal<string | null>(null),
arrived: signal<LocationSummary | null>(null),
load: vi.fn(() => Promise.resolve()), load: vi.fn(() => Promise.resolve()),
selectConnection: vi.fn((connection: CurrentLocationConnection | null) => selectConnection: vi.fn((connection: CurrentLocationConnection | null) =>
selectedConnection.set(connection), selectedConnection.set(connection),
), ),
startTravel: vi.fn(() => Promise.resolve()), startTravel: vi.fn(() => Promise.resolve()),
acknowledgeArrival: vi.fn(() => store.arrived.set(null)),
}; };
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [WorldPageComponent], imports: [WorldPageComponent],
providers: [{ provide: WorldStore, useValue: store }], providers: [provideRouter([]), { provide: WorldStore, useValue: store }],
}).compileComponents(); }).compileComponents();
}); });
@@ -206,4 +182,40 @@ describe('WorldPageComponent', () => {
expect(store.load).toHaveBeenCalledTimes(2); 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 { CurrentLocationConnection } from '../../core/api/game-api.models';
import { LocationNodeComponent } from './location-node.component'; import { LocationNodeComponent } from './location-node.component';
import { TravelPanelComponent } from './travel-panel.component'; import { TravelPanelComponent } from './travel-panel.component';
@@ -12,6 +13,18 @@ import { WorldStore } from './world.store';
}) })
export class WorldPageComponent implements OnInit { export class WorldPageComponent implements OnInit {
protected readonly worldStore = inject(WorldStore); 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 { ngOnInit(): void {
void this.worldStore.load(); void this.worldStore.load();

View File

@@ -8,6 +8,7 @@ import type {
CurrentTravel, CurrentTravel,
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service'; import { GameApiService } from '../../core/api/game-api.service';
import { southGateFixture } from './current-location.fixture';
import { WorldStore } from './world.store'; import { WorldStore } from './world.store';
const character: CharacterResponse = { const character: CharacterResponse = {
@@ -22,19 +23,11 @@ const character: CharacterResponse = {
currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' },
}; };
const currentLocation: CurrentLocationResponse = { const currentLocation = southGateFixture({
id: 'origin-id', id: 'origin-id',
key: 'south-gate',
name: 'Südtor', name: 'Südtor',
description: 'Der Ausgang zur Wildnis.', description: 'Der Ausgang zur Wildnis.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 1, dangerLevel: 1,
isSafe: true,
huntingEnabled: false,
artworkPath: '/images/backgrounds/Suedtor.png',
possibleMonsters: [],
connections: [ connections: [
{ {
targetLocation: { targetLocation: {
@@ -46,7 +39,7 @@ const currentLocation: CurrentLocationResponse = {
danger: 'LOW', danger: 'LOW',
}, },
], ],
}; });
const travelling: CurrentTravel = { const travelling: CurrentTravel = {
status: 'TRAVELLING', status: 'TRAVELLING',
@@ -208,6 +201,32 @@ describe('WorldStore', () => {
expect(api.getCurrentLocation).toHaveBeenCalledTimes(2); 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 () => { it('clears selection and rejects a second start while authoritative completion reload is pending', async () => {
const pendingCharacter = new Subject<CharacterResponse>(); const pendingCharacter = new Subject<CharacterResponse>();
const pendingLocation = new Subject<CurrentLocationResponse>(); const pendingLocation = new Subject<CurrentLocationResponse>();

View File

@@ -6,6 +6,7 @@ import {
CurrentLocationConnection, CurrentLocationConnection,
CurrentLocationResponse, CurrentLocationResponse,
CurrentTravel, CurrentTravel,
LocationSummary,
} from '../../core/api/game-api.models'; } from '../../core/api/game-api.models';
import { GameApiService } from '../../core/api/game-api.service'; 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 selectedConnectionState = signal<CurrentLocationConnection | null>(null);
private readonly currentTravelState = signal<CurrentTravel | null>(null); private readonly currentTravelState = signal<CurrentTravel | null>(null);
private readonly remainingSecondsState = signal<number | null>(null); private readonly remainingSecondsState = signal<number | null>(null);
private readonly arrivedState = signal<LocationSummary | null>(null);
private readonly loadingState = signal(false); private readonly loadingState = signal(false);
private readonly errorState = signal<string | null>(null); private readonly errorState = signal<string | null>(null);
private countdownTimer: ReturnType<typeof setInterval> | undefined; private countdownTimer: ReturnType<typeof setInterval> | undefined;
@@ -39,6 +41,8 @@ export class WorldStore implements OnDestroy {
readonly selectedConnection = this.selectedConnectionState.asReadonly(); readonly selectedConnection = this.selectedConnectionState.asReadonly();
readonly currentTravel = this.currentTravelState.asReadonly(); readonly currentTravel = this.currentTravelState.asReadonly();
readonly remainingSeconds = this.remainingSecondsState.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 loading = this.loadingState.asReadonly();
readonly error = this.errorState.asReadonly(); readonly error = this.errorState.asReadonly();
@@ -77,6 +81,11 @@ export class WorldStore implements OnDestroy {
this.selectedConnectionState.set(connection); 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 * 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). * silver. Never mutates the values locally: the server owns them (spec §35).
@@ -172,6 +181,10 @@ export class WorldStore implements OnDestroy {
await this.reloadAuthoritativeState(); await this.reloadAuthoritativeState();
if (!this.destroyed) { if (!this.destroyed) {
this.currentTravelState.set({ status: 'IDLE' }); 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 { } finally {
if (!this.destroyed) { if (!this.destroyed) {

View File

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

View File

@@ -19,9 +19,18 @@ import { TopBarComponent } from '../top-bar/top-bar.component';
styleUrl: './app-shell.component.scss', styleUrl: './app-shell.component.scss',
}) })
export class AppShellComponent { export class AppShellComponent {
private readonly router = inject(Router);
protected readonly worldStore = inject(WorldStore); protected readonly worldStore = inject(WorldStore);
// The fight has its own log rail and wants the width, and the area info // 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. // 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"> <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 <button
class="side-navigation__item" class="side-navigation__item"
type="button" type="button"

View File

@@ -37,6 +37,24 @@
border-radius: 50%; 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 { .side-navigation__item--active {
border-inline-start-color: var(--ar-blue); border-inline-start-color: var(--ar-blue);
color: var(--ar-text); color: var(--ar-text);

View File

@@ -1,9 +1,10 @@
import { Component } from '@angular/core'; import { Component } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router'; import { RouterLink, RouterLinkActive } from '@angular/router';
import { LocationIconComponent } from '../../features/world/location-icon/location-icon.component';
@Component({ @Component({
selector: 'app-side-navigation', selector: 'app-side-navigation',
imports: [RouterLink, RouterLinkActive], imports: [LocationIconComponent, RouterLink, RouterLinkActive],
templateUrl: './side-navigation.component.html', templateUrl: './side-navigation.component.html',
styleUrl: './side-navigation.component.scss', 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', () => { 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('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png');
expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.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', () => { 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', () => { it('returns the medallion icon for a known monster key', () => {
expect(monsterIconPath('ash-rat')).toBe('/images/combat/icons/ash-rat-128.png'); 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('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', () => { 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>> = { const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
'ash-rat': 0.46, 'ash-rat': 0.46,
'road-bandit': 0.82, 'road-bandit': 0.82,
'wild-road-dog': 0.58,
'charred-looter': 0.86,
}; };
const DEFAULT_MONSTER_SCALE = 0.6; const DEFAULT_MONSTER_SCALE = 0.6;