diff --git a/apps/api/src/database/migrations/1788600000000-CreateLocalLocationView.ts b/apps/api/src/database/migrations/1788600000000-CreateLocalLocationView.ts new file mode 100644 index 0000000..117f16b --- /dev/null +++ b/apps/api/src/database/migrations/1788600000000-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 CreateLocalLocationView1788600000000 + 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..4f3e4a7 --- /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, '1788600000000-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..c3795ce --- /dev/null +++ b/apps/api/src/database/seeds/local-location.content.ts @@ -0,0 +1,204 @@ +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', + localPointsOfInterest: [ + { + key: 'hunt-area', + title: 'Jagdgebiet', + actionLabel: 'Jagd beginnen', + type: 'HUNT', + iconKey: 'hunt', + xPercent: 52, + yPercent: 44, + enabled: true, + }, + { + key: 'wounded-scout', + title: 'Verwundeter Kundschafter', + actionLabel: 'Sprechen', + type: 'NPC', + iconKey: 'speak', + xPercent: 20, + yPercent: 60, + 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."', + }, + { + 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. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.', + }, + { + key: 'search-abandoned-wagon', + title: 'Verlassener Wagen', + actionLabel: 'Durchsuchen', + type: 'SEARCH', + iconKey: 'search', + xPercent: 80, + yPercent: 68, + 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.', + }, + ], + 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 what the game actually grants today. Combat hands out silver and + // experience; there is no loot system yet, so nothing else is promised + // (spec §8, "Mögliche Belohnungen"). + localRewardPreview: [ + { key: 'silver', label: 'Silber', iconKey: 'silver' }, + { key: 'experience', label: 'Erfahrung', iconKey: 'experience' }, + ], +}; + +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 39e0c44..41104ec 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.spec.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.spec.ts @@ -13,6 +13,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/monsters/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/monsters/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', + 'wounded-scout', + 'inspect-tracks', + 'search-abandoned-wagon', + ]); + expect(pointsOfInterest.map((poi) => poi.type)).toEqual([ + 'HUNT', + 'NPC', + 'INVESTIGATE', + 'SEARCH', + ]); + + 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 d0ca3b8..0c8af6a 100644 --- a/apps/api/src/database/seeds/vertical-slice.seed.ts +++ b/apps/api/src/database/seeds/vertical-slice.seed.ts @@ -6,11 +6,17 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity'; import { LocationConnection } from '../../world/entities/location-connection.entity'; import { LocationDefinition } from '../../world/entities/location-definition.entity'; +import { + BURNED_ROAD_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( @@ -36,6 +42,7 @@ export async function seedVisibleVerticalSlice( isSafe: true, huntingEnabled: false, artworkPath: '/images/backgrounds/Suedtor.png', + ...SOUTH_GATE_LOCAL_CONTENT, }, { id: BURNED_ROAD_ID, @@ -50,17 +57,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); @@ -68,13 +72,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( [ { @@ -108,6 +111,21 @@ export async function seedVisibleVerticalSlice( silverMin: 4, silverMax: 7, artworkPath: '/images/monsters/ash-rat.png', + iconPath: '/images/monsters/icons/ash-rat-128.png', + }, + { + 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/monsters/icons/wild-road-dog-128.png', }, { id: ROAD_BANDIT_MONSTER_ID, @@ -121,17 +139,30 @@ export async function seedVisibleVerticalSlice( silverMin: 9, silverMax: 15, artworkPath: '/images/monsters/road-bandit.png', + iconPath: '/images/monsters/icons/road-bandit-128.png', + }, + { + 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/monsters/icons/charred-looter-128.png', }, ]; - 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); @@ -139,30 +170,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 2b953ee..7f85638 100644 --- a/apps/api/src/monsters/entities/monster-definition.entity.ts +++ b/apps/api/src/monsters/entities/monster-definition.entity.ts @@ -43,6 +43,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; + @CreateDateColumn({ name: 'created_at', type: 'timestamptz' }) createdAt!: Date; 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/combat/sprites/charred-looter-620.png b/apps/web/public/images/combat/sprites/charred-looter-620.png new file mode 100644 index 0000000..91f7a10 Binary files /dev/null and b/apps/web/public/images/combat/sprites/charred-looter-620.png differ diff --git a/apps/web/public/images/combat/sprites/wild-road-dog-760.png b/apps/web/public/images/combat/sprites/wild-road-dog-760.png new file mode 100644 index 0000000..dfc00e6 Binary files /dev/null and b/apps/web/public/images/combat/sprites/wild-road-dog-760.png differ diff --git a/apps/web/public/images/monsters/charred-looter.png b/apps/web/public/images/monsters/charred-looter.png new file mode 100644 index 0000000..6d31116 Binary files /dev/null and b/apps/web/public/images/monsters/charred-looter.png differ diff --git a/apps/web/public/images/combat/icons/ash-rat-128.png b/apps/web/public/images/monsters/icons/ash-rat-128.png similarity index 100% rename from apps/web/public/images/combat/icons/ash-rat-128.png rename to apps/web/public/images/monsters/icons/ash-rat-128.png diff --git a/apps/web/public/images/monsters/icons/charred-looter-128.png b/apps/web/public/images/monsters/icons/charred-looter-128.png new file mode 100644 index 0000000..e06decf Binary files /dev/null and b/apps/web/public/images/monsters/icons/charred-looter-128.png differ diff --git a/apps/web/public/images/combat/icons/road-bandit-128.png b/apps/web/public/images/monsters/icons/road-bandit-128.png similarity index 100% rename from apps/web/public/images/combat/icons/road-bandit-128.png rename to apps/web/public/images/monsters/icons/road-bandit-128.png diff --git a/apps/web/public/images/monsters/icons/wild-road-dog-128.png b/apps/web/public/images/monsters/icons/wild-road-dog-128.png new file mode 100644 index 0000000..8058b20 Binary files /dev/null and b/apps/web/public/images/monsters/icons/wild-road-dog-128.png differ 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/public/images/monsters/wild-road-dog.png b/apps/web/public/images/monsters/wild-road-dog.png new file mode 100644 index 0000000..bd57d6d Binary files /dev/null and b/apps/web/public/images/monsters/wild-road-dog.png differ diff --git a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts index b070354..c036489 100644 --- a/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts +++ b/apps/web/src/app/features/hunting/encounter-card/encounter-card.component.spec.ts @@ -61,7 +61,7 @@ describe('EncounterCardComponent', () => { const element = render(ashRatEncounter); const crest = element.querySelector('.encounter-card__crest-icon'); - expect(crest?.getAttribute('src')).toBe('/images/combat/icons/ash-rat-128.png'); + expect(crest?.getAttribute('src')).toBe('/images/monsters/icons/ash-rat-128.png'); }); it('leaves the crest empty for a monster without an icon', () => { diff --git a/apps/web/src/app/shared/monster-artwork.spec.ts b/apps/web/src/app/shared/monster-artwork.spec.ts index 8e30452..ef2fb4f 100644 --- a/apps/web/src/app/shared/monster-artwork.spec.ts +++ b/apps/web/src/app/shared/monster-artwork.spec.ts @@ -16,6 +16,12 @@ describe('monsterCutoutPath', () => { it('returns the background-free cut-out for a known monster key', () => { expect(monsterCutoutPath('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png'); expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.png'); + expect(monsterCutoutPath('wild-road-dog')).toBe( + '/images/combat/sprites/wild-road-dog-760.png', + ); + expect(monsterCutoutPath('charred-looter')).toBe( + '/images/combat/sprites/charred-looter-620.png', + ); }); it('returns undefined for a monster without a cut-out', () => { @@ -25,8 +31,11 @@ describe('monsterCutoutPath', () => { describe('monsterIconPath', () => { it('returns the medallion icon for a known monster key', () => { - expect(monsterIconPath('ash-rat')).toBe('/images/combat/icons/ash-rat-128.png'); - expect(monsterIconPath('road-bandit')).toBe('/images/combat/icons/road-bandit-128.png'); + expect(monsterIconPath('ash-rat')).toBe('/images/monsters/icons/ash-rat-128.png'); + expect(monsterIconPath('road-bandit')).toBe('/images/monsters/icons/road-bandit-128.png'); + expect(monsterIconPath('charred-looter')).toBe( + '/images/monsters/icons/charred-looter-128.png', + ); }); it('returns undefined for a monster without an icon', () => { diff --git a/apps/web/src/app/shared/monster-artwork.ts b/apps/web/src/app/shared/monster-artwork.ts index 770d824..e7bc522 100644 --- a/apps/web/src/app/shared/monster-artwork.ts +++ b/apps/web/src/app/shared/monster-artwork.ts @@ -1,6 +1,8 @@ const RUNTIME_MONSTER_ARTWORK: Readonly> = { '/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg', '/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg', + '/images/monsters/wild-road-dog.png': '/images/monsters/runtime/wild-road-dog-560.jpg', + '/images/monsters/charred-looter.png': '/images/monsters/runtime/charred-looter-560.jpg', }; export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined { @@ -12,11 +14,18 @@ export function runtimeMonsterArtworkPath(artworkPath: string): string | undefin const MONSTER_CUTOUT: Readonly> = { 'ash-rat': '/images/combat/sprites/ash-rat-760.png', 'road-bandit': '/images/combat/sprites/road-bandit-620.png', + 'wild-road-dog': '/images/combat/sprites/wild-road-dog-760.png', + 'charred-looter': '/images/combat/sprites/charred-looter-620.png', }; +// Medallions live under `monsters/`, not `combat/`: the local location view +// shows the same icons in its encounter preview, where they are served +// straight from `MonsterDefinition.iconPath`. const MONSTER_ICON: Readonly> = { - 'ash-rat': '/images/combat/icons/ash-rat-128.png', - 'road-bandit': '/images/combat/icons/road-bandit-128.png', + 'ash-rat': '/images/monsters/icons/ash-rat-128.png', + 'road-bandit': '/images/monsters/icons/road-bandit-128.png', + 'wild-road-dog': '/images/monsters/icons/wild-road-dog-128.png', + 'charred-looter': '/images/monsters/icons/charred-looter-128.png', }; // Share of the battlefield height each monster sprite occupies, so a hulking @@ -24,6 +33,8 @@ const MONSTER_ICON: Readonly> = { const COMBAT_MONSTER_SCALE: Readonly> = { 'ash-rat': 0.46, 'road-bandit': 0.82, + 'wild-road-dog': 0.58, + 'charred-looter': 0.86, }; const DEFAULT_MONSTER_SCALE = 0.6;