From aaa7146c0c0606f727a6cdfdcbb85b6a02f369c1 Mon Sep 17 00:00:00 2001 From: Bastian Wagner Date: Sun, 23 Aug 2026 11:05:53 +0200 Subject: [PATCH] feat(api): refuse travel down an undiscovered route --- apps/api/src/travel/travel.module.ts | 2 + apps/api/src/travel/travel.service.spec.ts | 65 ++++++++++++++++++---- apps/api/src/travel/travel.service.ts | 14 +++++ 3 files changed, 69 insertions(+), 12 deletions(-) diff --git a/apps/api/src/travel/travel.module.ts b/apps/api/src/travel/travel.module.ts index eca8a2b..013ff17 100644 --- a/apps/api/src/travel/travel.module.ts +++ b/apps/api/src/travel/travel.module.ts @@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity'; import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity'; import { CLOCK, systemClock } from '../shared/clock'; +import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module'; import { Travel } from './entities/travel.entity'; import { TravelController } from './travel.controller'; import { TravelService } from './travel.service'; @@ -16,6 +17,7 @@ import { TravelService } from './travel.service'; LocationConnection, Travel, ]), + WorldDiscoveryModule, ], controllers: [TravelController], providers: [TravelService, { provide: CLOCK, useValue: systemClock }], diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts index daa28c4..a1ceb9d 100644 --- a/apps/api/src/travel/travel.service.spec.ts +++ b/apps/api/src/travel/travel.service.spec.ts @@ -7,6 +7,7 @@ import { import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity'; import { Clock } from '../shared/clock'; +import { WorldDiscoveryService } from '../world/discovery/world-discovery.service'; import { Travel } from './entities/travel.entity'; import { TravelDomainError } from './travel.errors'; import { TravelService } from './travel.service'; @@ -215,16 +216,37 @@ function activeTravel(arrivesAt: Date): Travel { } as Travel; } -function createService(state = createState()) { - const dataSource = new FakeDataSource(state); +function buildService( + options: { state?: FakeState; discovered?: string[] } = {}, +) { + const dataSource = new FakeDataSource(options.state ?? createState()); const clock: Clock = { now: () => new Date(NOW) }; - const service = new TravelService(dataSource as unknown as DataSource, clock); - return { dataSource, service }; + const worldDiscovery = { + isTravelAllowed: ( + _characterId: string, + connection: { toLocationId: string; requiresDiscovery: boolean }, + manager?: unknown, + ) => { + if (manager === undefined) { + throw new Error('isTravelAllowed must be called with a manager'); + } + return Promise.resolve( + !connection.requiresDiscovery || + (options.discovered ?? []).includes(connection.toLocationId), + ); + }, + } as unknown as WorldDiscoveryService; + const service = new TravelService( + dataSource as unknown as DataSource, + clock, + worldDiscovery, + ); + return { dataSource, service, state: dataSource.state }; } describe('TravelService', () => { it('starts travel for an enabled directed connection', async () => { - const { dataSource, service } = createService(); + const { dataSource, service } = buildService(); await expect( service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), @@ -259,7 +281,7 @@ describe('TravelService', () => { it('rejects a target without an enabled connection', async () => { const state = createState(); state.connections[0].enabled = false; - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); await expect( service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), @@ -270,7 +292,7 @@ describe('TravelService', () => { }); it('derives arrivesAt from the injected clock and connection duration', async () => { - const { dataSource, service } = createService(); + const { dataSource, service } = buildService(); const result = await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID); @@ -284,7 +306,7 @@ describe('TravelService', () => { it('rejects a second journey with a stable active-travel error', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); let error: unknown; try { @@ -308,7 +330,7 @@ describe('TravelService', () => { it('returns the current active travel without exposing persistence fields', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z'))); - const { service } = createService(state); + const { service } = buildService({ state }); await expect(service.getCurrentTravel(CHARACTER_ID)).resolves.toEqual({ status: TravelStatus.TRAVELLING, @@ -330,7 +352,7 @@ describe('TravelService', () => { it('does not complete or move the character before arrivesAt', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.001Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({ status: TravelStatus.TRAVELLING, @@ -356,7 +378,7 @@ describe('TravelService', () => { it('completes due travel and updates character location atomically', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({ status: TravelStatus.COMPLETED, @@ -382,7 +404,7 @@ describe('TravelService', () => { it('rolls back both due-travel updates if either save fails', async () => { const state = createState(); state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z'))); - const { dataSource, service } = createService(state); + const { dataSource, service } = buildService({ state }); dataSource.failSaveTarget = Character; await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow( @@ -393,4 +415,23 @@ describe('TravelService', () => { ); expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING); }); + + it('refuses a gated route the character has not discovered', async () => { + const { service, state } = buildService(); + state.connections[0].requiresDiscovery = true; + + await expect( + service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), + ).rejects.toBeInstanceOf(TravelDomainError); + expect(state.travels).toHaveLength(0); + }); + + it('allows a gated route once it has been discovered', async () => { + const { service, state } = buildService({ discovered: [BURNED_ROAD_ID] }); + state.connections[0].requiresDiscovery = true; + + await expect( + service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), + ).resolves.toMatchObject({ status: TravelStatus.TRAVELLING }); + }); }); diff --git a/apps/api/src/travel/travel.service.ts b/apps/api/src/travel/travel.service.ts index 2c99dc4..674690b 100644 --- a/apps/api/src/travel/travel.service.ts +++ b/apps/api/src/travel/travel.service.ts @@ -5,6 +5,7 @@ import { LocationConnection } from '../world/entities/location-connection.entity import { LocationDefinition } from '../world/entities/location-definition.entity'; import { CLOCK } from '../shared/clock'; import type { Clock } from '../shared/clock'; +import { WorldDiscoveryService } from '../world/discovery/world-discovery.service'; import { Travel } from './entities/travel.entity'; import { characterNotFound, @@ -45,6 +46,7 @@ export class TravelService { constructor( private readonly dataSource: DataSource, @Inject(CLOCK) private readonly clock: Clock, + private readonly worldDiscovery: WorldDiscoveryService, ) {} startTravel( @@ -79,6 +81,18 @@ export class TravelService { throw invalidTravelTarget(); } + // The map already hides an undiscovered route, but the map is not what + // decides. A gated target is refused here too, inside the same + // transaction that locks the character (AGENTS.md ยง5). + const allowed = await this.worldDiscovery.isTravelAllowed( + characterId, + connection, + manager, + ); + if (!allowed) { + throw invalidTravelTarget(); + } + const originLocation = await locations.findOneBy({ id: character.currentLocationId, });