feat(api): reveal the ash pit route from the watchpost hotspot

This commit is contained in:
Bastian Wagner
2026-08-23 11:18:20 +02:00
parent aaa7146c0c
commit 37d025f401
5 changed files with 309 additions and 11 deletions

View File

@@ -6,6 +6,7 @@ import {
} from '../database/seeds/vertical-slice.constants'; } from '../database/seeds/vertical-slice.constants';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { WorldDiscoveryService } from './discovery/world-discovery.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity'; import { LocationDefinition } from './entities/location-definition.entity';
import type { LocationPointOfInterestContent } from './local-location.types'; import type { LocationPointOfInterestContent } from './local-location.types';
@@ -92,9 +93,51 @@ function createService(
} as unknown as Repository<Character>, } as unknown as Repository<Character>,
{ find: jest.fn() } as unknown as Repository<LocationConnection>, { find: jest.fn() } as unknown as Repository<LocationConnection>,
{ find: jest.fn() } as unknown as Repository<LocationMonster>, { 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> { async function expectRejected(promise: Promise<unknown>): Promise<void> {
await expect(promise).rejects.toBeInstanceOf(WorldDomainError); await expect(promise).rejects.toBeInstanceOf(WorldDomainError);
await expect(promise).rejects.toMatchObject({ await expect(promise).rejects.toMatchObject({
@@ -112,6 +155,7 @@ describe('WorldService.runLocalInteraction', () => {
interactionKey: 'inspect-tracks', interactionKey: 'inspect-tracks',
title: 'Suspicious Tracks', title: 'Suspicious Tracks',
text: 'Between the ash and broken stones you make out several fresh bootprints.', 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', title: 'Gate Watch',
text: 'Only heard at the South Gate.', text: 'Only heard at the South Gate.',
img: '/images/npcs/graufurt-gate-watch.png', img: '/images/npcs/graufurt-gate-watch.png',
discoveredLocation: null,
}); });
}); });
@@ -171,4 +216,87 @@ describe('WorldService.runLocalInteraction', () => {
service.runLocalInteraction(CHARACTER_ID, 'hunt-area'), 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();
});
}); });

View File

@@ -55,6 +55,12 @@ export interface LocationPointOfInterestContent {
* scout on the Burned Road stays a piece of scenery, Borin does not. * scout on the Burned Road stays a piece of scenery, Borin does not.
*/ */
npcKey?: string; 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; title: string;
text: string; text: string;
img?: 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. */ /** Strips server-only result text before a POI is sent to the client. */

View File

@@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { MonsterDefinition } from '../monsters/entities/monster-definition.entity'; import { MonsterDefinition } from '../monsters/entities/monster-definition.entity';
import { TravelModule } from '../travel/travel.module'; import { TravelModule } from '../travel/travel.module';
import { WorldDiscoveryModule } from './discovery/world-discovery.module';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { WorldController } from './world.controller'; import { WorldController } from './world.controller';
import { WorldService } from './world.service'; import { WorldService } from './world.service';
@@ -17,6 +18,7 @@ import { WorldService } from './world.service';
MonsterDefinition, MonsterDefinition,
]), ]),
TravelModule, TravelModule,
WorldDiscoveryModule,
], ],
controllers: [WorldController], controllers: [WorldController],
providers: [WorldService], providers: [WorldService],

View File

@@ -7,6 +7,7 @@ import {
} from '../database/seeds/vertical-slice.constants'; } from '../database/seeds/vertical-slice.constants';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { WorldDiscoveryService } from './discovery/world-discovery.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity'; import { LocationDefinition } from './entities/location-definition.entity';
import type { import type {
@@ -16,6 +17,7 @@ import type {
import { WorldService } from './world.service'; import { WorldService } from './world.service';
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001'; const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
const ASH_PIT_ID = '20000000-0000-4000-8000-000000000004';
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [ 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', () => { describe('WorldService', () => {
it('returns the authoritative current location and only enabled public connections', async () => { it('returns the authoritative current location and only enabled public connections', async () => {
const callOrder: string[] = []; const callOrder: string[] = [];
@@ -230,11 +306,16 @@ describe('WorldService', () => {
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
} as unknown as Repository<LocationMonster>; } as unknown as Repository<LocationMonster>;
const worldDiscovery = {
isTravelAllowed: () => Promise.resolve(true),
discover: jest.fn(),
} as unknown as WorldDiscoveryService;
const service = new WorldService( const service = new WorldService(
travelService, travelService,
characters, characters,
connections, connections,
locationMonsters, locationMonsters,
worldDiscovery,
); );
const result = await service.getCurrentLocation(CHARACTER_ID); const result = await service.getCurrentLocation(CHARACTER_ID);
@@ -313,11 +394,16 @@ describe('WorldService', () => {
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
} as unknown as Repository<LocationMonster>; } as unknown as Repository<LocationMonster>;
const worldDiscovery = {
isTravelAllowed: () => Promise.resolve(true),
discover: jest.fn(),
} as unknown as WorldDiscoveryService;
const service = new WorldService( const service = new WorldService(
travelService, travelService,
characters, characters,
connections, connections,
locationMonsters, locationMonsters,
worldDiscovery,
); );
const result = await service.getCurrentLocation(CHARACTER_ID); const result = await service.getCurrentLocation(CHARACTER_ID);
@@ -349,11 +435,16 @@ describe('WorldService', () => {
const locationMonsters = { const locationMonsters = {
find: findLocationMonsters, find: findLocationMonsters,
} as unknown as Repository<LocationMonster>; } as unknown as Repository<LocationMonster>;
const worldDiscovery = {
isTravelAllowed: () => Promise.resolve(true),
discover: jest.fn(),
} as unknown as WorldDiscoveryService;
const service = new WorldService( const service = new WorldService(
travelService, travelService,
characters, characters,
connections, connections,
locationMonsters, locationMonsters,
worldDiscovery,
); );
await expect( await expect(
@@ -458,6 +549,48 @@ describe('WorldService', () => {
expect(result.dangerRating).toBeNull(); expect(result.dangerRating).toBeNull();
expect(result.encounterPreview).toEqual([]); 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() { async function loadBurnedRoad() {
@@ -486,6 +619,10 @@ async function loadLocation(
{ {
find: jest.fn().mockResolvedValue(pool), find: jest.fn().mockResolvedValue(pool),
} as unknown as Repository<LocationMonster>, } as unknown as Repository<LocationMonster>,
{
isTravelAllowed: () => Promise.resolve(true),
discover: jest.fn(),
} as unknown as WorldDiscoveryService,
); );
return service.getCurrentLocation(CHARACTER_ID); return service.getCurrentLocation(CHARACTER_ID);

View File

@@ -5,6 +5,7 @@ import { Character } from '../characters/entities/character.entity';
import { calculateDangerRating, DangerRating } from '../hunting/danger-rating'; import { calculateDangerRating, DangerRating } from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity'; import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service'; import { TravelService } from '../travel/travel.service';
import { WorldDiscoveryService } from './discovery/world-discovery.service';
import { LocationConnection } from './entities/location-connection.entity'; import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity'; import { LocationDefinition } from './entities/location-definition.entity';
import { import {
@@ -68,6 +69,7 @@ export class WorldService {
private readonly connections: Repository<LocationConnection>, private readonly connections: Repository<LocationConnection>,
@InjectRepository(LocationMonster) @InjectRepository(LocationMonster)
private readonly locationMonsters: Repository<LocationMonster>, private readonly locationMonsters: Repository<LocationMonster>,
private readonly worldDiscovery: WorldDiscoveryService,
) {} ) {}
async getCurrentLocation( async getCurrentLocation(
@@ -85,6 +87,16 @@ export class WorldService {
? await this.getEncounterPool(location.id) ? 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 { return {
id: location.id, id: location.id,
key: location.key, key: location.key,
@@ -114,9 +126,7 @@ export class WorldService {
iconPath: entry.monster.iconPath, iconPath: entry.monster.iconPath,
})), })),
rewardPreview: location.localRewardPreview, rewardPreview: location.localRewardPreview,
connections: connections connections: visibleConnections.map((connection) => ({
.filter((connection) => connection.enabled)
.map((connection) => ({
targetLocation: { targetLocation: {
id: connection.toLocation.id, id: connection.toLocation.id,
key: connection.toLocation.key, key: connection.toLocation.key,
@@ -152,11 +162,21 @@ export class WorldService {
throw locationInteractionUnavailable(); 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 { return {
interactionKey: poi.key, interactionKey: poi.key,
title: poi.resultTitle ?? poi.title, title: poi.resultTitle ?? poi.title,
text: poi.resultText, text: poi.resultText,
...(poi.resultImg === undefined ? {} : { img: poi.resultImg }), ...(poi.resultImg === undefined ? {} : { img: poi.resultImg }),
discoveredLocation,
}; };
} }