From 37d025f4010030bac7a4815fcc3162662a5c0e5c Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 23 Aug 2026 11:18:20 +0200 Subject: [PATCH] feat(api): reveal the ash pit route from the watchpost hotspot --- .../world/local-location-interaction.spec.ts | 128 ++++++++++++++++ apps/api/src/world/local-location.types.ts | 11 ++ apps/api/src/world/world.module.ts | 2 + apps/api/src/world/world.service.spec.ts | 137 ++++++++++++++++++ apps/api/src/world/world.service.ts | 42 ++++-- 5 files changed, 309 insertions(+), 11 deletions(-) diff --git a/apps/api/src/world/local-location-interaction.spec.ts b/apps/api/src/world/local-location-interaction.spec.ts index 0dca51c..b72c8a9 100644 --- a/apps/api/src/world/local-location-interaction.spec.ts +++ b/apps/api/src/world/local-location-interaction.spec.ts @@ -6,6 +6,7 @@ import { } from '../database/seeds/vertical-slice.constants'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; +import { WorldDiscoveryService } from './discovery/world-discovery.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; import type { LocationPointOfInterestContent } from './local-location.types'; @@ -92,9 +93,51 @@ function createService( } as unknown as Repository, { find: jest.fn() } as unknown as Repository, { find: jest.fn() } as unknown as Repository, + { + isTravelAllowed: () => Promise.resolve(true), + discover: jest.fn(), + } as unknown as WorldDiscoveryService, ); } +/** + * Builds a `WorldService` sitting at the Burned Road with the given points of + * interest, and a stub `WorldDiscoveryService` whose `discover` mirrors the + * real one: it returns the ash pit the first time and `null` once + * `alreadyDiscovered` says the character already knows it. + */ +function buildService(options: { + pointsOfInterest: LocationPointOfInterestContent[]; + alreadyDiscovered?: boolean; +}) { + const currentLocation = location(BURNED_ROAD_ID, options.pointsOfInterest); + const discover = jest.fn().mockResolvedValue( + options.alreadyDiscovered ? null : { key: 'ash-pit', name: 'Ash Pit' }, + ); + const worldDiscovery = { + discover, + isTravelAllowed: () => Promise.resolve(true), + } as unknown as WorldDiscoveryService; + + const service = new WorldService( + { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } 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, + worldDiscovery, + ); + + return { service, discover }; +} + async function expectRejected(promise: Promise): Promise { await expect(promise).rejects.toBeInstanceOf(WorldDomainError); await expect(promise).rejects.toMatchObject({ @@ -112,6 +155,7 @@ describe('WorldService.runLocalInteraction', () => { interactionKey: 'inspect-tracks', title: 'Suspicious Tracks', text: 'Between the ash and broken stones you make out several fresh bootprints.', + discoveredLocation: null, }); }); @@ -125,6 +169,7 @@ describe('WorldService.runLocalInteraction', () => { title: 'Gate Watch', text: 'Only heard at the South Gate.', img: '/images/npcs/graufurt-gate-watch.png', + discoveredLocation: null, }); }); @@ -171,4 +216,87 @@ describe('WorldService.runLocalInteraction', () => { service.runLocalInteraction(CHARACTER_ID, 'hunt-area'), ); }); + + it('discovers the route the hotspot points at', async () => { + const { service, discover } = buildService({ + pointsOfInterest: [ + { + key: 'inspect-watchpost', + title: 'The Watchpost', + actionLabel: 'Inspect', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 50, + yPercent: 50, + enabled: true, + resultTitle: 'The Watchpost', + resultText: 'Fresh tracks lead east.', + discoversLocationKey: 'ash-pit', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'inspect-watchpost', + ); + + expect(discover).toHaveBeenCalledWith(CHARACTER_ID, 'ash-pit'); + expect(result.discoveredLocation).toEqual({ + key: 'ash-pit', + name: 'Ash Pit', + }); + }); + + it('reports no discovery the second time the hotspot is used', async () => { + const { service } = buildService({ + alreadyDiscovered: true, + pointsOfInterest: [ + { + key: 'inspect-watchpost', + title: 'The Watchpost', + type: 'INVESTIGATE', + iconKey: 'investigate', + xPercent: 50, + yPercent: 50, + enabled: true, + resultText: 'Fresh tracks lead east.', + discoversLocationKey: 'ash-pit', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'inspect-watchpost', + ); + + expect(result.discoveredLocation).toBeNull(); + expect(result.text).toBe('Fresh tracks lead east.'); + }); + + it('reports no discovery for a hotspot that reveals nothing', async () => { + const { service, discover } = buildService({ + pointsOfInterest: [ + { + key: 'search-quarters', + title: 'Guard Quarters', + type: 'SEARCH', + iconKey: 'search', + xPercent: 20, + yPercent: 60, + enabled: true, + resultText: 'Nothing but ash.', + }, + ], + }); + + const result = await service.runLocalInteraction( + CHARACTER_ID, + 'search-quarters', + ); + + expect(discover).not.toHaveBeenCalled(); + expect(result.discoveredLocation).toBeNull(); + }); }); diff --git a/apps/api/src/world/local-location.types.ts b/apps/api/src/world/local-location.types.ts index f12e817..400ff92 100644 --- a/apps/api/src/world/local-location.types.ts +++ b/apps/api/src/world/local-location.types.ts @@ -55,6 +55,12 @@ export interface LocationPointOfInterestContent { * scout on the Burned Road stays a piece of scenery, Borin does not. */ npcKey?: string; + /** + * Names a location this hotspot reveals (Playable Slice 0.10 ยง9). Setting it + * turns a read-only reveal into a piece of world progress, which is why the + * interaction endpoint writes as well as reads. + */ + discoversLocationKey?: string; } /** @@ -120,6 +126,11 @@ export interface LocationInteractionResultDto { title: string; text: string; img?: string; + /** + * Set only on the interaction that reveals a route for the first time, so + * the UI can say so once instead of on every repeat. + */ + discoveredLocation: { key: string; name: string } | null; } /** Strips server-only result text before a POI is sent to the client. */ diff --git a/apps/api/src/world/world.module.ts b/apps/api/src/world/world.module.ts index f89e016..008b1a9 100644 --- a/apps/api/src/world/world.module.ts +++ b/apps/api/src/world/world.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { TravelModule } from '../travel/travel.module'; +import { WorldDiscoveryModule } from './discovery/world-discovery.module'; import { LocationConnection } from './entities/location-connection.entity'; import { WorldController } from './world.controller'; import { WorldService } from './world.service'; @@ -17,6 +18,7 @@ import { WorldService } from './world.service'; MonsterDefinition, ]), TravelModule, + WorldDiscoveryModule, ], controllers: [WorldController], providers: [WorldService], diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index bd3c32e..1481917 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -7,6 +7,7 @@ import { } from '../database/seeds/vertical-slice.constants'; import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { TravelService } from '../travel/travel.service'; +import { WorldDiscoveryService } from './discovery/world-discovery.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; import type { @@ -16,6 +17,7 @@ import type { import { WorldService } from './world.service'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; +const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004'; const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ { @@ -183,6 +185,80 @@ function burnedRoad(): LocationDefinition { }; } +const ashPitLocation: LocationDefinition = { + id: ASH_PIT_ID, + key: 'ash-pit', + name: 'Ash Pit', + description: 'A smoldering pit at the edge of the Ashen Fields.', + regionKey: 'ashen-fields', + minRecommendedLevel: 2, + maxRecommendedLevel: 3, + dangerLevel: 2, + isSafe: false, + huntingEnabled: true, + artworkPath: '/assets/locations/ash-pit.webp', + regionName: 'Ashen Fields', + regionTierLabel: 'Tier 1', + locationType: 'HUNTING_GROUND', + localDescription: 'The pit still smolders, day and night.', + localArtworkPath: '/images/backgrounds/ash-pit.png', + localPointsOfInterest: [], + localPrimaryActions: [], + localRewardPreview: [], + createdAt: new Date('2026-08-18T09:00:00.000Z'), + updatedAt: new Date('2026-08-18T09:00:00.000Z'), + characters: [], + outgoingConnections: [], + incomingConnections: [], +}; + +/** + * Builds a `WorldService` sitting at the Burned Road, with a stub + * `WorldDiscoveryService` that mirrors the real gate: a connection with + * `requiresDiscovery` is only allowed once its target is in `discovered`. + */ +function buildService( + options: { + connections?: LocationConnection[]; + discovered?: string[]; + } = {}, +) { + const location = burnedRoad(); + const travelService = { + completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }), + } as unknown as TravelService; + const characters = { + findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)), + } as unknown as Repository; + const connections = { + find: jest.fn().mockResolvedValue(options.connections ?? []), + } as unknown as Repository; + const locationMonsters = { + find: jest.fn().mockResolvedValue([]), + } as unknown as Repository; + const worldDiscovery = { + discover: jest.fn(), + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + ) => + Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ), + } as unknown as WorldDiscoveryService; + + const service = new WorldService( + travelService, + characters, + connections, + locationMonsters, + worldDiscovery, + ); + + return { service }; +} + describe('WorldService', () => { it('returns the authoritative current location and only enabled public connections', async () => { const callOrder: string[] = []; @@ -230,11 +306,16 @@ describe('WorldService', () => { const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; + const worldDiscovery = { + isTravelAllowed: () => Promise.resolve(true), + discover: jest.fn(), + } as unknown as WorldDiscoveryService; const service = new WorldService( travelService, characters, connections, locationMonsters, + worldDiscovery, ); const result = await service.getCurrentLocation(CHARACTER_ID); @@ -313,11 +394,16 @@ describe('WorldService', () => { const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; + const worldDiscovery = { + isTravelAllowed: () => Promise.resolve(true), + discover: jest.fn(), + } as unknown as WorldDiscoveryService; const service = new WorldService( travelService, characters, connections, locationMonsters, + worldDiscovery, ); const result = await service.getCurrentLocation(CHARACTER_ID); @@ -349,11 +435,16 @@ describe('WorldService', () => { const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; + const worldDiscovery = { + isTravelAllowed: () => Promise.resolve(true), + discover: jest.fn(), + } as unknown as WorldDiscoveryService; const service = new WorldService( travelService, characters, connections, locationMonsters, + worldDiscovery, ); await expect( @@ -458,6 +549,48 @@ describe('WorldService', () => { expect(result.dangerRating).toBeNull(); expect(result.encounterPreview).toEqual([]); }); + + it('hides a gated connection until the character has discovered it', async () => { + const { service } = buildService({ + connections: [ + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: ASH_PIT_ID, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + toLocation: ashPitLocation, + } as unknown as LocationConnection, + ], + }); + + const location = await service.getCurrentLocation(CHARACTER_ID); + + expect(location.connections).toHaveLength(0); + }); + + it('shows a gated connection once it has been discovered', async () => { + const { service } = buildService({ + discovered: [ASH_PIT_ID], + connections: [ + { + fromLocationId: BURNED_ROAD_ID, + toLocationId: ASH_PIT_ID, + travelDurationSeconds: 20, + ambushChance: '0.1500', + enabled: true, + requiresDiscovery: true, + toLocation: ashPitLocation, + } as unknown as LocationConnection, + ], + }); + + const location = await service.getCurrentLocation(CHARACTER_ID); + + expect(location.connections).toHaveLength(1); + expect(location.connections[0].targetLocation.key).toBe('ash-pit'); + }); }); async function loadBurnedRoad() { @@ -486,6 +619,10 @@ async function loadLocation( { find: jest.fn().mockResolvedValue(pool), } as unknown as Repository, + { + isTravelAllowed: () => Promise.resolve(true), + discover: jest.fn(), + } as unknown as WorldDiscoveryService, ); return service.getCurrentLocation(CHARACTER_ID); diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index 17a2d14..ea36956 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -5,6 +5,7 @@ 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 { WorldDiscoveryService } from './discovery/world-discovery.service'; import { LocationConnection } from './entities/location-connection.entity'; import { LocationDefinition } from './entities/location-definition.entity'; import { @@ -68,6 +69,7 @@ export class WorldService { private readonly connections: Repository, @InjectRepository(LocationMonster) private readonly locationMonsters: Repository, + private readonly worldDiscovery: WorldDiscoveryService, ) {} async getCurrentLocation( @@ -85,6 +87,16 @@ export class WorldService { ? await this.getEncounterPool(location.id) : []; + const visibleConnections: LocationConnection[] = []; + for (const connection of connections) { + if ( + connection.enabled && + (await this.worldDiscovery.isTravelAllowed(characterId, connection)) + ) { + visibleConnections.push(connection); + } + } + return { id: location.id, key: location.key, @@ -114,17 +126,15 @@ export class WorldService { iconPath: entry.monster.iconPath, })), rewardPreview: location.localRewardPreview, - connections: connections - .filter((connection) => connection.enabled) - .map((connection) => ({ - targetLocation: { - id: connection.toLocation.id, - key: connection.toLocation.key, - name: connection.toLocation.name, - }, - travelDurationSeconds: connection.travelDurationSeconds, - danger: this.toDangerRating(connection.ambushChance), - })), + connections: visibleConnections.map((connection) => ({ + targetLocation: { + id: connection.toLocation.id, + key: connection.toLocation.key, + name: connection.toLocation.name, + }, + travelDurationSeconds: connection.travelDurationSeconds, + danger: this.toDangerRating(connection.ambushChance), + })), possibleMonsters: pool.map((entry) => entry.monster.name), }; } @@ -152,11 +162,21 @@ export class WorldService { throw locationInteractionUnavailable(); } + // A hotspot that reveals a route writes before it speaks. Idempotent by + // the unique pair, so a second click simply reports nothing new. + const discoveredLocation = poi.discoversLocationKey + ? await this.worldDiscovery.discover( + characterId, + poi.discoversLocationKey, + ) + : null; + return { interactionKey: poi.key, title: poi.resultTitle ?? poi.title, text: poi.resultText, ...(poi.resultImg === undefined ? {} : { img: poi.resultImg }), + discoveredLocation, }; }