diff --git a/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts new file mode 100644 index 0000000..4d234b5 --- /dev/null +++ b/apps/api/src/database/migrations/1788700000000-CreateLocalLocationView.ts @@ -0,0 +1,82 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Adds the local location view content to `location_definitions`. + * + * The existing `description`/`artwork_path` columns are deliberately left + * alone — the map and hunt screens still render them. Backfill defaults keep + * existing rows valid; the seed replaces them with authored content. + */ +export class CreateLocalLocationView1788700000000 + implements MigrationInterface +{ + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'', + ); + + // Existing rows fall back to their map-level content until the seed runs, + // so the view never renders an empty breadcrumb or a missing artwork. + await queryRunner.query( + 'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'', + ); + + await queryRunner.query( + 'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'', + ); + await queryRunner.query( + 'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'', + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + 'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "local_description"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "location_type"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"', + ); + await queryRunner.query( + 'ALTER TABLE "location_definitions" DROP COLUMN "region_name"', + ); + } +} diff --git a/apps/api/src/database/migrations/local-location-view.migration.spec.ts b/apps/api/src/database/migrations/local-location-view.migration.spec.ts new file mode 100644 index 0000000..0d3bafe --- /dev/null +++ b/apps/api/src/database/migrations/local-location-view.migration.spec.ts @@ -0,0 +1,71 @@ +import 'reflect-metadata'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { getMetadataArgsStorage } from 'typeorm'; +import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; +import { LocationDefinition } from '../../world/entities/location-definition.entity'; + +const MIGRATION_SQL = readFileSync( + join(__dirname, '1788700000000-CreateLocalLocationView.ts'), + 'utf8', +); + +function columnNames(target: unknown): string[] { + return getMetadataArgsStorage() + .columns.filter((column) => column.target === target) + .map((column) => column.options.name) + .filter((name): name is string => typeof name === 'string'); +} + +describe('local location view schema', () => { + const newLocationColumns = [ + 'region_name', + 'region_tier_label', + 'location_type', + 'local_description', + 'local_artwork_path', + 'local_points_of_interest', + 'local_primary_actions', + 'local_reward_preview', + ]; + + it.each(newLocationColumns)( + 'maps %s on LocationDefinition', + (name: string) => { + expect(columnNames(LocationDefinition)).toContain(name); + }, + ); + + it.each(newLocationColumns)('adds %s in the migration', (name: string) => { + expect(MIGRATION_SQL).toContain( + `ALTER TABLE "location_definitions" ADD COLUMN "${name}"`, + ); + expect(MIGRATION_SQL).toContain( + `ALTER TABLE "location_definitions" DROP COLUMN "${name}"`, + ); + }); + + it('adds the monster icon path in both the entity and the migration', () => { + expect(columnNames(MonsterDefinition)).toContain('icon_path'); + expect(MIGRATION_SQL).toContain( + 'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"', + ); + expect(MIGRATION_SQL).toContain( + 'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"', + ); + }); + + it('keeps the map-level columns untouched so the world screen is unaffected', () => { + expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"'); + expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"'); + expect(columnNames(LocationDefinition)).toEqual( + expect.arrayContaining(['description', 'artwork_path', 'region_key']), + ); + }); + + it('backfills existing rows so no location renders an empty breadcrumb', () => { + expect(MIGRATION_SQL).toContain( + 'UPDATE "location_definitions" SET "region_name" = "region_key"', + ); + }); +}); diff --git a/apps/api/src/database/seeds/local-location.content.ts b/apps/api/src/database/seeds/local-location.content.ts new file mode 100644 index 0000000..1a18927 --- /dev/null +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -0,0 +1,210 @@ +import type { + LocationPointOfInterestContent, + LocationPrimaryActionContent, + LocationRewardPreviewContent, + LocationType, +} from '../../world/local-location.types'; + +/** + * Authored local-view content per location (plan §7–§11). + * + * Coordinates are percentages of the artwork box, so a hotspot stays on the + * same painted detail at every viewport width. They are tuned against the real + * artwork in `apps/web/public/images/backgrounds/`, not against the + * composition mockup in `docs/references/`. + */ +export interface LocalLocationContent { + regionName: string; + regionTierLabel: string; + locationType: LocationType; + localDescription: string; + localArtworkPath: string; + localPointsOfInterest: LocationPointOfInterestContent[]; + localPrimaryActions: LocationPrimaryActionContent[]; + localRewardPreview: LocationRewardPreviewContent[]; +} + +export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = { + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'HUNTING_GROUND', + localDescription: + 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde. Verbrannte Karren, zerbrochene Waffen und verstummte Schreie säumen den Pfad in die Aschenfelder.', + localArtworkPath: '/images/backgrounds/Aschestrasse.png', + // Anchored to painted detail in `Aschestrasse.png`: the burning horizon down + // the road, the standing gravestone on the left verge, the cracked stones in + // the near foreground, and the broken cart wheel on the right. + localPointsOfInterest: [ + { + key: 'hunt-area', + title: 'Jagdgebiet', + actionLabel: 'Jagd beginnen', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 57, + yPercent: 33, + enabled: true, + }, + { + key: 'inspect-tracks', + title: 'Verdächtige Spuren', + actionLabel: 'Untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 40, + yPercent: 82, + enabled: true, + resultTitle: 'Verdächtige Spuren', + resultText: + 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.', + }, + { + key: 'search-abandoned-wagon', + title: 'Verlassener Wagen', + actionLabel: 'Durchsuchen', + type: 'SEARCH', + iconKey: 'search', + xPercent: 86, + yPercent: 70, + enabled: true, + resultTitle: 'Verlassener Wagen', + resultText: + 'Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.', + }, + { + key: 'wounded-scout', + title: 'Verwundeter Kundschafter', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 17, + yPercent: 62, + enabled: true, + resultTitle: 'Verwundeter Kundschafter', + resultText: + '„Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen."', + }, + ], + localPrimaryActions: [ + { + key: 'start-hunt', + label: 'Jagd beginnen', + description: 'Im Gebiet jagen', + type: 'HUNT', + iconKey: 'hunt', + enabled: true, + }, + { + key: 'investigate-tracks', + label: 'Spuren untersuchen', + description: 'Hinweise finden', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'inspect-tracks', + }, + { + key: 'search-surroundings', + label: 'Umgebung durchsuchen', + description: 'Beute finden', + type: 'SEARCH', + iconKey: 'search', + enabled: true, + poiKey: 'search-abandoned-wagon', + }, + { + key: 'open-map', + label: 'Zur Karte', + description: 'Gebiet wechseln', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, + ], + // Only categories the loot tables on this road actually back: silver and + // experience from every kill, gear from the raiders, pelts from the beasts. + // Named items stay out — the view may not promise a drop the roll does not + // guarantee (spec §8, "Mögliche Belohnungen"). + localRewardPreview: [ + { key: 'silver', label: 'Silber', iconKey: 'silver' }, + { key: 'experience', label: 'Erfahrung', iconKey: 'experience' }, + { key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' }, + { key: 'material', label: 'Material', iconKey: 'material' }, + ], +}; + +export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = { + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'TRANSITION', + localDescription: + 'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.', + localArtworkPath: '/images/backgrounds/Suedtor.png', + localPointsOfInterest: [ + { + key: 'gate-notice', + title: 'Aushangtafel', + actionLabel: 'Lesen', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 22, + yPercent: 42, + enabled: true, + resultTitle: 'Aushangtafel', + resultText: + 'Verwitterte Anschläge flattern im Wind. Ein frischer Zettel warnt vor Plünderern auf der Verbrannten Straße und verspricht Silber für jeden erlegten Räuber.', + }, + { + key: 'gate-watch', + title: 'Torwache', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 45, + yPercent: 52, + enabled: true, + resultTitle: 'Torwache', + resultText: + '„Hinter dem Tor endet Graufurts Schutz. Wer nach Süden geht, geht auf eigene Gefahr — und kommt selten so zurück, wie er gegangen ist."', + }, + { + key: 'south-road', + title: 'Straße nach Süden', + actionLabel: 'Zur Karte', + type: 'MAP', + iconKey: 'map', + xPercent: 66, + yPercent: 72, + enabled: true, + }, + ], + localPrimaryActions: [ + { + key: 'talk-to-watch', + label: 'Wache ansprechen', + description: 'Lage erfragen', + type: 'NPC', + iconKey: 'speak', + enabled: true, + poiKey: 'gate-watch', + }, + { + key: 'read-notice', + label: 'Aushang lesen', + description: 'Hinweise finden', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'gate-notice', + }, + { + key: 'open-map', + label: 'Zur Karte', + description: 'Gebiet wechseln', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, + ], + localRewardPreview: [], +}; diff --git a/apps/api/src/database/seeds/vertical-slice.constants.ts b/apps/api/src/database/seeds/vertical-slice.constants.ts index b7b8248..45c0543 100644 --- a/apps/api/src/database/seeds/vertical-slice.constants.ts +++ b/apps/api/src/database/seeds/vertical-slice.constants.ts @@ -2,3 +2,5 @@ export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001'; export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; +export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003'; +export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004'; diff --git a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts index c24015f..4628510 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -17,6 +17,8 @@ const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001'; const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002'; const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001'; const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002'; +const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003'; +const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004'; class InMemoryRepository { readonly rows: Row[] = []; @@ -153,8 +155,8 @@ describe('seedVisibleVerticalSlice', () => { }), ); - expect(monsterRepository.insert).toHaveBeenCalledTimes(2); - expect(monsterRepository.rows).toHaveLength(2); + expect(monsterRepository.insert).toHaveBeenCalledTimes(4); + expect(monsterRepository.rows).toHaveLength(4); expect(monsterRepository.rows).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -181,6 +183,20 @@ describe('seedVisibleVerticalSlice', () => { silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', }), + expect.objectContaining({ + key: 'wild-road-dog', + name: 'Verwilderter Straßenhund', + level: 1, + artworkPath: '/images/monsters/wild-road-dog.png', + iconPath: '/images/combat/icons/wild-road-dog-128.png', + }), + expect.objectContaining({ + key: 'charred-looter', + name: 'Verkohlter Plünderer', + level: 2, + artworkPath: '/images/monsters/charred-looter.png', + iconPath: '/images/combat/icons/charred-looter-128.png', + }), ]), ); @@ -189,17 +205,114 @@ describe('seedVisibleVerticalSlice', () => { expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ASH_RAT_MONSTER_ID, - weight: 70, + weight: 40, + }), + expect.objectContaining({ + locationId: BURNED_ROAD_ID, + monsterId: WILD_ROAD_DOG_MONSTER_ID, + weight: 30, }), expect.objectContaining({ locationId: BURNED_ROAD_ID, monsterId: ROAD_BANDIT_MONSTER_ID, - weight: 30, + weight: 20, + }), + expect.objectContaining({ + locationId: BURNED_ROAD_ID, + monsterId: CHARRED_LOOTER_MONSTER_ID, + weight: 10, }), ]), ['locationId', 'monsterId'], ); - expect(locationMonsterRepository.rows).toHaveLength(2); + expect(locationMonsterRepository.rows).toHaveLength(4); + }); + + it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => { + const locationRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + ); + + await seedVisibleVerticalSlice(dataSource); + + const burnedRoad = locationRepository.rows.find( + (row) => row.key === 'burned-road', + ) as Row; + + expect(burnedRoad).toEqual( + expect.objectContaining({ + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'HUNTING_GROUND', + localArtworkPath: '/images/backgrounds/Aschestrasse.png', + }), + ); + expect(burnedRoad.localDescription).toContain( + 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.', + ); + + const pointsOfInterest = burnedRoad.localPointsOfInterest as { + key: string; + type: string; + }[]; + expect(pointsOfInterest.map((poi) => poi.key)).toEqual([ + 'hunt-area', + 'inspect-tracks', + 'search-abandoned-wagon', + 'wounded-scout', + ]); + expect(pointsOfInterest.map((poi) => poi.type)).toEqual([ + 'HUNT', + 'INVESTIGATE', + 'SEARCH', + 'NPC', + ]); + + const primaryActions = burnedRoad.localPrimaryActions as { + label: string; + }[]; + expect(primaryActions.map((action) => action.label)).toEqual([ + 'Jagd beginnen', + 'Spuren untersuchen', + 'Umgebung durchsuchen', + 'Zur Karte', + ]); + }); + + it('gives the Südtor its own local content so a second location needs no new component', async () => { + const locationRepository = new InMemoryRepository(); + const dataSource = createDataSource( + locationRepository, + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + new InMemoryRepository(), + ); + + await seedVisibleVerticalSlice(dataSource); + + const southGate = locationRepository.rows.find( + (row) => row.key === 'south-gate', + ) as Row; + + expect(southGate).toEqual( + expect.objectContaining({ + locationType: 'TRANSITION', + localArtworkPath: '/images/backgrounds/Suedtor.png', + }), + ); + expect(southGate.localPointsOfInterest).toHaveLength(3); + // A transition location offers no hunt, so no HUNT hotspot may appear. + expect( + (southGate.localPointsOfInterest as { type: string }[]).some( + (poi) => poi.type === 'HUNT', + ), + ).toBe(false); }); it('preserves existing location IDs and uses them for the directed connections', async () => { diff --git a/apps/api/src/database/seeds/vertical-slice.seed.ts b/apps/api/src/database/seeds/vertical-slice.seed.ts index 92add28..36176c4 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -9,16 +9,22 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; import { ASH_RAT_LOOT_TABLE_ID, ROAD_BANDIT_LOOT_TABLE_ID, } from './item.constants'; -import { ITEM_DEFINITIONS, LOOT_TABLES, LOOT_TABLE_ENTRIES } from './item-content'; +import { + BURNED_ROAD_LOCAL_CONTENT, + SOUTH_GATE_LOCAL_CONTENT, +} from './local-location.content'; import { ASH_RAT_MONSTER_ID, BURNED_ROAD_ID, + CHARRED_LOOTER_MONSTER_ID, ROAD_BANDIT_MONSTER_ID, SOUTH_GATE_ID, + WILD_ROAD_DOG_MONSTER_ID, } from './vertical-slice.constants'; export async function seedVisibleVerticalSlice( @@ -47,6 +53,7 @@ export async function seedVisibleVerticalSlice( isSafe: true, huntingEnabled: false, artworkPath: '/images/backgrounds/Suedtor.png', + ...SOUTH_GATE_LOCAL_CONTENT, }, { id: BURNED_ROAD_ID, @@ -61,17 +68,14 @@ export async function seedVisibleVerticalSlice( isSafe: false, huntingEnabled: true, artworkPath: '/images/backgrounds/Aschestrasse.png', + ...BURNED_ROAD_LOCAL_CONTENT, }, ]; - let southGateId = SOUTH_GATE_ID; - let burnedRoadId = BURNED_ROAD_ID; + const locationIds = new Map(); for (const location of locations) { - const existing = await locationRepository.findOneBy({ - key: location.key, - }); + const existing = await locationRepository.findOneBy({ key: location.key }); const { id, key, ...definition } = location; - const persistedId = existing?.id ?? id; if (existing) { await locationRepository.update(existing.id, definition); @@ -79,13 +83,12 @@ export async function seedVisibleVerticalSlice( await locationRepository.insert(location); } - if (key === 'south-gate') { - southGateId = persistedId; - } else { - burnedRoadId = persistedId; - } + locationIds.set(key, existing?.id ?? id); } + const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID; + const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID; + await connectionRepository.upsert( [ { @@ -128,6 +131,24 @@ export async function seedVisibleVerticalSlice( silverMin: 4, silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', + iconPath: '/images/combat/icons/ash-rat-128.png', + lootTableId: ASH_RAT_LOOT_TABLE_ID, + }, + { + id: WILD_ROAD_DOG_MONSTER_ID, + key: 'wild-road-dog', + name: 'Verwilderter Straßenhund', + level: 1, + maxHp: 55, + attack: 7, + armor: 0, + experienceReward: 11, + silverMin: 5, + silverMax: 9, + artworkPath: '/images/monsters/wild-road-dog.png', + iconPath: '/images/combat/icons/wild-road-dog-128.png', + // Shares the beast table: both are scorched road animals that leave a + // pelt behind. A table of its own waits for content that differs. lootTableId: ASH_RAT_LOOT_TABLE_ID, }, { @@ -142,18 +163,33 @@ export async function seedVisibleVerticalSlice( silverMin: 9, silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', + iconPath: '/images/combat/icons/road-bandit-128.png', + lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, + }, + { + id: CHARRED_LOOTER_MONSTER_ID, + key: 'charred-looter', + name: 'Verkohlter Plünderer', + level: 2, + maxHp: 85, + attack: 11, + armor: 6, + experienceReward: 20, + silverMin: 12, + silverMax: 19, + artworkPath: '/images/monsters/charred-looter.png', + iconPath: '/images/combat/icons/charred-looter-128.png', + // Shares the raider table: same gear, taken from the same caravans. lootTableId: ROAD_BANDIT_LOOT_TABLE_ID, }, ]; - let ashRatId = ASH_RAT_MONSTER_ID; - let roadBanditId = ROAD_BANDIT_MONSTER_ID; + const monsterIds = new Map(); for (const monster of monsters) { const existingMonster = await monsterRepository.findOneBy({ key: monster.key, }); const { id, key, ...definition } = monster; - const persistedId = existingMonster?.id ?? id; if (existingMonster) { await monsterRepository.update(existingMonster.id, definition); @@ -161,30 +197,27 @@ export async function seedVisibleVerticalSlice( await monsterRepository.insert(monster); } - if (key === 'ash-rat') { - ashRatId = persistedId; - } else { - roadBanditId = persistedId; - } + monsterIds.set(key, existingMonster?.id ?? id); } + // Weights read as "how often you meet this on the road". They also drive the + // location's danger rating, which is computed from the weighted average of + // the pool rather than from its single worst entry. + const encounterWeights: Readonly> = { + 'ash-rat': 40, + 'wild-road-dog': 30, + 'road-bandit': 20, + 'charred-looter': 10, + }; + await locationMonsterRepository.upsert( - [ - { - locationId: burnedRoadId, - monsterId: ashRatId, - weight: 70, - encounterType: EncounterType.NORMAL, - enabled: true, - }, - { - locationId: burnedRoadId, - monsterId: roadBanditId, - weight: 30, - encounterType: EncounterType.NORMAL, - enabled: true, - }, - ], + Object.entries(encounterWeights).map(([key, weight]) => ({ + locationId: burnedRoadId, + monsterId: monsterIds.get(key) as string, + weight, + encounterType: EncounterType.NORMAL, + enabled: true, + })), ['locationId', 'monsterId'], ); diff --git a/apps/api/src/monsters/entities/monster-definition.entity.ts b/apps/api/src/monsters/entities/monster-definition.entity.ts index 5bf3286..efc79aa 100644 --- a/apps/api/src/monsters/entities/monster-definition.entity.ts +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -46,6 +46,12 @@ export class MonsterDefinition { @Column({ name: 'artwork_path', type: 'varchar', length: 255 }) artworkPath!: string; + // Round medallion portrait used wherever a monster appears at icon size + // (encounter preview in the local location view). `artworkPath` stays the + // wide combat/hunt portrait. + @Column({ name: 'icon_path', type: 'varchar', length: 255 }) + iconPath!: string; + @Column({ name: 'loot_table_id', type: 'uuid', nullable: true }) lootTableId!: string | null; diff --git a/apps/api/src/world/entities/location-definition.entity.ts b/apps/api/src/world/entities/location-definition.entity.ts index b6e86a2..146daf8 100644 --- a/apps/api/src/world/entities/location-definition.entity.ts +++ b/apps/api/src/world/entities/location-definition.entity.ts @@ -8,6 +8,12 @@ import { UpdateDateColumn, } from 'typeorm'; import { Character } from '../../characters/entities/character.entity'; +import type { + LocationPointOfInterestContent, + LocationPrimaryActionContent, + LocationRewardPreviewContent, + LocationType, +} from '../local-location.types'; import { LocationConnection } from './location-connection.entity'; @Entity({ name: 'location_definitions' }) @@ -46,6 +52,35 @@ export class LocationDefinition { @Column({ name: 'artwork_path', type: 'varchar', length: 255 }) artworkPath!: string; + // --- Local location view (spec §4, §10) ----------------------------------- + // `description`/`artworkPath` above stay untouched: the map and the hunt + // screen keep rendering them. The `local*` columns below feed the local + // location view, which needs a longer scene description and a wide artwork. + + @Column({ name: 'region_name', type: 'varchar', length: 150 }) + regionName!: string; + + @Column({ name: 'region_tier_label', type: 'varchar', length: 50 }) + regionTierLabel!: string; + + @Column({ name: 'location_type', type: 'varchar', length: 50 }) + locationType!: LocationType; + + @Column({ name: 'local_description', type: 'text' }) + localDescription!: string; + + @Column({ name: 'local_artwork_path', type: 'varchar', length: 255 }) + localArtworkPath!: string; + + @Column({ name: 'local_points_of_interest', type: 'jsonb' }) + localPointsOfInterest!: LocationPointOfInterestContent[]; + + @Column({ name: 'local_primary_actions', type: 'jsonb' }) + localPrimaryActions!: LocationPrimaryActionContent[]; + + @Column({ name: 'local_reward_preview', type: 'jsonb' }) + localRewardPreview!: LocationRewardPreviewContent[]; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts new file mode 100644 index 0000000..cd54a2a --- /dev/null +++ b/apps/api/src/world/local-location-interaction.spec.ts @@ -0,0 +1,160 @@ +import { Repository } from 'typeorm'; +import { Character } from '../characters/entities/character.entity'; +import { + BURNED_ROAD_ID, + SOUTH_GATE_ID, +} from '../database/seeds/vertical-slice.constants'; +import { LocationMonster } from '../monsters/entities/location-monster.entity'; +import { TravelService } from '../travel/travel.service'; +import { LocationConnection } from './entities/location-connection.entity'; +import { LocationDefinition } from './entities/location-definition.entity'; +import type { LocationPointOfInterestContent } from './local-location.types'; +import { WorldDomainError } from './world.errors'; +import { WorldService } from './world.service'; + +const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; + +const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [ + { + key: 'hunt-area', + title: 'Jagdgebiet', + actionLabel: 'Jagd beginnen', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 52, + yPercent: 44, + enabled: true, + }, + { + key: 'inspect-tracks', + title: 'Verdächtige Spuren', + actionLabel: 'Untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 32, + yPercent: 78, + enabled: true, + resultTitle: 'Verdächtige Spuren', + resultText: + 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.', + }, + { + key: 'sealed-crypt', + title: 'Versiegelte Krypta', + type: 'DUNGEON', + iconKey: 'search', + xPercent: 90, + yPercent: 20, + enabled: false, + resultTitle: 'Versiegelte Krypta', + resultText: 'Noch verschlossen.', + }, +]; + +const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ + { + key: 'gate-watch', + title: 'Torwache', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 45, + yPercent: 52, + enabled: true, + resultTitle: 'Torwache', + resultText: 'Nur am Südtor zu hören.', + }, +]; + +function location( + id: string, + pointsOfInterest: LocationPointOfInterestContent[], +): LocationDefinition { + return { + id, + localPointsOfInterest: pointsOfInterest, + } as LocationDefinition; +} + +function createService( + currentLocation: LocationDefinition, + completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }), +) { + return new WorldService( + { completeTravelIfDue } as unknown as TravelService, + { + findOne: jest.fn().mockResolvedValue({ + id: CHARACTER_ID, + currentLocationId: currentLocation.id, + currentLocation, + }), + } as unknown as Repository, + { find: jest.fn() } as unknown as Repository, + { find: jest.fn() } as unknown as Repository, + ); +} + +async function expectRejected(promise: Promise): Promise { + await expect(promise).rejects.toBeInstanceOf(WorldDomainError); + await expect(promise).rejects.toMatchObject({ + code: 'LOCATION_INTERACTION_UNAVAILABLE', + }); +} + +describe('WorldService.runLocalInteraction', () => { + it('returns the authored result of an enabled interaction at the current location', async () => { + const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS)); + + await expect( + service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'), + ).resolves.toEqual({ + interactionKey: 'inspect-tracks', + title: 'Verdächtige Spuren', + text: 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.', + }); + }); + + it('settles travel before resolving which location the character stands at', async () => { + const completeTravelIfDue = jest + .fn() + .mockResolvedValue({ status: 'IDLE' }); + const service = createService( + location(BURNED_ROAD_ID, BURNED_ROAD_POIS), + completeTravelIfDue, + ); + + await service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'); + + expect(completeTravelIfDue).toHaveBeenCalledWith(CHARACTER_ID); + }); + + it('rejects an interaction that belongs to a different location', async () => { + const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS)); + + await expectRejected( + service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'), + ); + }); + + it('rejects an unknown interaction key', async () => { + const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS)); + + await expectRejected( + service.runLocalInteraction(CHARACTER_ID, 'open-vault'), + ); + }); + + it('rejects a disabled interaction', async () => { + const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS)); + + await expectRejected( + service.runLocalInteraction(CHARACTER_ID, 'sealed-crypt'), + ); + }); + + it('rejects a navigation hotspot that has no result to reveal', async () => { + const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS)); + + await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area')); + }); +}); diff --git a/apps/api/src/world/local-location.types.ts b/apps/api/src/world/local-location.types.ts new file mode 100644 index 0000000..9a7a72f --- /dev/null +++ b/apps/api/src/world/local-location.types.ts @@ -0,0 +1,126 @@ +/** + * Content and transport types for the local location view (spec §10). + * + * A location's local presentation is content, not code: points of interest, + * primary actions and the reward preview are stored as JSONB on + * `LocationDefinition` so a new location renders through the same components + * by supplying different data. + */ + +export type LocationInteractionType = + | 'HUNT' + | 'INVESTIGATE' + | 'SEARCH' + | 'NPC' + | 'MAP' + | 'TRAVEL' + | 'SHOP' + | 'QUEST' + | 'BOSS' + | 'DUNGEON'; + +export type LocationType = + | 'SAFE_HUB' + | 'TRANSITION' + | 'HUNTING_GROUND' + | 'QUEST_LOCATION' + | 'OUTPOST' + | 'ELITE_ZONE' + | 'BOSS_LOCATION' + | 'DUNGEON_ENTRANCE'; + +/** + * Stored shape of a point of interest. `resultTitle`/`resultText` never leave + * the server through the location payload — they are revealed only by the + * interaction endpoint, which first verifies the character actually stands + * here (plan §5). + */ +export interface LocationPointOfInterestContent { + key: string; + title: string; + actionLabel?: string; + type: LocationInteractionType; + iconKey: string; + xPercent: number; + yPercent: number; + enabled: boolean; + resultTitle?: string; + resultText?: string; +} + +/** + * Stored shape of a primary action. Interaction-backed actions carry `poiKey` + * instead of their own result text, so a POI and the action bar entry that + * duplicates it can never drift apart. + */ +export interface LocationPrimaryActionContent { + key: string; + label: string; + description?: string; + type: LocationInteractionType; + iconKey: string; + enabled: boolean; + poiKey?: string; +} + +export interface LocationRewardPreviewContent { + key: string; + label: string; + iconKey: string; +} + +export interface LocalLocationPointOfInterestDto { + key: string; + title: string; + actionLabel?: string; + type: LocationInteractionType; + iconKey: string; + xPercent: number; + yPercent: number; + enabled: boolean; +} + +export interface LocalLocationPrimaryActionDto { + key: string; + label: string; + description?: string; + type: LocationInteractionType; + iconKey: string; + enabled: boolean; + poiKey?: string; +} + +export interface EncounterPreviewDto { + key: string; + name: string; + level: number; + iconPath: string; +} + +export interface RewardPreviewDto { + key: string; + label: string; + iconKey: string; +} + +export interface LocationInteractionResultDto { + interactionKey: string; + title: string; + text: string; +} + +/** Strips server-only result text before a POI is sent to the client. */ +export function toPointOfInterestDto( + poi: LocationPointOfInterestContent, +): LocalLocationPointOfInterestDto { + return { + key: poi.key, + title: poi.title, + ...(poi.actionLabel === undefined ? {} : { actionLabel: poi.actionLabel }), + type: poi.type, + iconKey: poi.iconKey, + xPercent: poi.xPercent, + yPercent: poi.yPercent, + enabled: poi.enabled, + }; +} diff --git a/apps/api/src/world/world.controller.spec.ts b/apps/api/src/world/world.controller.spec.ts new file mode 100644 index 0000000..9ecea5d --- /dev/null +++ b/apps/api/src/world/world.controller.spec.ts @@ -0,0 +1,35 @@ +import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { WorldController } from './world.controller'; +import { WorldService } from './world.service'; + +describe('WorldController', () => { + it('resolves the current location for the acting character', () => { + const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' }); + const controller = new WorldController({ + getCurrentLocation, + } as unknown as WorldService); + + void controller.getCurrentLocation(); + + expect(getCurrentLocation).toHaveBeenCalledWith(DEMO_CHARACTER_ID); + }); + + it('forwards only the interaction key, never a caller-supplied location', () => { + const runLocalInteraction = jest.fn().mockResolvedValue({ + interactionKey: 'inspect-tracks', + title: 'Verdächtige Spuren', + text: 'Frische Stiefelabdrücke.', + }); + const controller = new WorldController({ + runLocalInteraction, + } as unknown as WorldService); + + void controller.runLocalInteraction('inspect-tracks'); + + expect(runLocalInteraction).toHaveBeenCalledWith( + DEMO_CHARACTER_ID, + 'inspect-tracks', + ); + expect(runLocalInteraction).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/api/src/world/world.controller.ts b/apps/api/src/world/world.controller.ts index 6c28032..262e4cd 100644 --- a/apps/api/src/world/world.controller.ts +++ b/apps/api/src/world/world.controller.ts @@ -1,5 +1,6 @@ -import { Controller, Get } from '@nestjs/common'; +import { Controller, Get, Param, Post } from '@nestjs/common'; import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants'; +import { LocationInteractionResultDto } from './local-location.types'; import { WorldService } from './world.service'; @Controller('world') @@ -10,4 +11,19 @@ export class WorldController { getCurrentLocation() { return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID); } + + /** + * The interaction is addressed by key alone. There is deliberately no + * location parameter: the server resolves the location from the character, + * so the route cannot be pointed at somewhere the player is not. + */ + @Post('current-location/interactions/:interactionKey') + runLocalInteraction( + @Param('interactionKey') interactionKey: string, + ): Promise { + return this.worldService.runLocalInteraction( + DEMO_CHARACTER_ID, + interactionKey, + ); + } } diff --git a/apps/api/src/world/world.errors.ts b/apps/api/src/world/world.errors.ts new file mode 100644 index 0000000..bfdb8e9 --- /dev/null +++ b/apps/api/src/world/world.errors.ts @@ -0,0 +1,25 @@ +import { HttpException } from '@nestjs/common'; + +export class WorldDomainError extends HttpException { + constructor( + public readonly code: string, + status: number, + message: string, + ) { + super({ statusCode: status, code, message }, status); + } +} + +/** + * Raised for any interaction key the current location does not offer — an + * unknown key, a key belonging to another location, or one that is disabled. + * They share a code on purpose: the client learns "not here", not which of the + * three it was. + */ +export function locationInteractionUnavailable(): WorldDomainError { + return new WorldDomainError( + 'LOCATION_INTERACTION_UNAVAILABLE', + 400, + 'This interaction is not available at the current location.', + ); +} diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index edb37d1..9b692a8 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -9,10 +9,122 @@ import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; +import type { + LocationPointOfInterestContent, + LocationPrimaryActionContent, +} from './local-location.types'; import { WorldService } from './world.service'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ + { + key: 'gate-watch', + title: 'Torwache', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 45, + yPercent: 52, + enabled: true, + resultTitle: 'Torwache', + resultText: 'Geheimer Servertext.', + }, +]; + +const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [ + { + key: 'hunt-area', + title: 'Jagdgebiet', + actionLabel: 'Jagd beginnen', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 52, + yPercent: 44, + enabled: true, + }, + { + key: 'inspect-tracks', + title: 'Verdächtige Spuren', + actionLabel: 'Untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 32, + yPercent: 78, + enabled: true, + resultTitle: 'Verdächtige Spuren', + resultText: 'Frische Stiefelabdrücke führen nach Osten.', + }, + { + key: 'sealed-crypt', + title: 'Versiegelte Krypta', + actionLabel: 'Öffnen', + type: 'DUNGEON', + iconKey: 'search', + xPercent: 90, + yPercent: 20, + enabled: false, + resultTitle: 'Versiegelte Krypta', + resultText: 'Noch verschlossen.', + }, +]; + +const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [ + { + key: 'start-hunt', + label: 'Jagd beginnen', + description: 'Im Gebiet jagen', + type: 'HUNT', + iconKey: 'hunt', + enabled: true, + }, + { + key: 'investigate-tracks', + label: 'Spuren untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'inspect-tracks', + }, +]; + +function character(locationId: string, location: LocationDefinition) { + return { + id: CHARACTER_ID, + baseAttack: 6, + baseHp: 100, + currentLocationId: locationId, + currentLocation: location, + }; +} + +function poolEntry( + name: string, + weight: number, + stats: { level: number; attack: number; armor: number; maxHp: number }, +) { + return { + weight, + monster: { + key: name.toLowerCase(), + name, + level: stats.level, + attack: stats.attack, + armor: stats.armor, + maxHp: stats.maxHp, + iconPath: `/images/monsters/icons/${name.toLowerCase()}-128.png`, + }, + }; +} + +// Ash rats are common and harmless, bandits rare and dangerous. Judged by its +// worst entry this pool reads STRONG; judged by what a traveller actually +// meets it reads MATCH. +const BURNED_ROAD_POOL = [ + poolEntry('Aschenratte', 70, { level: 1, attack: 5, armor: 0, maxHp: 45 }), + poolEntry('Straßenräuber', 30, { level: 2, attack: 9, armor: 5, maxHp: 75 }), +]; + function currentLocation(): LocationDefinition { return { id: SOUTH_GATE_ID, @@ -27,6 +139,14 @@ function currentLocation(): LocationDefinition { isSafe: true, huntingEnabled: false, artworkPath: '/assets/locations/south-gate.webp', + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'TRANSITION', + localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.', + localArtworkPath: '/images/backgrounds/Suedtor.png', + localPointsOfInterest: SOUTH_GATE_POIS, + localPrimaryActions: [], + localRewardPreview: [], createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), characters: [], @@ -48,6 +168,14 @@ function burnedRoad(): LocationDefinition { isSafe: false, huntingEnabled: true, artworkPath: '/assets/locations/burned-road.webp', + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'HUNTING_GROUND', + localDescription: 'Ein alter Handelsweg, in Asche gelegt.', + localArtworkPath: '/images/backgrounds/Aschestrasse.png', + localPointsOfInterest: BURNED_ROAD_POIS, + localPrimaryActions: BURNED_ROAD_ACTIONS, + localRewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }], createdAt: new Date('2026-08-18T09:00:00.000Z'), updatedAt: new Date('2026-08-18T09:00:00.000Z'), characters: [], @@ -69,11 +197,7 @@ describe('WorldService', () => { } as unknown as TravelService; const findCharacter = jest.fn().mockImplementation(() => { callOrder.push('findCharacter'); - return Promise.resolve({ - id: CHARACTER_ID, - currentLocationId: SOUTH_GATE_ID, - currentLocation: location, - }); + return Promise.resolve(character(SOUTH_GATE_ID, location)); }); const characters = { findOne: findCharacter, @@ -130,6 +254,28 @@ describe('WorldService', () => { isSafe: true, huntingEnabled: false, artworkPath: '/assets/locations/south-gate.webp', + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'TRANSITION', + localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.', + localArtworkPath: '/images/backgrounds/Suedtor.png', + dangerRating: null, + recommendationLabel: '1', + pointsOfInterest: [ + { + key: 'gate-watch', + title: 'Torwache', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 45, + yPercent: 52, + enabled: true, + }, + ], + primaryActions: [], + encounterPreview: [], + rewardPreview: [], connections: [ { targetLocation: { @@ -160,21 +306,12 @@ describe('WorldService', () => { completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), } as unknown as TravelService; const characters = { - findOne: jest.fn().mockResolvedValue({ - id: CHARACTER_ID, - currentLocationId: BURNED_ROAD_ID, - currentLocation: location, - }), + findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)), } as unknown as Repository; const connections = { find: jest.fn().mockResolvedValue([]), } as unknown as Repository; - const findLocationMonsters = jest - .fn() - .mockResolvedValue([ - { monster: { name: 'Aschenratte' } }, - { monster: { name: 'Stra\u00dfenr\u00e4uber' } }, - ]); + const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL); const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; @@ -230,4 +367,131 @@ describe('WorldService', () => { expect(findConnections).not.toHaveBeenCalled(); expect(findLocationMonsters).not.toHaveBeenCalled(); }); + + it('exposes the authored local view content of the current location', async () => { + const result = await loadBurnedRoad(); + + expect(result).toEqual( + expect.objectContaining({ + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'HUNTING_GROUND', + localDescription: 'Ein alter Handelsweg, in Asche gelegt.', + localArtworkPath: '/images/backgrounds/Aschestrasse.png', + recommendationLabel: '1–2', + rewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }], + }), + ); + expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS); + }); + + it('renders a single recommended level without a range', async () => { + const result = await loadSouthGate(); + + expect(result.recommendationLabel).toBe('1'); + }); + + it('serves every point of interest, including disabled ones, without leaking its result text', async () => { + const result = await loadBurnedRoad(); + + expect(result.pointsOfInterest).toEqual([ + { + key: 'hunt-area', + title: 'Jagdgebiet', + actionLabel: 'Jagd beginnen', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 52, + yPercent: 44, + enabled: true, + }, + { + key: 'inspect-tracks', + title: 'Verdächtige Spuren', + actionLabel: 'Untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 32, + yPercent: 78, + enabled: true, + }, + { + key: 'sealed-crypt', + title: 'Versiegelte Krypta', + actionLabel: 'Öffnen', + type: 'DUNGEON', + iconKey: 'search', + xPercent: 90, + yPercent: 20, + enabled: false, + }, + ]); + expect(JSON.stringify(result)).not.toContain('Frische Stiefelabdrücke'); + expect(JSON.stringify(result)).not.toContain('Noch verschlossen'); + }); + + it('derives the encounter preview from the location monster pool', async () => { + const result = await loadBurnedRoad(); + + expect(result.encounterPreview).toEqual([ + { + key: 'aschenratte', + name: 'Aschenratte', + level: 1, + iconPath: '/images/monsters/icons/aschenratte-128.png', + }, + { + key: 'straßenräuber', + name: 'Straßenräuber', + level: 2, + iconPath: '/images/monsters/icons/straßenräuber-128.png', + }, + ]); + }); + + it('rates local danger from the weighted pool average rather than its worst entry', async () => { + const result = await loadBurnedRoad(); + + // The lone bandit rates STRONG against this character; weighted by how + // rarely it appears, the road as a whole is a fair match. + expect(result.dangerRating).toBe('MATCH'); + }); + + it('reports no danger rating where nothing can be hunted', async () => { + const result = await loadSouthGate(); + + expect(result.dangerRating).toBeNull(); + expect(result.encounterPreview).toEqual([]); + }); }); + +async function loadBurnedRoad() { + return loadLocation(burnedRoad(), BURNED_ROAD_ID, BURNED_ROAD_POOL); +} + +async function loadSouthGate() { + return loadLocation(currentLocation(), SOUTH_GATE_ID, []); +} + +async function loadLocation( + location: LocationDefinition, + locationId: string, + pool: ReturnType[], +) { + const service = new WorldService( + { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } as unknown as TravelService, + { + findOne: jest.fn().mockResolvedValue(character(locationId, location)), + } as unknown as Repository, + { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository, + { + find: jest.fn().mockResolvedValue(pool), + } as unknown as Repository, + ); + + return service.getCurrentLocation(CHARACTER_ID); +} diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index 703b4fe..7e61b00 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -2,9 +2,24 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Character } from '../characters/entities/character.entity'; +import { + calculateDangerRating, + DangerRating, +} from '../hunting/danger-rating'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; import { LocationConnection } from './entities/location-connection.entity'; +import { LocationDefinition } from './entities/location-definition.entity'; +import { + EncounterPreviewDto, + LocalLocationPointOfInterestDto, + LocalLocationPrimaryActionDto, + LocationInteractionResultDto, + LocationType, + RewardPreviewDto, + toPointOfInterestDto, +} from './local-location.types'; +import { locationInteractionUnavailable } from './world.errors'; export interface LocationSummary { id: string; @@ -30,6 +45,18 @@ export interface CurrentLocationResponse { isSafe: boolean; huntingEnabled: boolean; artworkPath: string; + regionName: string; + regionTierLabel: string; + locationType: LocationType; + localDescription: string; + localArtworkPath: string; + /** `null` where nothing hostile can be met — the view reads that as safe. */ + dangerRating: DangerRating | null; + recommendationLabel: string; + pointsOfInterest: LocalLocationPointOfInterestDto[]; + primaryActions: LocalLocationPrimaryActionDto[]; + encounterPreview: EncounterPreviewDto[]; + rewardPreview: RewardPreviewDto[]; connections: CurrentLocationConnection[]; possibleMonsters: string[]; } @@ -49,15 +76,7 @@ export class WorldService { async getCurrentLocation( characterId: string, ): Promise { - await this.travelService.completeTravelIfDue(characterId); - - const character = await this.characters.findOne({ - where: { id: characterId }, - relations: { currentLocation: true }, - }); - if (!character) { - throw new NotFoundException('Character not found.'); - } + const character = await this.loadCharacterAtCurrentLocation(characterId); const connections = await this.connections.find({ where: { fromLocationId: character.currentLocationId, enabled: true }, @@ -65,8 +84,8 @@ export class WorldService { }); const location = character.currentLocation; - const possibleMonsters = location.huntingEnabled - ? await this.getPossibleMonsters(location.id) + const pool = location.huntingEnabled + ? await this.getEncounterPool(location.id) : []; return { @@ -81,6 +100,24 @@ export class WorldService { isSafe: location.isSafe, huntingEnabled: location.huntingEnabled, artworkPath: location.artworkPath, + regionName: location.regionName, + regionTierLabel: location.regionTierLabel, + locationType: location.locationType, + localDescription: location.localDescription, + localArtworkPath: location.localArtworkPath, + dangerRating: this.toLocalDangerRating(character, pool), + recommendationLabel: this.toRecommendationLabel(location), + pointsOfInterest: location.localPointsOfInterest.map( + toPointOfInterestDto, + ), + primaryActions: location.localPrimaryActions, + encounterPreview: pool.map((entry) => ({ + key: entry.monster.key, + name: entry.monster.name, + level: entry.monster.level, + iconPath: entry.monster.iconPath, + })), + rewardPreview: location.localRewardPreview, connections: connections .filter((connection) => connection.enabled) .map((connection) => ({ @@ -92,17 +129,102 @@ export class WorldService { travelDurationSeconds: connection.travelDurationSeconds, danger: this.toDangerRating(connection.ambushChance), })), - possibleMonsters, + possibleMonsters: pool.map((entry) => entry.monster.name), }; } - private async getPossibleMonsters(locationId: string): Promise { - const pool = await this.locationMonsters.find({ + /** + * Runs a short local interaction (investigate, search, talk) and reveals its + * authored result. + * + * The location is taken from the character, never from the request, so a + * caller cannot reach a hotspot it has not travelled to. Hotspots that only + * navigate (HUNT, MAP) carry no result text and are rejected here — the + * client routes those itself. + */ + async runLocalInteraction( + characterId: string, + interactionKey: string, + ): Promise { + const character = await this.loadCharacterAtCurrentLocation(characterId); + + const poi = character.currentLocation.localPointsOfInterest.find( + (candidate) => candidate.key === interactionKey, + ); + + if (!poi?.enabled || !poi.resultText) { + throw locationInteractionUnavailable(); + } + + return { + interactionKey: poi.key, + title: poi.resultTitle ?? poi.title, + text: poi.resultText, + }; + } + + /** + * Loads the character together with the location it actually stands at. + * + * Every local interaction resolves through here, so a request can never name + * the location it wants to act on (plan §5). + */ + async loadCharacterAtCurrentLocation( + characterId: string, + ): Promise { + await this.travelService.completeTravelIfDue(characterId); + + const character = await this.characters.findOne({ + where: { id: characterId }, + relations: { currentLocation: true }, + }); + if (!character) { + throw new NotFoundException('Character not found.'); + } + + return character; + } + + private getEncounterPool(locationId: string): Promise { + return this.locationMonsters.find({ where: { locationId, enabled: true }, relations: { monster: true }, order: { weight: 'DESC' }, }); - return pool.map((entry) => entry.monster.name); + } + + /** + * Rates the location by the encounter a traveller can typically expect: the + * pool's weight-averaged stats, not its single worst entry. A rare elite + * would otherwise make a beginner road read as lethal. + */ + private toLocalDangerRating( + character: Character, + pool: LocationMonster[], + ): DangerRating | null { + const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0); + if (totalWeight === 0) { + return null; + } + + const average = (pick: (entry: LocationMonster) => number) => + pool.reduce((sum, entry) => sum + entry.weight * pick(entry), 0) / + totalWeight; + + return calculateDangerRating( + { attack: character.baseAttack, armor: 0, hp: character.baseHp }, + { + attack: average((entry) => entry.monster.attack), + armor: average((entry) => entry.monster.armor), + hp: average((entry) => entry.monster.maxHp), + }, + ); + } + + private toRecommendationLabel(location: LocationDefinition): string { + return location.minRecommendedLevel === location.maxRecommendedLevel + ? `${location.minRecommendedLevel}` + : `${location.minRecommendedLevel}–${location.maxRecommendedLevel}`; } private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' { diff --git a/apps/web/public/images/monsters/runtime/charred-looter-560.jpg b/apps/web/public/images/monsters/runtime/charred-looter-560.jpg new file mode 100644 index 0000000..8860ec0 Binary files /dev/null and b/apps/web/public/images/monsters/runtime/charred-looter-560.jpg differ diff --git a/apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg b/apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg new file mode 100644 index 0000000..05126ba Binary files /dev/null and b/apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg differ diff --git a/apps/web/src/app/app.routes.ts b/apps/web/src/app/app.routes.ts index 8a0c082..648992c 100644 --- a/apps/web/src/app/app.routes.ts +++ b/apps/web/src/app/app.routes.ts @@ -2,11 +2,18 @@ import { Routes } from '@angular/router'; import { AppShellComponent } from './layout/app-shell/app-shell.component'; export const routes: Routes = [ - { path: '', pathMatch: 'full', redirectTo: 'world' }, + { path: '', pathMatch: 'full', redirectTo: 'location' }, { path: '', component: AppShellComponent, children: [ + { + path: 'location', + loadComponent: () => + import('./features/world/location-page/location-page.component').then( + (module) => module.LocationPageComponent, + ), + }, { path: 'world', loadComponent: () => @@ -30,5 +37,5 @@ export const routes: Routes = [ }, ], }, - { path: '**', redirectTo: 'world' }, + { path: '**', redirectTo: 'location' }, ]; diff --git a/apps/web/src/app/app.spec.ts b/apps/web/src/app/app.spec.ts index 4dd3f38..f33cc2a 100644 --- a/apps/web/src/app/app.spec.ts +++ b/apps/web/src/app/app.spec.ts @@ -20,6 +20,7 @@ describe('App', () => { imports: [AppShellComponent], providers: [ provideRouter([ + { path: 'location', children: [] }, { path: 'world', children: [] }, { path: 'hunt', children: [] }, ]), @@ -65,6 +66,55 @@ describe('App', () => { expect(element.textContent).not.toContain('Shop'); }); + it('offers Ort as the first navigation entry, marked active on /location', async () => { + const fixture = TestBed.createComponent(AppShellComponent); + const router = TestBed.inject(Router); + await router.navigateByUrl('/location'); + fixture.detectChanges(); + await fixture.whenStable(); + + const element = fixture.nativeElement as HTMLElement; + const entries = element.querySelectorAll('[data-navigation]'); + expect([...entries].map((entry) => entry.dataset['navigation'])).toEqual([ + 'location', + 'world', + 'hunt', + 'quests', + 'inventory', + 'character', + ]); + + const locationButton = element.querySelector( + '[data-navigation="location"]', + ); + expect(locationButton?.disabled).toBe(false); + expect(locationButton?.getAttribute('aria-label')).toBe('Ort'); + expect(locationButton?.getAttribute('aria-current')).toBe('page'); + expect( + element.querySelector('[data-navigation="world"]')?.getAttribute('aria-current'), + ).toBeNull(); + }); + + it('drops the shell context rail on /location, where the screen brings its own', async () => { + const fixture = TestBed.createComponent(AppShellComponent); + const router = TestBed.inject(Router); + await router.navigateByUrl('/location'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('app-context-panel')).toBeNull(); + }); + + it('keeps the shell context rail on the map', async () => { + const fixture = TestBed.createComponent(AppShellComponent); + const router = TestBed.inject(Router); + await router.navigateByUrl('/world'); + fixture.detectChanges(); + await fixture.whenStable(); + + expect(fixture.nativeElement.querySelector('app-context-panel')).not.toBeNull(); + }); + it('marks Jagd as the active navigation entry while on /hunt', async () => { const fixture = TestBed.createComponent(AppShellComponent); const router = TestBed.inject(Router); @@ -104,11 +154,19 @@ describe('App', () => { expect(fixture.nativeElement.querySelector('app-top-bar')?.textContent).toContain('320'); }); - it('redirects root and unknown routes to the world shell', () => { + it('lands root and unknown routes on the place the character is standing in', () => { expect(routes.find((route) => route.path === '')).toMatchObject({ pathMatch: 'full', - redirectTo: 'world', + redirectTo: 'location', }); - expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'world' }); + expect(routes.find((route) => route.path === '**')).toMatchObject({ redirectTo: 'location' }); + }); + + it('keeps the map and hunt routes where they were', () => { + const shellChildren = routes.find((route) => route.children)?.children ?? []; + + expect(shellChildren.map((route) => route.path)).toEqual( + expect.arrayContaining(['location', 'world', 'hunt']), + ); }); }); diff --git a/apps/web/src/app/core/api/game-api.models.ts b/apps/web/src/app/core/api/game-api.models.ts index f4ad3e7..a33118f 100644 --- a/apps/web/src/app/core/api/game-api.models.ts +++ b/apps/web/src/app/core/api/game-api.models.ts @@ -22,6 +22,69 @@ export interface CurrentLocationConnection { danger: 'LOW' | 'HIGH'; } +export type LocationInteractionType = + | 'HUNT' + | 'INVESTIGATE' + | 'SEARCH' + | 'NPC' + | 'MAP' + | 'TRAVEL' + | 'SHOP' + | 'QUEST' + | 'BOSS' + | 'DUNGEON'; + +export type LocationType = + | 'SAFE_HUB' + | 'TRANSITION' + | 'HUNTING_GROUND' + | 'QUEST_LOCATION' + | 'OUTPOST' + | 'ELITE_ZONE' + | 'BOSS_LOCATION' + | 'DUNGEON_ENTRANCE'; + +export interface LocationPointOfInterest { + key: string; + title: string; + actionLabel?: string; + type: LocationInteractionType; + iconKey: string; + xPercent: number; + yPercent: number; + enabled: boolean; +} + +export interface LocationPrimaryAction { + key: string; + label: string; + description?: string; + type: LocationInteractionType; + iconKey: string; + enabled: boolean; + /** Set when the action reveals the same result as a hotspot on the artwork. */ + poiKey?: string; +} + +export interface EncounterPreview { + key: string; + name: string; + level: number; + iconPath: string; +} + +export interface RewardPreview { + key: string; + label: string; + iconKey: string; +} + +export interface LocationInteractionResult { + interactionKey: string; + title: string; + text: string; +} + export interface CurrentLocationResponse { id: string; key: string; @@ -34,6 +97,18 @@ export interface CurrentLocationResponse { isSafe: boolean; huntingEnabled: boolean; artworkPath: string; + regionName: string; + regionTierLabel: string; + locationType: LocationType; + localDescription: string; + localArtworkPath: string; + /** `null` where nothing hostile can be met — the view reads that as safe. */ + dangerRating: DangerRating | null; + recommendationLabel: string; + pointsOfInterest: LocationPointOfInterest[]; + primaryActions: LocationPrimaryAction[]; + encounterPreview: EncounterPreview[]; + rewardPreview: RewardPreview[]; connections: CurrentLocationConnection[]; possibleMonsters: string[]; } diff --git a/apps/web/src/app/core/api/game-api.service.spec.ts b/apps/web/src/app/core/api/game-api.service.spec.ts index bc3a992..ffc16ff 100644 --- a/apps/web/src/app/core/api/game-api.service.spec.ts +++ b/apps/web/src/app/core/api/game-api.service.spec.ts @@ -48,6 +48,24 @@ describe('GameApiService', () => { request.flush({ status: 'IDLE' }); }); + it('posts a local interaction by key alone, never naming a location', () => { + service.runLocationInteraction('inspect-tracks').subscribe(); + + const request = http.expectOne('/api/world/current-location/interactions/inspect-tracks'); + expect(request.request.method).toBe('POST'); + expect(request.request.body).toEqual({}); + request.flush({ interactionKey: 'inspect-tracks', title: 'Spuren', text: '…' }); + }); + + it('escapes an interaction key so it cannot break out of its path segment', () => { + service.runLocationInteraction('a/../b').subscribe(); + + const request = http.expectOne( + '/api/world/current-location/interactions/a%2F..%2Fb', + ); + request.flush({ interactionKey: 'a/../b', title: '', text: '' }); + }); + it('posts to the encounter-scoped attack endpoint with an empty body to start a combat', () => { service.startCombat('encounter-uuid').subscribe(); diff --git a/apps/web/src/app/core/api/game-api.service.ts b/apps/web/src/app/core/api/game-api.service.ts index 66c8b56..a5686d1 100644 --- a/apps/web/src/app/core/api/game-api.service.ts +++ b/apps/web/src/app/core/api/game-api.service.ts @@ -8,6 +8,7 @@ import { CurrentLocationResponse, CurrentTravel, HuntResult, + LocationInteractionResult, } from './game-api.models'; @Injectable({ providedIn: 'root' }) @@ -22,6 +23,17 @@ export class GameApiService { return this.http.get('/api/world/current-location'); } + /** + * Runs a local hotspot interaction. Only the key travels: the server pairs + * it with the character's actual location. + */ + runLocationInteraction(interactionKey: string): Observable { + return this.http.post( + `/api/world/current-location/interactions/${encodeURIComponent(interactionKey)}`, + {}, + ); + } + startTravel(targetLocationId: string): Observable { return this.http.post('/api/travel', { targetLocationId }); } diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.html b/apps/web/src/app/features/combat/combat-page/combat-page.component.html index 2926081..7bd7c3f 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.html +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.html @@ -117,17 +117,37 @@ } - +
+ + +
} @else if (combat.status === 'LOST') {

Niederlage

{{ combat.player.name }} wurde im Kampf besiegt.

- +
+ + +
} diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss index be70953..e170302 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.scss +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.scss @@ -490,6 +490,18 @@ margin-block-start: var(--ar-space-2); } +.outcome__buttons { + display: flex; + gap: var(--ar-space-3); + justify-content: center; +} + +.outcome__button--secondary { + border-color: var(--ar-border); + color: var(--ar-text-muted); + background: var(--ar-panel); +} + .outcome__button:hover, .combat__notice--error button:hover { border-color: var(--ar-gold); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts index e1ea829..5e4bbd3 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.spec.ts @@ -266,7 +266,7 @@ describe('CombatPageComponent', () => { expect(element.querySelector('[data-combat-attack]')).toBeNull(); }); - it('navigates to /hunt from the victory screen', async () => { + it('keeps the one-click hunt loop from the victory screen', async () => { const fixture = await setup({ ...activeCombat, status: 'WON' }); const element = fixture.nativeElement as HTMLElement; @@ -275,6 +275,25 @@ describe('CombatPageComponent', () => { expect(router.navigate).toHaveBeenCalledWith(['/hunt']); }); + it('also offers the way back to the location from the victory screen', async () => { + const fixture = await setup({ ...activeCombat, status: 'WON' }); + const element = fixture.nativeElement as HTMLElement; + + element.querySelector('[data-combat-to-location]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/location']); + }); + + it('offers the same two ways out after a defeat', async () => { + const fixture = await setup({ ...activeCombat, status: 'LOST' }); + const element = fixture.nativeElement as HTMLElement; + + expect(element.querySelector('[data-combat-to-hunt]')).not.toBeNull(); + element.querySelector('[data-combat-to-location]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/location']); + }); + it('shows an error and retries loading the combat', async () => { const fixture = await setup(null); combatStore.error.set('Dieser Kampf wurde nicht gefunden.'); diff --git a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts index 38c0b88..ff63cbd 100644 --- a/apps/web/src/app/features/combat/combat-page/combat-page.component.ts +++ b/apps/web/src/app/features/combat/combat-page/combat-page.component.ts @@ -161,6 +161,13 @@ export class CombatPageComponent implements OnInit { void this.router.navigate(['/hunt']); } + // The location is the screen a fight resolves back into. It sits beside + // "Weiter jagen" rather than replacing it, so the hunt loop keeps its + // one-click rhythm. + protected goToLocation(): void { + void this.router.navigate(['/location']); + } + protected monsterSprite(monsterKey: string, artworkPath: string): string { return monsterCutoutPath(monsterKey) ?? runtimeMonsterArtworkPath(artworkPath) ?? artworkPath; } diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html index 5ec2587..40be676 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.html @@ -7,7 +7,7 @@ Am Südtor von Graufurt gibt es keine regulären Jagdgebiete. Reise in ein gefährlicheres Gebiet, um nach Gegnern zu suchen.

- + } @else if (huntingStore.currentHunt(); as hunt) {
@@ -28,7 +28,7 @@ > Neu suchen - +
} @else { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts index bc54ca2..0ff4a32 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.spec.ts @@ -5,38 +5,17 @@ import { Router, provideRouter } from '@angular/router'; import { vi } from 'vitest'; import type { Combat, CurrentLocationResponse, HuntResult } from '../../../core/api/game-api.models'; import { CombatStore } from '../../combat/combat.store'; +import { + burnedRoadFixture, + southGateFixture, +} from '../../world/current-location.fixture'; import { WorldStore } from '../../world/world.store'; import { HuntingStore } from '../hunting.store'; import { HuntPageComponent } from './hunt-page.component'; -const southGate: CurrentLocationResponse = { - id: 'south-gate-id', - key: 'south-gate', - name: 'Südtor von Graufurt', - description: 'Der letzte sichere Schritt vor den Aschenfeldern.', - regionKey: 'ashen-fields', - minRecommendedLevel: 1, - maxRecommendedLevel: 1, - dangerLevel: 0, - isSafe: true, - huntingEnabled: false, - artworkPath: '/images/backgrounds/Suedtor.png', - connections: [], - possibleMonsters: [], -}; +const southGate = southGateFixture({ connections: [] }); -const burnedRoad: CurrentLocationResponse = { - ...southGate, - id: 'burned-road-id', - key: 'burned-road', - name: 'Verbrannte Straße', - description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.', - isSafe: false, - huntingEnabled: true, - artworkPath: '/images/backgrounds/Aschestrasse.png', - possibleMonsters: ['Aschenratte', 'Straßenräuber'], - connections: [], -}; +const burnedRoad = burnedRoadFixture({ connections: [] }); const threeEncounterHunt: HuntResult = { id: 'hunt-id', @@ -150,7 +129,7 @@ describe('HuntPageComponent', () => { return fixture; } - it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a working Zur Karte action', async () => { + it('shows the hunting-unavailable state at the Südtor, with no Jagd beginnen button, and a way back to the location', async () => { const fixture = await setup(southGate); const element = fixture.nativeElement as HTMLElement; @@ -162,11 +141,11 @@ describe('HuntPageComponent', () => { ), ).toBe(false); - const toWorldButton = element.querySelector('[data-hunt-to-world]'); - expect(toWorldButton?.textContent?.trim()).toBe('Zur Karte'); - toWorldButton?.click(); + const backButton = element.querySelector('[data-hunt-to-location]'); + expect(backButton?.textContent?.trim()).toBe('Zurück zum Ort'); + backButton?.click(); - expect(router.navigate).toHaveBeenCalledWith(['/world']); + expect(router.navigate).toHaveBeenCalledWith(['/location']); }); it('calls startHunt when Jagd beginnen is clicked at a hunting-enabled location', async () => { diff --git a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts index 6bdb95b..5547472 100644 --- a/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts +++ b/apps/web/src/app/features/hunting/hunt-page/hunt-page.component.ts @@ -44,8 +44,10 @@ export class HuntPageComponent implements OnInit { } } - protected goToWorld(): void { - void this.router.navigate(['/world']); + // Back out of the hunt returns to the place the hunt happens in, not to the + // map: the location is the screen the player left to get here. + protected goToLocation(): void { + void this.router.navigate(['/location']); } protected async onAttack(encounterId: string): Promise { diff --git a/apps/web/src/app/features/world/current-location.fixture.ts b/apps/web/src/app/features/world/current-location.fixture.ts new file mode 100644 index 0000000..c05d603 --- /dev/null +++ b/apps/web/src/app/features/world/current-location.fixture.ts @@ -0,0 +1,192 @@ +import type { + CurrentLocationResponse, + LocationPointOfInterest, + LocationPrimaryAction, +} from '../../core/api/game-api.models'; + +/** + * Test fixtures for `GET /api/world/current-location`. + * + * Shared rather than re-declared per spec: the payload backs the map, the + * hunt screen and the local location view, so a field added to the contract + * needs to be answered in exactly one place. + */ + +export const BURNED_ROAD_POIS: LocationPointOfInterest[] = [ + { + key: 'hunt-area', + title: 'Jagdgebiet', + actionLabel: 'Jagd beginnen', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 52, + yPercent: 44, + enabled: true, + }, + { + key: 'inspect-tracks', + title: 'Verdächtige Spuren', + actionLabel: 'Untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 32, + yPercent: 78, + enabled: true, + }, + { + key: 'search-abandoned-wagon', + title: 'Verlassener Wagen', + actionLabel: 'Durchsuchen', + type: 'SEARCH', + iconKey: 'search', + xPercent: 80, + yPercent: 68, + enabled: true, + }, + { + key: 'wounded-scout', + title: 'Verwundeter Kundschafter', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 20, + yPercent: 60, + enabled: true, + }, +]; + +export const BURNED_ROAD_ACTIONS: LocationPrimaryAction[] = [ + { + key: 'start-hunt', + label: 'Jagd beginnen', + description: 'Im Gebiet jagen', + type: 'HUNT', + iconKey: 'hunt', + enabled: true, + }, + { + key: 'investigate-tracks', + label: 'Spuren untersuchen', + description: 'Hinweise finden', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'inspect-tracks', + }, + { + key: 'search-surroundings', + label: 'Umgebung durchsuchen', + description: 'Beute finden', + type: 'SEARCH', + iconKey: 'search', + enabled: true, + poiKey: 'search-abandoned-wagon', + }, + { + key: 'open-map', + label: 'Zur Karte', + description: 'Gebiet wechseln', + type: 'MAP', + iconKey: 'map', + enabled: true, + }, +]; + +export function southGateFixture( + overrides: Partial = {}, +): CurrentLocationResponse { + return { + id: 'south-gate-id', + key: 'south-gate', + name: 'Südtor von Graufurt', + description: 'Der letzte sichere Schritt vor den Aschenfeldern.', + regionKey: 'ashen-fields', + minRecommendedLevel: 1, + maxRecommendedLevel: 1, + dangerLevel: 0, + isSafe: true, + huntingEnabled: false, + artworkPath: '/images/backgrounds/Suedtor.png', + regionName: 'Aschenfelder', + regionTierLabel: 'Gebiet 1', + locationType: 'TRANSITION', + localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.', + localArtworkPath: '/images/backgrounds/Suedtor.png', + dangerRating: null, + recommendationLabel: '1', + pointsOfInterest: [], + primaryActions: [], + encounterPreview: [], + rewardPreview: [], + connections: [ + { + targetLocation: { + id: 'burned-road-id', + key: 'burned-road', + name: 'Verbrannte Straße', + }, + travelDurationSeconds: 10, + danger: 'LOW', + }, + ], + possibleMonsters: [], + ...overrides, + }; +} + +export function burnedRoadFixture( + overrides: Partial = {}, +): CurrentLocationResponse { + return southGateFixture({ + id: 'burned-road-id', + key: 'burned-road', + name: 'Verbrannte Straße', + description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.', + maxRecommendedLevel: 2, + dangerLevel: 1, + isSafe: false, + huntingEnabled: true, + artworkPath: '/images/backgrounds/Aschestrasse.png', + locationType: 'HUNTING_GROUND', + localDescription: + 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.', + localArtworkPath: '/images/backgrounds/Aschestrasse.png', + dangerRating: 'MATCH', + recommendationLabel: '1–2', + pointsOfInterest: BURNED_ROAD_POIS, + primaryActions: BURNED_ROAD_ACTIONS, + encounterPreview: [ + { + key: 'ash-rat', + name: 'Aschenratte', + level: 1, + iconPath: '/images/combat/icons/ash-rat-128.png', + }, + { + key: 'road-bandit', + name: 'Straßenräuber', + level: 2, + iconPath: '/images/combat/icons/road-bandit-128.png', + }, + ], + rewardPreview: [ + { key: 'silver', label: 'Silber', iconKey: 'silver' }, + { key: 'experience', label: 'Erfahrung', iconKey: 'experience' }, + { key: 'equipment', label: 'Ausrüstung', iconKey: 'equipment' }, + { key: 'material', label: 'Material', iconKey: 'material' }, + ], + connections: [ + { + targetLocation: { + id: 'south-gate-id', + key: 'south-gate', + name: 'Südtor von Graufurt', + }, + travelDurationSeconds: 10, + danger: 'LOW', + }, + ], + possibleMonsters: ['Aschenratte', 'Straßenräuber'], + ...overrides, + }); +} diff --git a/apps/web/src/app/features/world/local-location.store.spec.ts b/apps/web/src/app/features/world/local-location.store.spec.ts new file mode 100644 index 0000000..7432a01 --- /dev/null +++ b/apps/web/src/app/features/world/local-location.store.spec.ts @@ -0,0 +1,139 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { Subject, of, throwError } from 'rxjs'; +import { vi } from 'vitest'; +import { GameApiService } from '../../core/api/game-api.service'; +import { LocalLocationStore } from './local-location.store'; +import { WorldStore } from './world.store'; + +const trackResult = { + interactionKey: 'inspect-tracks', + title: 'Verdächtige Spuren', + text: 'Frische Stiefelabdrücke führen nach Osten.', +}; + +function setup(api: Partial = {}, world: Partial = {}) { + TestBed.configureTestingModule({ + providers: [ + LocalLocationStore, + { provide: GameApiService, useValue: api }, + { provide: WorldStore, useValue: { load: vi.fn(), ...world } }, + ], + }); + + return TestBed.inject(LocalLocationStore); +} + +describe('LocalLocationStore', () => { + it('loads the location through the world store rather than a second fetch path', async () => { + const load = vi.fn().mockResolvedValue(undefined); + const store = setup({}, { load }); + + await store.load(); + + expect(load).toHaveBeenCalledTimes(1); + }); + + it('opens the panel with the result the server returned', async () => { + const store = setup({ + runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)), + }); + + await store.runInteraction('inspect-tracks'); + + expect(store.interactionResult()).toEqual(trackResult); + expect(store.interactionError()).toBeNull(); + expect(store.interactionOpen()).toBe(true); + }); + + it('marks only the running interaction as pending', async () => { + const pending = new Subject(); + const store = setup({ + runLocationInteraction: vi.fn().mockReturnValue(pending), + }); + + const running = store.runInteraction('inspect-tracks'); + expect(store.interactionPending()).toBe('inspect-tracks'); + + pending.next(trackResult); + pending.complete(); + await running; + + expect(store.interactionPending()).toBeNull(); + }); + + it('ignores a second interaction while one is still running', async () => { + const pending = new Subject(); + const runLocationInteraction = vi.fn().mockReturnValue(pending); + const store = setup({ runLocationInteraction }); + + const running = store.runInteraction('inspect-tracks'); + await store.runInteraction('search-abandoned-wagon'); + + expect(runLocationInteraction).toHaveBeenCalledTimes(1); + expect(runLocationInteraction).toHaveBeenCalledWith('inspect-tracks'); + + pending.next(trackResult); + pending.complete(); + await running; + }); + + it('translates a rejected interaction into a readable message without navigating', async () => { + const store = setup({ + runLocationInteraction: vi.fn().mockReturnValue( + throwError( + () => + new HttpErrorResponse({ + status: 400, + error: { code: 'LOCATION_INTERACTION_UNAVAILABLE' }, + }), + ), + ), + }); + + await store.runInteraction('inspect-tracks'); + + expect(store.interactionError()).toBe('Hier gibt es dazu nichts zu entdecken.'); + expect(store.interactionResult()).toBeNull(); + expect(store.interactionOpen()).toBe(true); + }); + + it('falls back to a generic message for an unmapped failure', async () => { + const store = setup({ + runLocationInteraction: vi + .fn() + .mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))), + }); + + await store.runInteraction('inspect-tracks'); + + expect(store.interactionError()).toBe('Diese Handlung ist gerade nicht möglich.'); + }); + + it('closes the panel without clearing the location', async () => { + const store = setup({ + runLocationInteraction: vi.fn().mockReturnValue(of(trackResult)), + }); + + await store.runInteraction('inspect-tracks'); + store.closeInteraction(); + + expect(store.interactionOpen()).toBe(false); + expect(store.interactionResult()).toBeNull(); + }); + + it('resolves the hotspot an action mirrors', () => { + const store = setup(); + + expect( + store.interactionKeyOf({ + key: 'investigate-tracks', + label: 'Spuren untersuchen', + type: 'INVESTIGATE', + iconKey: 'investigate', + enabled: true, + poiKey: 'inspect-tracks', + }), + ).toBe('inspect-tracks'); + }); +}); diff --git a/apps/web/src/app/features/world/local-location.store.ts b/apps/web/src/app/features/world/local-location.store.ts new file mode 100644 index 0000000..10428f2 --- /dev/null +++ b/apps/web/src/app/features/world/local-location.store.ts @@ -0,0 +1,97 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { Injectable, computed, inject, signal } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { + LocationInteractionResult, + LocationPointOfInterest, + LocationPrimaryAction, +} from '../../core/api/game-api.models'; +import { GameApiService } from '../../core/api/game-api.service'; +import { WorldStore } from './world.store'; + +const GENERIC_INTERACTION_ERROR = 'Diese Handlung ist gerade nicht möglich.'; + +// Mirrors the codes in `apps/api/src/world/world.errors.ts`. +const INTERACTION_ERROR_MESSAGES: Readonly> = { + LOCATION_INTERACTION_UNAVAILABLE: 'Hier gibt es dazu nichts zu entdecken.', + CHARACTER_NOT_FOUND: 'Dein Charakter konnte nicht gefunden werden.', +}; + +/** + * State for the local location view. + * + * Location data comes from `WorldStore`, not from a second fetch path: that + * store already settles due travel before answering, so arriving at a place + * and looking around cannot disagree about where the character is. This store + * adds only what is local to the screen — the open interaction result. + */ +@Injectable({ providedIn: 'root' }) +export class LocalLocationStore { + private readonly api = inject(GameApiService); + private readonly worldStore = inject(WorldStore); + + private readonly interactionResultState = signal(null); + private readonly interactionErrorState = signal(null); + private readonly interactionPendingState = signal(null); + + readonly location = this.worldStore.currentLocation; + readonly loading = this.worldStore.loading; + readonly error = this.worldStore.error; + + readonly interactionResult = this.interactionResultState.asReadonly(); + readonly interactionError = this.interactionErrorState.asReadonly(); + /** Key of the interaction currently in flight, so only that control busies. */ + readonly interactionPending = this.interactionPendingState.asReadonly(); + readonly interactionOpen = computed( + () => this.interactionResultState() !== null || this.interactionErrorState() !== null, + ); + + load(): Promise { + return this.worldStore.load(); + } + + /** + * Runs a hotspot or action that reveals text. Navigation types (HUNT, MAP) + * never reach here — the page routes those itself. + */ + async runInteraction(interactionKey: string): Promise { + if (this.interactionPendingState() !== null) { + return; + } + + this.interactionPendingState.set(interactionKey); + this.interactionResultState.set(null); + this.interactionErrorState.set(null); + + try { + this.interactionResultState.set( + await firstValueFrom(this.api.runLocationInteraction(interactionKey)), + ); + } catch (error) { + this.interactionErrorState.set(this.toErrorMessage(error)); + } finally { + this.interactionPendingState.set(null); + } + } + + closeInteraction(): void { + this.interactionResultState.set(null); + this.interactionErrorState.set(null); + } + + /** The hotspot an action mirrors, so an action bar entry can highlight it. */ + interactionKeyOf( + action: LocationPrimaryAction | LocationPointOfInterest, + ): string | undefined { + return 'poiKey' in action ? action.poiKey : action.key; + } + + private toErrorMessage(error: unknown): string { + if (error instanceof HttpErrorResponse) { + const code = (error.error as { code?: string } | null)?.code; + return (code && INTERACTION_ERROR_MESSAGES[code]) || GENERIC_INTERACTION_ERROR; + } + + return GENERIC_INTERACTION_ERROR; + } +} diff --git a/apps/web/src/app/features/world/location-icon/location-icon.component.ts b/apps/web/src/app/features/world/location-icon/location-icon.component.ts new file mode 100644 index 0000000..dbec8ad --- /dev/null +++ b/apps/web/src/app/features/world/location-icon/location-icon.component.ts @@ -0,0 +1,68 @@ +import { Component, Input } from '@angular/core'; + +/** + * Line glyphs for local location content, addressed by the `iconKey` the + * server stores alongside a hotspot, action or reward. + * + * Drawn rather than loaded: these sit on top of artwork at sizes from 16px to + * 40px, where a downscaled bitmap turns to mush, and a new location can use an + * existing key without anyone exporting an asset. Swapping a key for a painted + * medallion later is a one-line change here. + */ +const GLYPHS: Readonly> = { + // Crossed hunting arrows. + hunt: 'M4 20 19 5M15 5h4v4M20 20 5 5M9 5H5v4', + // Magnifying glass over a trail. + investigate: 'M11 4a6 6 0 1 0 0 12 6 6 0 0 0 0-12M15.5 15.5 21 21', + // Lidded chest. + search: 'M3 9h18v11H3zM3 9l2-4h14l2 4M12 9v11M10 12h4', + // Speech bubble. + speak: 'M4 5h16v11h-9l-5 4v-4H4z', + // Compass rose. + map: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M15.5 8.5l-2 5-5 2 2-5z', + // Struck coin. + silver: 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 7l1.7 3.3L17 12l-3.3 1.7L12 17l-1.7-3.3L7 12l3.3-1.7z', + // Rising rune. + experience: 'M12 3 4 12l8 9 8-9zM12 8v8M9 11l3-3 3 3', + // Blade over a shield. + equipment: 'M12 3 5 6v6c0 4 3 7 7 9 4-2 7-5 7-9V6zM12 8v7M10 10l2-2 2 2', + // Bundled pelt. + material: 'M4 8c4-3 12-3 16 0l-2 11H6zM9 8v11M15 8v11', + // Travel marker, for locations whose hotspots lead onward. + travel: 'M12 3a6 6 0 0 1 6 6c0 4.5-6 12-6 12S6 13.5 6 9a6 6 0 0 1 6-6M12 7a2 2 0 1 0 0 4 2 2 0 0 0 0-4', +}; + +const FALLBACK_GLYPH = 'M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18M12 8v5M12 16h.01'; + +@Component({ + selector: 'app-location-icon', + template: ` + + `, + styles: ` + :host { + display: inline-flex; + inline-size: 1.5rem; + block-size: 1.5rem; + } + + svg { + inline-size: 100%; + block-size: 100%; + fill: none; + stroke: currentcolor; + stroke-width: 1.4; + stroke-linecap: round; + stroke-linejoin: round; + } + `, +}) +export class LocationIconComponent { + @Input({ required: true }) iconKey!: string; + + protected get glyph(): string { + return GLYPHS[this.iconKey] ?? FALLBACK_GLYPH; + } +} diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html new file mode 100644 index 0000000..69ff7ee --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.html @@ -0,0 +1,28 @@ +@if (result || error) { + +} diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss new file mode 100644 index 0000000..7153d64 --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.scss @@ -0,0 +1,64 @@ +:host { + position: absolute; + inset-block-end: var(--ar-space-5); + inset-inline: 0; + display: grid; + justify-items: center; + pointer-events: none; +} + +.interaction-panel { + display: grid; + gap: var(--ar-space-3); + justify-items: start; + inline-size: min(38rem, calc(100% - var(--ar-space-6))); + padding: var(--ar-space-4) var(--ar-space-5); + border: 1px solid var(--ar-border-highlight); + border-radius: var(--ar-radius-md); + background: + linear-gradient(180deg, rgb(201 164 95 / 0.07), transparent 42%), + rgb(13 15 17 / 0.96); + box-shadow: var(--ar-shadow-raised); + pointer-events: auto; +} + +.interaction-panel__title { + margin: 0; + color: var(--ar-gold); + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.25rem; + font-weight: 400; + letter-spacing: 0.01em; +} + +.interaction-panel__text { + margin: 0; + color: var(--ar-text); + font-size: 0.95rem; + line-height: 1.55; +} + +.interaction-panel__text--error { + color: var(--ar-danger); +} + +.interaction-panel__close { + justify-self: end; + padding: var(--ar-space-2) var(--ar-space-5); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: var(--ar-panel); + font: inherit; + font-size: var(--ar-font-sm); + letter-spacing: 0.04em; + text-transform: uppercase; + transition: + border-color var(--ar-motion-fast), + color var(--ar-motion-fast); +} + +.interaction-panel__close:hover { + border-color: var(--ar-border-highlight); + color: var(--ar-gold); +} diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts new file mode 100644 index 0000000..5af9cc4 --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.spec.ts @@ -0,0 +1,86 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { LocationInteractionPanelComponent } from './location-interaction-panel.component'; + +async function setup(inputs: { + result?: { interactionKey: string; title: string; text: string } | null; + error?: string | null; +}): Promise<{ + fixture: ComponentFixture; + closed: number; + element: HTMLElement; +}> { + await TestBed.configureTestingModule({ + imports: [LocationInteractionPanelComponent], + }).compileComponents(); + + const fixture = TestBed.createComponent(LocationInteractionPanelComponent); + fixture.componentRef.setInput('result', inputs.result ?? null); + fixture.componentRef.setInput('error', inputs.error ?? null); + + let closed = 0; + fixture.componentInstance.closePanel.subscribe(() => { + closed += 1; + }); + fixture.detectChanges(); + + return { + fixture, + get closed() { + return closed; + }, + element: fixture.nativeElement as HTMLElement, + }; +} + +describe('LocationInteractionPanelComponent', () => { + it('shows the title and text the server returned', async () => { + const { element } = await setup({ + result: { + interactionKey: 'inspect-tracks', + title: 'Verdächtige Spuren', + text: 'Frische Stiefelabdrücke führen nach Osten.', + }, + }); + + expect(element.textContent).toContain('Verdächtige Spuren'); + expect(element.textContent).toContain('Frische Stiefelabdrücke führen nach Osten.'); + }); + + it('shows an NPC line through the same panel, with no branching choices', async () => { + const { element } = await setup({ + result: { + interactionKey: 'wounded-scout', + title: 'Verwundeter Kundschafter', + text: '„Die Straße ist nicht mehr sicher."', + }, + }); + + expect(element.textContent).toContain('Verwundeter Kundschafter'); + // Exactly one control: close. A dialogue tree is out of scope here. + expect(element.querySelectorAll('button')).toHaveLength(1); + }); + + it('reports a rejected interaction in place instead of navigating away', async () => { + const { element } = await setup({ error: 'Hier gibt es dazu nichts zu entdecken.' }); + + expect(element.querySelector('[role="alert"]')?.textContent).toContain( + 'Hier gibt es dazu nichts zu entdecken.', + ); + }); + + it('emits close without touching the router', async () => { + const panel = await setup({ + result: { interactionKey: 'k', title: 'T', text: 'X' }, + }); + + panel.element.querySelector('[data-interaction-close]')?.click(); + + expect(panel.closed).toBe(1); + }); + + it('renders nothing while no interaction is open', async () => { + const { element } = await setup({}); + + expect(element.querySelector('[data-interaction-panel]')).toBeNull(); + }); +}); diff --git a/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts new file mode 100644 index 0000000..2acaf84 --- /dev/null +++ b/apps/web/src/app/features/world/location-interaction-panel/location-interaction-panel.component.ts @@ -0,0 +1,21 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { LocationInteractionResult } from '../../../core/api/game-api.models'; + +/** + * Result of a short local interaction, shown over the scene. + * + * Deliberately one panel for investigate, search and talk alike, and + * deliberately without choices: this slice reveals authored text and closes. + * The location stays visible behind it so the player never leaves the place + * they are standing in. + */ +@Component({ + selector: 'app-location-interaction-panel', + templateUrl: './location-interaction-panel.component.html', + styleUrl: './location-interaction-panel.component.scss', +}) +export class LocationInteractionPanelComponent { + @Input() result: LocationInteractionResult | null = null; + @Input() error: string | null = null; + @Output() readonly closePanel = new EventEmitter(); +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.html b/apps/web/src/app/features/world/location-page/location-page.component.html new file mode 100644 index 0000000..1c9beb4 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.html @@ -0,0 +1,72 @@ +
+ @if (store.location(); as location) { +
+
+

{{ location.name }}

+

+ {{ location.regionTierLabel }} + + {{ location.regionName }} +

+

{{ location.localDescription }}

+
+ +
+ + @if (runtimeArtwork(location.localArtworkPath); as runtime) { + + } + + + +
+ @for (poi of location.pointsOfInterest; track poi.key) { + + } +
+ + +
+ + +
+ + + } @else if (store.error(); as error) { + + } @else { +
+

Der Ort wird geladen…

+
+ } +
diff --git a/apps/web/src/app/features/world/location-page/location-page.component.scss b/apps/web/src/app/features/world/location-page/location-page.component.scss new file mode 100644 index 0000000..aa58052 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.scss @@ -0,0 +1,205 @@ +// The app shell sizes itself with `min-block-size` everywhere (a floor, not a +// ceiling: apps/web/src/app/layout/app-shell/app-shell.component.scss), so +// `block-size: 100%` here would resolve against an indefinite ancestor and +// fall back to auto — a `minmax(0, 1fr)` row below would then track content +// size instead of clamping, letting the artwork push the action bar off +// screen. Giving this page its own definite, viewport-bounded height fixes +// that without touching the shell, which other screens still size freely. +// +// Reserve terms are the shell chrome's own `min-block-size` values plus this +// page's own padding, so the number tracks its sources rather than sitting as +// an opaque constant — if the top bar or footer ever needs more room than its +// current floor (a longer name, a wrapped nav row), bump the matching term +// here too: +// apps/web/src/app/layout/top-bar/top-bar.component.scss (5.6rem) +// + apps/web/src/app/layout/game-footer/game-footer.component.scss (3.3rem) +// + this page's own padding, 2 × var(--ar-space-5) (3rem) +// A small safety margin (0.5rem) absorbs sub-pixel/line-height drift so a +// few px of unplanned growth doesn't immediately reopen the clipping bug. +$app-shell-chrome-reserve: calc(5.6rem + 3.3rem + 3rem + 0.5rem); + +:host { + display: block; + block-size: calc(100dvh - #{$app-shell-chrome-reserve}); + min-block-size: 0; +} + +.location-page { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(14rem, 17rem); + gap: var(--ar-space-4); + block-size: 100%; + min-block-size: 0; +} + +.location-page__main { + // Header and action bar take what they need; the artwork absorbs the rest, + // which is what keeps it dominant instead of one card among many. + display: grid; + grid-template-rows: auto minmax(0, 1fr) auto; + gap: var(--ar-space-3); + min-block-size: 0; +} + +.location-page__header { + display: grid; + gap: 0.2rem; +} + +.location-page__title { + margin: 0; + color: #e9dcc0; + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(1.8rem, 2.6vw, 2.6rem); + font-weight: 400; + letter-spacing: 0.01em; + line-height: 1.05; + text-shadow: 0 0.2rem 0.8rem rgb(0 0 0 / 0.8); +} + +.location-page__breadcrumb { + display: flex; + gap: var(--ar-space-2); + margin: 0; + color: var(--ar-text-muted); + font-size: var(--ar-font-sm); + letter-spacing: 0.05em; +} + +.location-page__separator { + color: var(--ar-gold); +} + +.location-page__description { + max-inline-size: 44rem; + margin: var(--ar-space-2) 0 0; + color: var(--ar-text-muted); + font-size: 0.9rem; + line-height: 1.5; +} + +.location-page__scene { + position: relative; + inline-size: 100%; + // Fills the `minmax(0, 1fr)` row exactly — the row is a real, bounded size + // now that `.location-page` has a definite height, so the artwork needs no + // aspect-ratio of its own; `object-fit: cover` on the does the crop. + min-block-size: 14rem; + overflow: hidden; + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-md); + box-shadow: var(--ar-shadow-raised); +} + +.location-page__artwork, +.location-page__artwork img { + display: block; + inline-size: 100%; + block-size: 100%; +} + +.location-page__artwork img { + object-fit: cover; +} + +// Hotspots are positioned against this box, not against the , so the +// percentages stay true to the painted image under `object-fit: cover`. +.location-page__hotspots { + position: absolute; + inset: 0; +} + +.location-page__actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); + gap: var(--ar-space-3); +} + +.location-action { + display: grid; + justify-items: center; + gap: 0.25rem; + padding: var(--ar-space-3) var(--ar-space-2); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: linear-gradient(180deg, rgb(201 164 95 / 0.06), transparent 55%), var(--ar-panel); + font: inherit; + text-align: center; + transition: + border-color var(--ar-motion-fast), + color var(--ar-motion-fast), + background var(--ar-motion-fast); +} + +.location-action app-location-icon { + color: var(--ar-gold); +} + +.location-action__label { + font-family: Georgia, 'Times New Roman', serif; + font-size: clamp(0.85rem, 0.95vw, 1rem); + text-wrap: balance; + white-space: nowrap; +} + +.location-action__hint { + color: var(--ar-text-muted); + font-size: 0.72rem; +} + +.location-action:not(:disabled):hover { + border-color: var(--ar-border-highlight); + background: linear-gradient(180deg, rgb(201 164 95 / 0.12), transparent 55%), var(--ar-panel); +} + +.location-action:not(:disabled):hover .location-action__label { + color: var(--ar-gold); +} + +.location-action:disabled { + opacity: 0.5; +} + +.location-page__notice { + display: grid; + align-content: center; + justify-items: center; + gap: var(--ar-space-3); + grid-column: 1 / -1; + color: var(--ar-text-muted); +} + +.location-page__notice-detail { + margin: 0; + font-size: var(--ar-font-sm); +} + +.location-page__notice button { + padding: var(--ar-space-2) var(--ar-space-5); + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-sm); + color: var(--ar-text); + background: var(--ar-panel); + font: inherit; +} + +@media (width < 1100px) { + .location-page { + grid-template-columns: minmax(0, 1fr); + // Main first, sized to its own natural minimum (scene floors at its + // min-block-size); sidebar gets whatever remains and scrolls internally. + // The reverse order starved main entirely — an `auto` track claims its + // full max-content height before a later `1fr` track sees any space, so + // the sidebar's tall content once pushed the action bar off (0px) while + // consuming the whole column itself. + grid-template-rows: auto minmax(8rem, 1fr); + } + + .location-page__scene { + // Narrower columns give the artwork more natural height at 100% width + // (it has no aspect-ratio of its own below 1100px); floor it lower so + // the sidebar keeps a visible sliver instead of being squeezed to 0. + min-block-size: 10rem; + } +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.spec.ts b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts new file mode 100644 index 0000000..555f3c4 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.spec.ts @@ -0,0 +1,269 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; +import { vi } from 'vitest'; +import type { + CurrentLocationResponse, + LocationInteractionResult, +} from '../../../core/api/game-api.models'; +import { burnedRoadFixture, southGateFixture } from '../current-location.fixture'; +import { LocalLocationStore } from '../local-location.store'; +import { LocationPageComponent } from './location-page.component'; + +describe('LocationPageComponent', () => { + let location: ReturnType>; + let error: ReturnType>; + let interactionResult: ReturnType>; + let interactionError: ReturnType>; + let interactionPending: ReturnType>; + let store: { + location: typeof location; + loading: ReturnType>; + error: typeof error; + interactionResult: typeof interactionResult; + interactionError: typeof interactionError; + interactionPending: typeof interactionPending; + load: ReturnType; + runInteraction: ReturnType; + closeInteraction: ReturnType; + }; + + async function setup(current: CurrentLocationResponse | null = burnedRoadFixture()) { + location = signal(current); + error = signal(null); + interactionResult = signal(null); + interactionError = signal(null); + interactionPending = signal(null); + store = { + location, + loading: signal(false), + error, + interactionResult, + interactionError, + interactionPending, + load: vi.fn().mockResolvedValue(undefined), + runInteraction: vi.fn().mockResolvedValue(undefined), + closeInteraction: vi.fn(), + }; + + await TestBed.configureTestingModule({ + imports: [LocationPageComponent], + providers: [provideRouter([]), { provide: LocalLocationStore, useValue: store }], + }).compileComponents(); + + const fixture = TestBed.createComponent(LocationPageComponent); + const router = TestBed.inject(Router); + vi.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.detectChanges(); + + return { fixture, router, element: fixture.nativeElement as HTMLElement }; + } + + it('names the place and its region straight away', async () => { + const { element } = await setup(); + + expect(element.querySelector('h1')?.textContent).toContain('Verbrannte Straße'); + expect(element.textContent).toContain('Gebiet 1'); + expect(element.textContent).toContain('Aschenfelder'); + expect(element.textContent).toContain( + 'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.', + ); + }); + + it('renders the location artwork, not the composition mockup', async () => { + const { element } = await setup(); + const image = element.querySelector('img') as HTMLImageElement; + + expect(image.getAttribute('src')).toBe('/images/backgrounds/Aschestrasse.png'); + expect(image.getAttribute('alt')).toBe('Ortsansicht: Verbrannte Straße'); + }); + + it('places every hotspot on the artwork', async () => { + const { element } = await setup(); + const hotspots = element.querySelectorAll('app-location-poi'); + + expect(hotspots).toHaveLength(4); + expect([...hotspots].map((poi) => poi.querySelector('button')?.dataset['poi'])).toEqual([ + 'hunt-area', + 'inspect-tracks', + 'search-abandoned-wagon', + 'wounded-scout', + ]); + }); + + it('renders the four primary actions in the authored order', async () => { + const { element } = await setup(); + const actions = element.querySelectorAll('[data-action]'); + + expect([...actions].map((action) => action.querySelector('.location-action__label')?.textContent?.trim())).toEqual([ + 'Jagd beginnen', + 'Spuren untersuchen', + 'Umgebung durchsuchen', + 'Zur Karte', + ]); + }); + + it('renders the context sidebar', async () => { + const { element } = await setup(); + + expect(element.querySelector('app-location-sidebar')).not.toBeNull(); + }); + + it('hands a hunt hotspot to the existing hunt screen without rolling encounters', async () => { + const { element, router } = await setup(); + + element.querySelector('[data-poi="hunt-area"]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/hunt']); + expect(store.runInteraction).not.toHaveBeenCalled(); + }); + + it('sends the hunt action to the hunt screen too', async () => { + const { element, router } = await setup(); + + element.querySelector('[data-action="start-hunt"]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/hunt']); + }); + + it('opens the existing map route without completing any travel itself', async () => { + const { element, router } = await setup(); + + element.querySelector('[data-action="open-map"]')?.click(); + + expect(router.navigate).toHaveBeenCalledWith(['/world']); + expect(store.runInteraction).not.toHaveBeenCalled(); + }); + + it('asks the server what investigating the tracks reveals', async () => { + const { element, router } = await setup(); + + element.querySelector('[data-poi="inspect-tracks"]')?.click(); + + expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks'); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('asks the server what searching the wagon reveals', async () => { + const { element } = await setup(); + + element.querySelector('[data-poi="search-abandoned-wagon"]')?.click(); + + expect(store.runInteraction).toHaveBeenCalledWith('search-abandoned-wagon'); + }); + + it('asks the server what the scout says', async () => { + const { element } = await setup(); + + element.querySelector('[data-poi="wounded-scout"]')?.click(); + + expect(store.runInteraction).toHaveBeenCalledWith('wounded-scout'); + }); + + it('routes an action through the hotspot it mirrors, so both show the same text', async () => { + const { element } = await setup(); + + element.querySelector('[data-action="investigate-tracks"]')?.click(); + + expect(store.runInteraction).toHaveBeenCalledWith('inspect-tracks'); + }); + + it('shows the interaction result over the still-visible location', async () => { + const { fixture, element } = await setup(); + + interactionResult.set({ + interactionKey: 'inspect-tracks', + title: 'Verdächtige Spuren', + text: 'Frische Stiefelabdrücke führen nach Osten.', + }); + fixture.detectChanges(); + + expect(element.querySelector('[data-interaction-panel]')?.textContent).toContain( + 'Frische Stiefelabdrücke führen nach Osten.', + ); + // The scene is not replaced by the panel. + expect(element.querySelector('img')).not.toBeNull(); + expect(element.querySelectorAll('app-location-poi')).toHaveLength(4); + }); + + it('closes the panel without navigating', async () => { + const { fixture, element, router } = await setup(); + + interactionResult.set({ interactionKey: 'k', title: 'T', text: 'X' }); + fixture.detectChanges(); + element.querySelector('[data-interaction-close]')?.click(); + + expect(store.closeInteraction).toHaveBeenCalledTimes(1); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('disables the controls while an interaction is running', async () => { + const { fixture, element } = await setup(); + + interactionPending.set('inspect-tracks'); + fixture.detectChanges(); + + const actions = element.querySelectorAll('[data-action]'); + expect([...actions].every((action) => action.disabled)).toBe(true); + expect(element.textContent).toContain('Einen Moment…'); + }); + + it('renders a restrained loading state that shows no misplaced hotspots', async () => { + const { element } = await setup(null); + + expect(element.querySelector('[role="status"]')?.textContent).toContain( + 'Der Ort wird geladen', + ); + expect(element.querySelectorAll('app-location-poi')).toHaveLength(0); + }); + + it('offers a retry when the location could not be loaded', async () => { + const { fixture, element } = await setup(null); + + error.set('Weltzustand konnte nicht geladen werden.'); + fixture.detectChanges(); + + expect(element.querySelector('[role="alert"]')?.textContent).toContain( + 'Ort konnte nicht geladen werden.', + ); + + element.querySelector('[data-location-retry]')?.click(); + expect(store.load).toHaveBeenCalled(); + }); + + it('renders a second location from its own data alone', async () => { + const { element } = await setup(southGateFixture(southGateContent())); + + expect(element.querySelector('h1')?.textContent).toContain('Südtor von Graufurt'); + expect(element.querySelectorAll('app-location-poi')).toHaveLength(1); + expect(element.querySelectorAll('[data-action]')).toHaveLength(1); + }); +}); + +function southGateContent(): Partial { + return { + pointsOfInterest: [ + { + key: 'gate-watch', + title: 'Torwache', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 45, + yPercent: 52, + enabled: true, + }, + ], + primaryActions: [ + { + key: 'talk-to-watch', + label: 'Wache ansprechen', + description: 'Lage erfragen', + type: 'NPC', + iconKey: 'speak', + enabled: true, + poiKey: 'gate-watch', + }, + ], + }; +} diff --git a/apps/web/src/app/features/world/location-page/location-page.component.ts b/apps/web/src/app/features/world/location-page/location-page.component.ts new file mode 100644 index 0000000..6a0eaa9 --- /dev/null +++ b/apps/web/src/app/features/world/location-page/location-page.component.ts @@ -0,0 +1,96 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { Router } from '@angular/router'; +import { + LocationInteractionType, + LocationPointOfInterest, + LocationPrimaryAction, +} from '../../../core/api/game-api.models'; +import { LocalLocationStore } from '../local-location.store'; +import { LocationInteractionPanelComponent } from '../location-interaction-panel/location-interaction-panel.component'; +import { LocationIconComponent } from '../location-icon/location-icon.component'; +import { LocationPoiComponent } from '../location-poi/location-poi.component'; +import { LocationSidebarComponent } from '../location-sidebar/location-sidebar.component'; + +const RUNTIME_ARTWORK: Readonly> = { + '/images/backgrounds/Suedtor.png': '/images/backgrounds/runtime/Suedtor-960.jpg', + '/images/backgrounds/Aschestrasse.png': '/images/backgrounds/runtime/Aschestrasse-960.jpg', +}; + +/** + * The screen the player stands on between activities. + * + * It renders whatever the current location's content describes and owns no + * knowledge of any particular place. Hunting and travel are not reimplemented + * here: HUNT and MAP hotspots hand off to the existing screens, and everything + * that reveals text goes through the server-authoritative interaction endpoint. + */ +@Component({ + selector: 'app-location-page', + imports: [ + LocationIconComponent, + LocationInteractionPanelComponent, + LocationPoiComponent, + LocationSidebarComponent, + ], + templateUrl: './location-page.component.html', + styleUrl: './location-page.component.scss', +}) +export class LocationPageComponent implements OnInit { + protected readonly store = inject(LocalLocationStore); + private readonly router = inject(Router); + + ngOnInit(): void { + if (this.store.location() === null) { + void this.store.load(); + } + } + + protected retry(): void { + void this.store.load(); + } + + protected activatePoi(poi: LocationPointOfInterest): void { + this.dispatch(poi.type, poi.key); + } + + protected activateAction(action: LocationPrimaryAction): void { + this.dispatch(action.type, action.poiKey ?? action.key); + } + + protected runtimeArtwork(artworkPath: string): string | undefined { + return RUNTIME_ARTWORK[artworkPath]; + } + + /** True while this control's own interaction is in flight. */ + protected isPending(key: string | undefined): boolean { + return key !== undefined && this.store.interactionPending() === key; + } + + protected get busy(): boolean { + return this.store.interactionPending() !== null; + } + + /** + * Routes a hotspot or action by its interaction type. Navigation types leave + * for the screen that owns them; every other type asks the server what + * happened. Unimplemented types are ignored rather than faked. + */ + private dispatch(type: LocationInteractionType, interactionKey: string): void { + switch (type) { + case 'HUNT': + void this.router.navigate(['/hunt']); + return; + case 'MAP': + case 'TRAVEL': + void this.router.navigate(['/world']); + return; + case 'INVESTIGATE': + case 'SEARCH': + case 'NPC': + void this.store.runInteraction(interactionKey); + return; + default: + return; + } + } +} diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.html b/apps/web/src/app/features/world/location-poi/location-poi.component.html new file mode 100644 index 0000000..721530d --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.html @@ -0,0 +1,17 @@ + diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.scss b/apps/web/src/app/features/world/location-poi/location-poi.component.scss new file mode 100644 index 0000000..b19d4cf --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.scss @@ -0,0 +1,88 @@ +:host { + position: absolute; + // The host carries left/top from the percentage coordinates; this centres + // the marker on that point instead of hanging it off the top-left corner. + transform: translate(-50%, -50%); +} + +.location-poi { + display: grid; + justify-items: center; + gap: 0.2rem; + padding: 0.35rem; + border: 0; + border-radius: var(--ar-radius-md); + color: var(--ar-text); + background: transparent; + font: inherit; + text-align: center; + text-shadow: 0 0.15rem 0.55rem rgb(0 0 0 / 0.95); + transition: + transform var(--ar-motion-fast), + filter var(--ar-motion-fast); +} + +.location-poi__medallion { + display: grid; + place-items: center; + inline-size: 2.6rem; + block-size: 2.6rem; + border: 0.13rem solid var(--ar-gold); + border-radius: 50%; + color: #e8d5a8; + background: + radial-gradient(circle at 50% 38%, rgb(201 164 95 / 0.22), transparent 62%), + radial-gradient(circle, #221c14 30%, #14171a 76%); + box-shadow: + 0 0 0 0.14rem rgb(6 8 9 / 0.8), + 0 0.3rem 0.9rem rgb(0 0 0 / 0.6), + 0 0 0.85rem rgb(201 164 95 / 0.28); +} + +.location-poi__title { + max-inline-size: 11rem; + font-family: Georgia, 'Times New Roman', serif; + font-size: 0.95rem; + line-height: 1.2; +} + +.location-poi__action { + color: var(--ar-gold); + font-size: var(--ar-font-sm); + letter-spacing: 0.02em; +} + +.location-poi:not(:disabled):hover, +.location-poi:not(:disabled):focus-visible { + transform: translateY(-0.1rem); +} + +.location-poi:not(:disabled):hover .location-poi__medallion, +.location-poi:not(:disabled):focus-visible .location-poi__medallion { + border-color: #e1bd72; + box-shadow: + 0 0 0 0.14rem rgb(6 8 9 / 0.8), + 0 0.3rem 0.9rem rgb(0 0 0 / 0.6), + 0 0 1.15rem rgb(225 189 114 / 0.72); +} + +.location-poi:focus-visible { + outline: 2px solid var(--ar-blue); + outline-offset: 0.15rem; +} + +.location-poi:disabled { + filter: grayscale(0.7); + opacity: 0.55; +} + +.location-poi--busy { + opacity: 0.75; +} + +@media (prefers-reduced-motion: reduce) { + .location-poi:not(:disabled):hover, + .location-poi:not(:disabled):focus-visible { + transform: none; + } +} diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts new file mode 100644 index 0000000..27b057d --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.spec.ts @@ -0,0 +1,116 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import type { LocationPointOfInterest } from '../../../core/api/game-api.models'; +import { LocationPoiComponent } from './location-poi.component'; + +const wagon: LocationPointOfInterest = { + key: 'search-abandoned-wagon', + title: 'Verlassener Wagen', + actionLabel: 'Durchsuchen', + type: 'SEARCH', + iconKey: 'search', + xPercent: 80, + yPercent: 68, + enabled: true, +}; + +async function setup( + poi: LocationPointOfInterest = wagon, + busy = false, +): Promise<{ + fixture: ComponentFixture; + activated: LocationPointOfInterest[]; + button: HTMLButtonElement; +}> { + await TestBed.configureTestingModule({ + imports: [LocationPoiComponent], + }).compileComponents(); + + const fixture = TestBed.createComponent(LocationPoiComponent); + fixture.componentRef.setInput('poi', poi); + fixture.componentRef.setInput('busy', busy); + + const activated: LocationPointOfInterest[] = []; + fixture.componentInstance.activate.subscribe((value) => activated.push(value)); + fixture.detectChanges(); + + return { + fixture, + activated, + button: fixture.nativeElement.querySelector('button') as HTMLButtonElement, + }; +} + +describe('LocationPoiComponent', () => { + it('anchors itself with the percentage coordinates so it scales with the artwork', async () => { + const { fixture } = await setup(); + const host = fixture.nativeElement as HTMLElement; + + expect(host.style.left).toBe('80%'); + expect(host.style.top).toBe('68%'); + }); + + it('renders title and action label', async () => { + const { fixture } = await setup(); + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + + expect(text).toContain('Verlassener Wagen'); + expect(text).toContain('Durchsuchen'); + }); + + it('emits the whole hotspot when clicked', async () => { + const { button, activated } = await setup(); + + button.click(); + + expect(activated).toEqual([wagon]); + }); + + it('is reachable and activatable from the keyboard', async () => { + const { button, activated } = await setup(); + + // A native button gives Enter and Space for free; assert it stayed a + // button rather than becoming a click-only div. + expect(button.tagName).toBe('BUTTON'); + expect(button.tabIndex).toBe(0); + + button.focus(); + expect(document.activeElement).toBe(button); + + button.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter' })); + button.click(); + + expect(activated).toHaveLength(1); + }); + + it('does nothing when the hotspot is disabled', async () => { + const { button, activated } = await setup({ ...wagon, enabled: false }); + + expect(button.disabled).toBe(true); + button.click(); + + expect(activated).toEqual([]); + }); + + it('does nothing while an interaction is already running', async () => { + const { button, activated } = await setup(wagon, true); + + button.click(); + + expect(activated).toEqual([]); + }); + + it('describes itself with both title and action for screen readers', async () => { + const { button } = await setup(); + + expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen: Durchsuchen'); + }); + + it('falls back to the title alone when a hotspot has no action label', async () => { + const { button } = await setup({ + ...wagon, + actionLabel: undefined, + }); + + expect(button.getAttribute('aria-label')).toBe('Verlassener Wagen'); + }); +}); diff --git a/apps/web/src/app/features/world/location-poi/location-poi.component.ts b/apps/web/src/app/features/world/location-poi/location-poi.component.ts new file mode 100644 index 0000000..1cecbf8 --- /dev/null +++ b/apps/web/src/app/features/world/location-poi/location-poi.component.ts @@ -0,0 +1,34 @@ +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { LocationPointOfInterest } from '../../../core/api/game-api.models'; +import { LocationIconComponent } from '../location-icon/location-icon.component'; + +/** + * One hotspot pinned to the location artwork. + * + * Purely presentational: it renders the marker, reports activation and knows + * nothing about what the interaction does. The page decides whether a hotspot + * navigates or opens the interaction panel. + */ +@Component({ + selector: 'app-location-poi', + imports: [LocationIconComponent], + templateUrl: './location-poi.component.html', + styleUrl: './location-poi.component.scss', + host: { + // Percentages of the artwork box, so a marker keeps sitting on the same + // painted detail at every viewport width. + '[style.left.%]': 'poi.xPercent', + '[style.top.%]': 'poi.yPercent', + }, +}) +export class LocationPoiComponent { + @Input({ required: true }) poi!: LocationPointOfInterest; + @Input() busy = false; + @Output() readonly activate = new EventEmitter(); + + protected onActivate(): void { + if (this.poi.enabled && !this.busy) { + this.activate.emit(this.poi); + } + } +} diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html new file mode 100644 index 0000000..ac644aa --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.html @@ -0,0 +1,73 @@ + diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss new file mode 100644 index 0000000..bf991cb --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.scss @@ -0,0 +1,130 @@ +:host { + display: block; + min-block-size: 0; +} + +.location-sidebar { + display: grid; + align-content: start; + gap: var(--ar-space-4); + block-size: 100%; + padding: var(--ar-space-4); + overflow-y: auto; + border: 1px solid var(--ar-border); + border-radius: var(--ar-radius-md); + background: + linear-gradient(180deg, rgb(201 164 95 / 0.05), transparent 30%), var(--ar-panel); +} + +.location-sidebar__identity { + display: grid; + gap: var(--ar-space-3); + margin: 0; +} + +.location-sidebar__identity dt { + color: var(--ar-text-muted); + font-size: 0.7rem; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.location-sidebar__identity dd { + margin: 0.15rem 0 0; + color: var(--ar-text); + font-size: 0.95rem; +} + +.location-sidebar__name { + font-family: Georgia, 'Times New Roman', serif; + font-size: 1.1rem; +} + +.location-sidebar__recommendation { + color: var(--ar-success); + font-weight: 600; +} + +.location-sidebar__safe { + color: var(--ar-success); + font-weight: 600; +} + +.location-sidebar__block { + display: grid; + gap: var(--ar-space-2); + padding-block-start: var(--ar-space-4); + border-block-start: 1px solid var(--ar-border); +} + +.location-sidebar__block h3 { + margin: 0; + color: var(--ar-text-muted); + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.09em; + text-transform: uppercase; +} + +.location-sidebar__block ul { + margin: 0; + padding: 0; + list-style: none; +} + +.location-sidebar__encounters { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(3.5rem, 1fr)); + gap: var(--ar-space-2); +} + +.location-sidebar__encounters li { + display: grid; + justify-items: center; + gap: 0.2rem; + text-align: center; +} + +.location-sidebar__encounters img { + inline-size: 3rem; + block-size: 3rem; + border: 1px solid var(--ar-border); + border-radius: 50%; +} + +.location-sidebar__encounters span { + color: var(--ar-text-muted); + font-size: 0.65rem; + line-height: 1.25; +} + +.location-sidebar__note { + margin: 0; + color: var(--ar-text-muted); + font-size: 0.68rem; + font-style: italic; +} + +.location-sidebar__interactions, +.location-sidebar__rewards { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: var(--ar-space-2); +} + +.location-sidebar__interactions li, +.location-sidebar__rewards li { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + align-items: center; + gap: var(--ar-space-2); + color: var(--ar-text); + font-size: var(--ar-font-sm); +} + +.location-sidebar__interactions app-location-icon, +.location-sidebar__rewards app-location-icon { + inline-size: 1.35rem; + block-size: 1.35rem; + color: var(--ar-gold); +} diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts new file mode 100644 index 0000000..0c7c39d --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.spec.ts @@ -0,0 +1,97 @@ +import { TestBed } from '@angular/core/testing'; +import type { CurrentLocationResponse } from '../../../core/api/game-api.models'; +import { burnedRoadFixture, southGateFixture } from '../current-location.fixture'; +import { LocationSidebarComponent } from './location-sidebar.component'; + +async function render(location: CurrentLocationResponse): Promise { + await TestBed.configureTestingModule({ + imports: [LocationSidebarComponent], + }).compileComponents(); + + const fixture = TestBed.createComponent(LocationSidebarComponent); + fixture.componentRef.setInput('location', location); + fixture.detectChanges(); + + return fixture.nativeElement as HTMLElement; +} + +describe('LocationSidebarComponent', () => { + it('states where the player is and what kind of place it is', async () => { + const element = await render(burnedRoadFixture()); + const text = element.textContent ?? ''; + + expect(text).toContain('Aschenfelder'); + expect(text).toContain('Verbrannte Straße'); + expect(text).toContain('Jagdgebiet'); + expect(text).toContain('1–2'); + }); + + it('shows the danger rating as the shared badge', async () => { + const element = await render(burnedRoadFixture()); + + expect(element.querySelector('app-danger-badge')?.textContent).toContain('Passend'); + }); + + it('calls a location with no hostile pool safe instead of inventing a rating', async () => { + const element = await render(southGateFixture()); + + expect(element.querySelector('app-danger-badge')).toBeNull(); + expect(element.textContent).toContain('Sicher'); + }); + + it('previews the encounters and marks them as preview only', async () => { + const element = await render(burnedRoadFixture()); + const encounters = element.querySelectorAll('[data-sidebar="encounters"] li'); + + expect([...encounters].map((item) => item.textContent?.trim())).toEqual([ + 'Aschenratte', + 'Straßenräuber', + ]); + expect(element.textContent).toContain('die Jagd würfelt eigenständig'); + }); + + it('lists each interaction kind once, taken from the enabled hotspots', async () => { + const element = await render(burnedRoadFixture()); + const interactions = element.querySelectorAll('[data-sidebar="interactions"] li'); + + expect([...interactions].map((item) => item.textContent?.trim())).toEqual([ + 'Jagd beginnen', + 'Untersuchen', + 'Durchsuchen', + 'Sprechen', + ]); + }); + + it('never advertises an interaction whose hotspot is disabled', async () => { + const location = burnedRoadFixture(); + const element = await render({ + ...location, + pointsOfInterest: location.pointsOfInterest.map((poi) => + poi.type === 'NPC' ? { ...poi, enabled: false } : poi, + ), + }); + + expect(element.querySelector('[data-sidebar="interactions"]')?.textContent).not.toContain( + 'Sprechen', + ); + }); + + it('shows the reward categories the location actually backs', async () => { + const element = await render(burnedRoadFixture()); + const rewards = element.querySelectorAll('[data-sidebar="rewards"] li'); + + expect([...rewards].map((item) => item.textContent?.trim())).toEqual([ + 'Silber', + 'Erfahrung', + 'Ausrüstung', + 'Material', + ]); + }); + + it('omits empty blocks entirely rather than rendering headings with nothing under them', async () => { + const element = await render(southGateFixture()); + + expect(element.querySelector('[data-sidebar="encounters"]')).toBeNull(); + expect(element.querySelector('[data-sidebar="rewards"]')).toBeNull(); + }); +}); diff --git a/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts new file mode 100644 index 0000000..cbfb826 --- /dev/null +++ b/apps/web/src/app/features/world/location-sidebar/location-sidebar.component.ts @@ -0,0 +1,59 @@ +import { Component, Input } from '@angular/core'; +import { CurrentLocationResponse } from '../../../core/api/game-api.models'; +import { DangerBadgeComponent } from '../../../shared/danger-badge/danger-badge.component'; +import { LocationIconComponent } from '../location-icon/location-icon.component'; + +const LOCATION_TYPE_LABELS: Readonly> = { + SAFE_HUB: 'Zuflucht', + TRANSITION: 'Übergang', + HUNTING_GROUND: 'Jagdgebiet', + QUEST_LOCATION: 'Questort', + OUTPOST: 'Außenposten', + ELITE_ZONE: 'Elitegebiet', + BOSS_LOCATION: 'Bossort', + DUNGEON_ENTRANCE: 'Verlieseingang', +}; + +interface InteractionSummary { + label: string; + iconKey: string; +} + +/** + * What this place means in play: identity, danger, who lives here, what can be + * done and what it pays. Every block is fed from the location payload, so a + * new location fills the same sidebar without a code change. + */ +@Component({ + selector: 'app-location-sidebar', + imports: [DangerBadgeComponent, LocationIconComponent], + templateUrl: './location-sidebar.component.html', + styleUrl: './location-sidebar.component.scss', +}) +export class LocationSidebarComponent { + @Input({ required: true }) location!: CurrentLocationResponse; + + protected get locationTypeLabel(): string { + return LOCATION_TYPE_LABELS[this.location.locationType] ?? this.location.locationType; + } + + /** + * Distinct interaction kinds available here, taken from the hotspots that + * are actually enabled — the list can never advertise more than the scene + * offers. + */ + protected get interactions(): InteractionSummary[] { + const seen = new Map(); + + for (const poi of this.location.pointsOfInterest) { + if (poi.enabled && !seen.has(poi.type)) { + seen.set(poi.type, { + label: poi.actionLabel ?? poi.title, + iconKey: poi.iconKey, + }); + } + } + + return [...seen.values()]; + } +} diff --git a/apps/web/src/app/features/world/world-page.component.spec.ts b/apps/web/src/app/features/world/world-page.component.spec.ts index ae0ce72..3426b4b 100644 --- a/apps/web/src/app/features/world/world-page.component.spec.ts +++ b/apps/web/src/app/features/world/world-page.component.spec.ts @@ -1,11 +1,14 @@ import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { Router, provideRouter } from '@angular/router'; import { vi } from 'vitest'; import type { CurrentLocationConnection, CurrentLocationResponse, CurrentTravel, + LocationSummary, } from '../../core/api/game-api.models'; +import { burnedRoadFixture, southGateFixture } from './current-location.fixture'; import { WorldStore } from './world.store'; import { WorldPageComponent } from './world-page.component'; @@ -19,40 +22,9 @@ const burnedRoadConnection: CurrentLocationConnection = { danger: 'LOW', }; -const southGate: CurrentLocationResponse = { - id: 'south-gate-id', - key: 'south-gate', - name: 'Südtor von Graufurt', - description: 'Der letzte sichere Schritt vor den Aschenfeldern.', - regionKey: 'ashen-fields', - minRecommendedLevel: 1, - maxRecommendedLevel: 1, - dangerLevel: 0, - isSafe: true, - huntingEnabled: false, - artworkPath: '/images/backgrounds/Suedtor.png', - connections: [burnedRoadConnection], - possibleMonsters: [], -}; +const southGate = southGateFixture({ connections: [burnedRoadConnection] }); -const burnedRoad: CurrentLocationResponse = { - ...southGate, - id: 'burned-road-id', - key: 'burned-road', - name: 'Verbrannte Straße', - description: 'Die erste Jagdzone zwischen Asche und zerbrochenen Wagen.', - isSafe: false, - huntingEnabled: true, - artworkPath: '/images/backgrounds/Aschestrasse.png', - possibleMonsters: ['goblin', 'skeleton'], - connections: [ - { - targetLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' }, - travelDurationSeconds: 10, - danger: 'LOW', - }, - ], -}; +const burnedRoad = burnedRoadFixture(); describe('WorldPageComponent', () => { let selectedConnection: ReturnType>; @@ -63,9 +35,11 @@ describe('WorldPageComponent', () => { remainingSeconds: ReturnType>; loading: ReturnType>; error: ReturnType>; + arrived: ReturnType>; load: () => Promise; selectConnection: (connection: CurrentLocationConnection | null) => void; startTravel: () => Promise; + acknowledgeArrival: () => void; }; beforeEach(async () => { @@ -77,16 +51,18 @@ describe('WorldPageComponent', () => { remainingSeconds: signal(null), loading: signal(false), error: signal(null), + arrived: signal(null), load: vi.fn(() => Promise.resolve()), selectConnection: vi.fn((connection: CurrentLocationConnection | null) => selectedConnection.set(connection), ), startTravel: vi.fn(() => Promise.resolve()), + acknowledgeArrival: vi.fn(() => store.arrived.set(null)), }; await TestBed.configureTestingModule({ imports: [WorldPageComponent], - providers: [{ provide: WorldStore, useValue: store }], + providers: [provideRouter([]), { provide: WorldStore, useValue: store }], }).compileComponents(); }); @@ -206,4 +182,40 @@ describe('WorldPageComponent', () => { expect(store.load).toHaveBeenCalledTimes(2); }); + + it('opens the location view once a journey has finished', () => { + const fixture = TestBed.createComponent(WorldPageComponent); + const router = TestBed.inject(Router); + const navigate = vi.spyOn(router, 'navigate').mockResolvedValue(true); + fixture.detectChanges(); + + expect(navigate).not.toHaveBeenCalled(); + + store.arrived.set({ + id: 'burned-road-id', + key: 'burned-road', + name: 'Verbrannte Straße', + }); + fixture.detectChanges(); + + expect(navigate).toHaveBeenCalledWith(['/location']); + // Acknowledged, so a later change detection cycle cannot navigate twice. + expect(store.acknowledgeArrival).toHaveBeenCalledTimes(1); + expect(navigate).toHaveBeenCalledTimes(1); + }); + + it('stays on the map while a journey is still running', () => { + store.currentTravel.set({ + status: 'TRAVELLING', + originLocation: { id: 'south-gate-id', key: 'south-gate', name: 'Südtor von Graufurt' }, + targetLocation: burnedRoadConnection.targetLocation, + startedAt: '2026-08-20T10:00:00.000Z', + arrivesAt: '2026-08-20T10:00:10.000Z', + }); + const fixture = TestBed.createComponent(WorldPageComponent); + const navigate = vi.spyOn(TestBed.inject(Router), 'navigate').mockResolvedValue(true); + fixture.detectChanges(); + + expect(navigate).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/src/app/features/world/world-page.component.ts b/apps/web/src/app/features/world/world-page.component.ts index 0e65124..7378820 100644 --- a/apps/web/src/app/features/world/world-page.component.ts +++ b/apps/web/src/app/features/world/world-page.component.ts @@ -1,4 +1,5 @@ -import { Component, OnInit, inject } from '@angular/core'; +import { Component, OnInit, effect, inject } from '@angular/core'; +import { Router } from '@angular/router'; import { CurrentLocationConnection } from '../../core/api/game-api.models'; import { LocationNodeComponent } from './location-node.component'; import { TravelPanelComponent } from './travel-panel.component'; @@ -12,6 +13,18 @@ import { WorldStore } from './world.store'; }) export class WorldPageComponent implements OnInit { protected readonly worldStore = inject(WorldStore); + private readonly router = inject(Router); + + constructor() { + // A finished journey ends at the place, not back on the map. The server + // still owns the arrival itself; this only decides which screen shows it. + effect(() => { + if (this.worldStore.arrived()) { + this.worldStore.acknowledgeArrival(); + void this.router.navigate(['/location']); + } + }); + } ngOnInit(): void { void this.worldStore.load(); diff --git a/apps/web/src/app/features/world/world.store.spec.ts b/apps/web/src/app/features/world/world.store.spec.ts index 169defa..589c26c 100644 --- a/apps/web/src/app/features/world/world.store.spec.ts +++ b/apps/web/src/app/features/world/world.store.spec.ts @@ -8,6 +8,7 @@ import type { CurrentTravel, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; +import { southGateFixture } from './current-location.fixture'; import { WorldStore } from './world.store'; const character: CharacterResponse = { @@ -22,19 +23,11 @@ const character: CharacterResponse = { currentLocation: { id: 'origin-id', key: 'south-gate', name: 'Südtor' }, }; -const currentLocation: CurrentLocationResponse = { +const currentLocation = southGateFixture({ id: 'origin-id', - key: 'south-gate', name: 'Südtor', description: 'Der Ausgang zur Wildnis.', - regionKey: 'ashen-fields', - minRecommendedLevel: 1, - maxRecommendedLevel: 1, dangerLevel: 1, - isSafe: true, - huntingEnabled: false, - artworkPath: '/images/backgrounds/Suedtor.png', - possibleMonsters: [], connections: [ { targetLocation: { @@ -46,7 +39,7 @@ const currentLocation: CurrentLocationResponse = { danger: 'LOW', }, ], -}; +}); const travelling: CurrentTravel = { status: 'TRAVELLING', @@ -208,6 +201,32 @@ describe('WorldStore', () => { expect(api.getCurrentLocation).toHaveBeenCalledTimes(2); }); + it('reports the arrival only once the new location has been re-read', async () => { + api.getCurrentTravel + .mockReturnValueOnce(of(travelling)) + .mockReturnValueOnce(of({ status: 'COMPLETED', targetLocation: travelling.targetLocation })); + + await store.load(); + expect(store.arrived()).toBeNull(); + + await vi.advanceTimersByTimeAsync(10_000); + + expect(store.arrived()).toEqual(travelling.targetLocation); + expect(api.getCurrentLocation).toHaveBeenCalledTimes(2); + + store.acknowledgeArrival(); + expect(store.arrived()).toBeNull(); + }); + + it('never reports an arrival while the journey is still running', async () => { + api.getCurrentTravel.mockReturnValue(of(travelling)); + + await store.load(); + await vi.advanceTimersByTimeAsync(5_000); + + expect(store.arrived()).toBeNull(); + }); + it('clears selection and rejects a second start while authoritative completion reload is pending', async () => { const pendingCharacter = new Subject(); const pendingLocation = new Subject(); diff --git a/apps/web/src/app/features/world/world.store.ts b/apps/web/src/app/features/world/world.store.ts index 95d9c52..9f7ce4c 100644 --- a/apps/web/src/app/features/world/world.store.ts +++ b/apps/web/src/app/features/world/world.store.ts @@ -6,6 +6,7 @@ import { CurrentLocationConnection, CurrentLocationResponse, CurrentTravel, + LocationSummary, } from '../../core/api/game-api.models'; import { GameApiService } from '../../core/api/game-api.service'; @@ -27,6 +28,7 @@ export class WorldStore implements OnDestroy { private readonly selectedConnectionState = signal(null); private readonly currentTravelState = signal(null); private readonly remainingSecondsState = signal(null); + private readonly arrivedState = signal(null); private readonly loadingState = signal(false); private readonly errorState = signal(null); private countdownTimer: ReturnType | undefined; @@ -39,6 +41,8 @@ export class WorldStore implements OnDestroy { readonly selectedConnection = this.selectedConnectionState.asReadonly(); readonly currentTravel = this.currentTravelState.asReadonly(); readonly remainingSeconds = this.remainingSecondsState.asReadonly(); + /** Set once a journey has finished and the new location has been re-read. */ + readonly arrived = this.arrivedState.asReadonly(); readonly loading = this.loadingState.asReadonly(); readonly error = this.errorState.asReadonly(); @@ -77,6 +81,11 @@ export class WorldStore implements OnDestroy { this.selectedConnectionState.set(connection); } + /** Clears the arrival flag once a screen has acted on it. */ + acknowledgeArrival(): void { + this.arrivedState.set(null); + } + /** * Re-reads the character from the server, e.g. after a combat granted XP and * silver. Never mutates the values locally: the server owns them (spec §35). @@ -172,6 +181,10 @@ export class WorldStore implements OnDestroy { await this.reloadAuthoritativeState(); if (!this.destroyed) { this.currentTravelState.set({ status: 'IDLE' }); + // Raised only after the server-owned current location has been + // re-read, so whoever reacts to an arrival sees the new place. The + // store does not navigate itself; routing stays with the screen. + this.arrivedState.set(travel.targetLocation); } } finally { if (!this.destroyed) { diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.html b/apps/web/src/app/layout/app-shell/app-shell.component.html index 07ff3cd..b8ae48c 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.html +++ b/apps/web/src/app/layout/app-shell/app-shell.component.html @@ -1,12 +1,12 @@
-
+
- @if (!inCombat()) { + @if (showContextPanel()) { }
diff --git a/apps/web/src/app/layout/app-shell/app-shell.component.ts b/apps/web/src/app/layout/app-shell/app-shell.component.ts index c8160f4..726e1f5 100644 --- a/apps/web/src/app/layout/app-shell/app-shell.component.ts +++ b/apps/web/src/app/layout/app-shell/app-shell.component.ts @@ -19,9 +19,18 @@ import { TopBarComponent } from '../top-bar/top-bar.component'; styleUrl: './app-shell.component.scss', }) export class AppShellComponent { + private readonly router = inject(Router); + protected readonly worldStore = inject(WorldStore); // The fight has its own log rail and wants the width, and the area info // belongs to the world view anyway, so the rail is dropped during combat. - protected readonly inCombat = isActive('/combat', inject(Router)); + private readonly inCombat = isActive('/combat', this.router); + + // The location view brings its own, far richer context sidebar. Showing the + // shell's generic area panel next to it would say the same thing twice and + // squeeze the artwork the screen is built around. + private readonly atLocation = isActive('/location', this.router); + + protected readonly showContextPanel = () => !this.inCombat() && !this.atLocation(); } diff --git a/apps/web/src/app/layout/side-navigation/side-navigation.component.html b/apps/web/src/app/layout/side-navigation/side-navigation.component.html index 563ea58..3c894d0 100644 --- a/apps/web/src/app/layout/side-navigation/side-navigation.component.html +++ b/apps/web/src/app/layout/side-navigation/side-navigation.component.html @@ -1,4 +1,22 @@