feat(api): reveal the ash pit route from the watchpost hotspot
This commit is contained in:
@@ -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<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({
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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<Character>;
|
||||
const connections = {
|
||||
find: jest.fn().mockResolvedValue(options.connections ?? []),
|
||||
} as unknown as Repository<LocationConnection>;
|
||||
const locationMonsters = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as Repository<LocationMonster>;
|
||||
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<LocationMonster>;
|
||||
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<LocationMonster>;
|
||||
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<LocationMonster>;
|
||||
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<LocationMonster>,
|
||||
{
|
||||
isTravelAllowed: () => Promise.resolve(true),
|
||||
discover: jest.fn(),
|
||||
} as unknown as WorldDiscoveryService,
|
||||
);
|
||||
|
||||
return service.getCurrentLocation(CHARACTER_ID);
|
||||
|
||||
@@ -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<LocationConnection>,
|
||||
@InjectRepository(LocationMonster)
|
||||
private readonly locationMonsters: Repository<LocationMonster>,
|
||||
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,9 +126,7 @@ export class WorldService {
|
||||
iconPath: entry.monster.iconPath,
|
||||
})),
|
||||
rewardPreview: location.localRewardPreview,
|
||||
connections: connections
|
||||
.filter((connection) => connection.enabled)
|
||||
.map((connection) => ({
|
||||
connections: visibleConnections.map((connection) => ({
|
||||
targetLocation: {
|
||||
id: connection.toLocation.id,
|
||||
key: connection.toLocation.key,
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user