feat(api): refuse travel down an undiscovered route

This commit is contained in:
Bastian Wagner
2026-08-23 11:05:53 +02:00
parent 3f336bfc34
commit aaa7146c0c
3 changed files with 69 additions and 12 deletions

View File

@@ -4,6 +4,7 @@ import { Character } from '../characters/entities/character.entity';
import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity';
import { CLOCK, systemClock } from '../shared/clock'; import { CLOCK, systemClock } from '../shared/clock';
import { WorldDiscoveryModule } from '../world/discovery/world-discovery.module';
import { Travel } from './entities/travel.entity'; import { Travel } from './entities/travel.entity';
import { TravelController } from './travel.controller'; import { TravelController } from './travel.controller';
import { TravelService } from './travel.service'; import { TravelService } from './travel.service';
@@ -16,6 +17,7 @@ import { TravelService } from './travel.service';
LocationConnection, LocationConnection,
Travel, Travel,
]), ]),
WorldDiscoveryModule,
], ],
controllers: [TravelController], controllers: [TravelController],
providers: [TravelService, { provide: CLOCK, useValue: systemClock }], providers: [TravelService, { provide: CLOCK, useValue: systemClock }],

View File

@@ -7,6 +7,7 @@ import {
import { LocationConnection } from '../world/entities/location-connection.entity'; import { LocationConnection } from '../world/entities/location-connection.entity';
import { LocationDefinition } from '../world/entities/location-definition.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity';
import { Clock } from '../shared/clock'; import { Clock } from '../shared/clock';
import { WorldDiscoveryService } from '../world/discovery/world-discovery.service';
import { Travel } from './entities/travel.entity'; import { Travel } from './entities/travel.entity';
import { TravelDomainError } from './travel.errors'; import { TravelDomainError } from './travel.errors';
import { TravelService } from './travel.service'; import { TravelService } from './travel.service';
@@ -215,16 +216,37 @@ function activeTravel(arrivesAt: Date): Travel {
} as Travel; } as Travel;
} }
function createService(state = createState()) { function buildService(
const dataSource = new FakeDataSource(state); options: { state?: FakeState; discovered?: string[] } = {},
) {
const dataSource = new FakeDataSource(options.state ?? createState());
const clock: Clock = { now: () => new Date(NOW) }; const clock: Clock = { now: () => new Date(NOW) };
const service = new TravelService(dataSource as unknown as DataSource, clock); const worldDiscovery = {
return { dataSource, service }; 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', () => { describe('TravelService', () => {
it('starts travel for an enabled directed connection', async () => { it('starts travel for an enabled directed connection', async () => {
const { dataSource, service } = createService(); const { dataSource, service } = buildService();
await expect( await expect(
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
@@ -259,7 +281,7 @@ describe('TravelService', () => {
it('rejects a target without an enabled connection', async () => { it('rejects a target without an enabled connection', async () => {
const state = createState(); const state = createState();
state.connections[0].enabled = false; state.connections[0].enabled = false;
const { dataSource, service } = createService(state); const { dataSource, service } = buildService({ state });
await expect( await expect(
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID), service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
@@ -270,7 +292,7 @@ describe('TravelService', () => {
}); });
it('derives arrivesAt from the injected clock and connection duration', async () => { 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); 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 () => { it('rejects a second journey with a stable active-travel error', async () => {
const state = createState(); const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z'))); 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; let error: unknown;
try { try {
@@ -308,7 +330,7 @@ describe('TravelService', () => {
it('returns the current active travel without exposing persistence fields', async () => { it('returns the current active travel without exposing persistence fields', async () => {
const state = createState(); const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:10.000Z'))); 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({ await expect(service.getCurrentTravel(CHARACTER_ID)).resolves.toEqual({
status: TravelStatus.TRAVELLING, status: TravelStatus.TRAVELLING,
@@ -330,7 +352,7 @@ describe('TravelService', () => {
it('does not complete or move the character before arrivesAt', async () => { it('does not complete or move the character before arrivesAt', async () => {
const state = createState(); const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.001Z'))); 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({ await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
status: TravelStatus.TRAVELLING, status: TravelStatus.TRAVELLING,
@@ -356,7 +378,7 @@ describe('TravelService', () => {
it('completes due travel and updates character location atomically', async () => { it('completes due travel and updates character location atomically', async () => {
const state = createState(); const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z'))); 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({ await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
status: TravelStatus.COMPLETED, status: TravelStatus.COMPLETED,
@@ -382,7 +404,7 @@ describe('TravelService', () => {
it('rolls back both due-travel updates if either save fails', async () => { it('rolls back both due-travel updates if either save fails', async () => {
const state = createState(); const state = createState();
state.travels.push(activeTravel(new Date('2026-08-18T10:00:00.000Z'))); 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; dataSource.failSaveTarget = Character;
await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow( await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow(
@@ -393,4 +415,23 @@ describe('TravelService', () => {
); );
expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING); 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 });
});
}); });

View File

@@ -5,6 +5,7 @@ import { LocationConnection } from '../world/entities/location-connection.entity
import { LocationDefinition } from '../world/entities/location-definition.entity'; import { LocationDefinition } from '../world/entities/location-definition.entity';
import { CLOCK } from '../shared/clock'; import { CLOCK } from '../shared/clock';
import type { 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 { Travel } from './entities/travel.entity';
import { import {
characterNotFound, characterNotFound,
@@ -45,6 +46,7 @@ export class TravelService {
constructor( constructor(
private readonly dataSource: DataSource, private readonly dataSource: DataSource,
@Inject(CLOCK) private readonly clock: Clock, @Inject(CLOCK) private readonly clock: Clock,
private readonly worldDiscovery: WorldDiscoveryService,
) {} ) {}
startTravel( startTravel(
@@ -79,6 +81,18 @@ export class TravelService {
throw invalidTravelTarget(); 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({ const originLocation = await locations.findOneBy({
id: character.currentLocationId, id: character.currentLocationId,
}); });