feat(api): refuse travel down an undiscovered route
This commit is contained in:
@@ -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 }],
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user