303 lines
8.9 KiB
TypeScript
303 lines
8.9 KiB
TypeScript
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<Character>,
|
|
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
|
|
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
|
|
{
|
|
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<Character>,
|
|
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
|
|
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
|
|
worldDiscovery,
|
|
);
|
|
|
|
return { service, discover };
|
|
}
|
|
|
|
async function expectRejected(promise: Promise<unknown>): Promise<void> {
|
|
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();
|
|
});
|
|
});
|