diff --git a/apps/api/src/world/discovery/world-discovery.service.ts b/apps/api/src/world/discovery/world-discovery.service.ts index 562c77d..dbf3a65 100644 --- a/apps/api/src/world/discovery/world-discovery.service.ts +++ b/apps/api/src/world/discovery/world-discovery.service.ts @@ -80,7 +80,24 @@ export class WorldDiscoveryService { } const known = await this.getDiscoveredLocationIds(characterId, manager); - return known.has(connection.toLocationId); + return this.isRouteOpen(known, connection); + } + + /** + * The gating rule itself, given an already-loaded discovery set. + * + * Pure and synchronous so a caller with many connections can load the set + * once and filter in memory, while `isTravelAllowed` stays the convenient + * single-connection entry point. One rule, two callers. + */ + isRouteOpen( + discoveredLocationIds: ReadonlySet, + connection: Pick, + ): boolean { + return ( + !connection.requiresDiscovery || + discoveredLocationIds.has(connection.toLocationId) + ); } private discoveries(manager?: EntityManager) { diff --git a/apps/api/src/world/world.service.spec.ts b/apps/api/src/world/world.service.spec.ts index 1481917..329537c 100644 --- a/apps/api/src/world/world.service.spec.ts +++ b/apps/api/src/world/world.service.spec.ts @@ -56,6 +56,7 @@ const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [ enabled: true, resultTitle: 'Suspicious Tracks', resultText: 'Fresh bootprints lead east.', + discoversLocationKey: 'ash-pit', }, { key: 'sealed-crypt', @@ -238,14 +239,15 @@ function buildService( } as unknown as Repository; const worldDiscovery = { discover: jest.fn(), - isTravelAllowed: ( - _characterId: string, + getDiscoveredLocationIds: jest + .fn() + .mockResolvedValue(new Set(options.discovered ?? [])), + isRouteOpen: ( + discoveredLocationIds: ReadonlySet, connection: { toLocationId: string; requiresDiscovery: boolean }, ) => - Promise.resolve( - !connection.requiresDiscovery || - (options.discovered ?? []).includes(connection.toLocationId), - ), + !connection.requiresDiscovery || + discoveredLocationIds.has(connection.toLocationId), } as unknown as WorldDiscoveryService; const service = new WorldService( @@ -307,7 +309,11 @@ describe('WorldService', () => { find: findLocationMonsters, } as unknown as Repository; const worldDiscovery = { - isTravelAllowed: () => Promise.resolve(true), + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, discover: jest.fn(), } as unknown as WorldDiscoveryService; const service = new WorldService( @@ -395,7 +401,11 @@ describe('WorldService', () => { find: findLocationMonsters, } as unknown as Repository; const worldDiscovery = { - isTravelAllowed: () => Promise.resolve(true), + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, discover: jest.fn(), } as unknown as WorldDiscoveryService; const service = new WorldService( @@ -436,7 +446,11 @@ describe('WorldService', () => { find: findLocationMonsters, } as unknown as Repository; const worldDiscovery = { - isTravelAllowed: () => Promise.resolve(true), + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, discover: jest.fn(), } as unknown as WorldDiscoveryService; const service = new WorldService( @@ -514,6 +528,8 @@ describe('WorldService', () => { ]); expect(JSON.stringify(result)).not.toContain('Fresh bootprints'); expect(JSON.stringify(result)).not.toContain('Still sealed'); + expect(JSON.stringify(result)).not.toContain('discoversLocationKey'); + expect(JSON.stringify(result)).not.toContain('ash-pit'); }); it('derives the encounter preview from the location monster pool', async () => { @@ -620,7 +636,11 @@ async function loadLocation( find: jest.fn().mockResolvedValue(pool), } as unknown as Repository, { - isTravelAllowed: () => Promise.resolve(true), + getDiscoveredLocationIds: jest.fn().mockResolvedValue(new Set()), + isRouteOpen: ( + _discoveredLocationIds: ReadonlySet, + connection: { requiresDiscovery: boolean }, + ) => !connection.requiresDiscovery, discover: jest.fn(), } as unknown as WorldDiscoveryService, ); diff --git a/apps/api/src/world/world.service.ts b/apps/api/src/world/world.service.ts index ea36956..9b94c68 100644 --- a/apps/api/src/world/world.service.ts +++ b/apps/api/src/world/world.service.ts @@ -87,15 +87,16 @@ export class WorldService { ? await this.getEncounterPool(location.id) : []; - const visibleConnections: LocationConnection[] = []; - for (const connection of connections) { - if ( + // Loaded once per request rather than per connection: `isRouteOpen` is + // synchronous, so a location with several gated exits costs one query + // here instead of one per gated connection. + const discoveredLocationIds = + await this.worldDiscovery.getDiscoveredLocationIds(characterId); + const visibleConnections = connections.filter( + (connection) => connection.enabled && - (await this.worldDiscovery.isTravelAllowed(characterId, connection)) - ) { - visibleConnections.push(connection); - } - } + this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection), + ); return { id: location.id,