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 { 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 }],

View File

@@ -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 });
});
});

View File

@@ -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,
});