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 { 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 }],
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user