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 { 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'; 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: 'Hunting Ground', actionLabel: 'Begin Hunt', type: 'HUNT', iconKey: 'hunt', xPercent: 52, yPercent: 44, enabled: true, }, { key: 'inspect-tracks', title: 'Suspicious Tracks', actionLabel: 'Investigate', type: 'INVESTIGATE', iconKey: 'investigate', xPercent: 32, yPercent: 78, enabled: true, resultTitle: 'Suspicious Tracks', resultText: 'Between the ash and broken stones you make out several fresh bootprints.', }, { key: 'sealed-crypt', title: 'Sealed Crypt', type: 'DUNGEON', iconKey: 'search', xPercent: 90, yPercent: 20, enabled: false, resultTitle: 'Sealed Crypt', resultText: 'Still sealed.', }, ]; const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ { key: 'gate-watch', title: 'Gate Watch', actionLabel: 'Talk', type: 'NPC', iconKey: 'speak', xPercent: 45, yPercent: 52, enabled: true, resultTitle: 'Gate Watch', resultText: 'Only heard at the South Gate.', resultImg: '/images/npcs/graufurt-gate-watch.png', }, ]; 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, { 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({ 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: 'Suspicious Tracks', text: 'Between the ash and broken stones you make out several fresh bootprints.', discoveredLocation: null, }); }); it('includes the authored image when the interaction carries one', async () => { const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS)); await expect( service.runLocalInteraction(CHARACTER_ID, 'gate-watch'), ).resolves.toEqual({ interactionKey: 'gate-watch', title: 'Gate Watch', text: 'Only heard at the South Gate.', img: '/images/npcs/graufurt-gate-watch.png', discoveredLocation: null, }); }); 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'), ); }); 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(); }); });