401 lines
12 KiB
TypeScript
401 lines
12 KiB
TypeScript
import { DataSource, EntityManager, EntityTarget } from 'typeorm';
|
|
import { Character } from '../characters/entities/character.entity';
|
|
import {
|
|
BURNED_ROAD_ID,
|
|
SOUTH_GATE_ID,
|
|
} from '../database/seeds/vertical-slice.constants';
|
|
import { LocationConnection } from '../world/entities/location-connection.entity';
|
|
import { LocationDefinition } from '../world/entities/location-definition.entity';
|
|
import { Clock } from './clock';
|
|
import { Travel } from './entities/travel.entity';
|
|
import { TravelDomainError } from './travel.errors';
|
|
import { TravelService } from './travel.service';
|
|
import { TravelStatus } from './travel-status.enum';
|
|
|
|
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
|
const CONNECTION_ID = '30000000-0000-4000-8000-000000000001';
|
|
const TRAVEL_ID = '40000000-0000-4000-8000-000000000001';
|
|
const NOW = new Date('2026-08-18T10:00:00.000Z');
|
|
|
|
interface FakeState {
|
|
characters: Character[];
|
|
locations: LocationDefinition[];
|
|
connections: LocationConnection[];
|
|
travels: Travel[];
|
|
}
|
|
|
|
class FakeRepository<T extends { id: string }> {
|
|
constructor(
|
|
private readonly state: FakeState,
|
|
private readonly target: EntityTarget<T>,
|
|
private readonly inTransaction: boolean,
|
|
private readonly dataSource: FakeDataSource,
|
|
) {}
|
|
|
|
findOne(options: {
|
|
where: Partial<T>;
|
|
lock?: { mode: string };
|
|
}): Promise<T | null> {
|
|
if (options.lock) {
|
|
if (!this.inTransaction) {
|
|
throw new Error('Pessimistic locks require a transaction');
|
|
}
|
|
this.dataSource.locks.push({
|
|
target: this.target,
|
|
mode: options.lock.mode,
|
|
});
|
|
}
|
|
|
|
return Promise.resolve(
|
|
this.rows().find((row) => this.matches(row, options.where)) ?? null,
|
|
);
|
|
}
|
|
|
|
findOneBy(where: Partial<T>): Promise<T | null> {
|
|
return Promise.resolve(
|
|
this.rows().find((row) => this.matches(row, where)) ?? null,
|
|
);
|
|
}
|
|
|
|
create(values: Partial<T>): T {
|
|
return { ...values } as T;
|
|
}
|
|
|
|
save(entity: T): Promise<T> {
|
|
if (this.dataSource.failSaveTarget === this.target) {
|
|
throw new Error(`Failed to save ${this.targetName()}`);
|
|
}
|
|
|
|
if (!entity.id) {
|
|
entity.id = TRAVEL_ID;
|
|
}
|
|
|
|
const rows = this.rows();
|
|
const index = rows.findIndex((row) => row.id === entity.id);
|
|
if (index === -1) {
|
|
rows.push(entity);
|
|
} else {
|
|
rows[index] = entity;
|
|
}
|
|
return Promise.resolve(entity);
|
|
}
|
|
|
|
private rows(): T[] {
|
|
if (this.target === Character) {
|
|
return this.state.characters as T[];
|
|
}
|
|
if (this.target === LocationDefinition) {
|
|
return this.state.locations as T[];
|
|
}
|
|
if (this.target === LocationConnection) {
|
|
return this.state.connections as T[];
|
|
}
|
|
if (this.target === Travel) {
|
|
return this.state.travels as T[];
|
|
}
|
|
throw new Error(`Unsupported repository ${this.targetName()}`);
|
|
}
|
|
|
|
private matches(row: T, where: Partial<T>): boolean {
|
|
return Object.entries(where).every(
|
|
([key, value]) => row[key as keyof T] === value,
|
|
);
|
|
}
|
|
|
|
private targetName(): string {
|
|
return typeof this.target === 'function'
|
|
? this.target.name
|
|
: 'EntitySchema';
|
|
}
|
|
}
|
|
|
|
class FakeEntityManager {
|
|
constructor(
|
|
private readonly state: FakeState,
|
|
private readonly dataSource: FakeDataSource,
|
|
) {}
|
|
|
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
|
return new FakeRepository(this.state, target, true, this.dataSource);
|
|
}
|
|
}
|
|
|
|
class FakeDataSource {
|
|
readonly locks: Array<{ target: EntityTarget<unknown>; mode: string }> = [];
|
|
failSaveTarget?: EntityTarget<unknown>;
|
|
|
|
constructor(public state: FakeState) {}
|
|
|
|
getRepository<T extends { id: string }>(target: EntityTarget<T>) {
|
|
return new FakeRepository(this.state, target, false, this);
|
|
}
|
|
|
|
async transaction<T>(
|
|
work: (manager: EntityManager) => Promise<T>,
|
|
): Promise<T> {
|
|
const draft = structuredClone(this.state);
|
|
const result = await work(
|
|
new FakeEntityManager(draft, this) as unknown as EntityManager,
|
|
);
|
|
this.state = draft;
|
|
return result;
|
|
}
|
|
}
|
|
|
|
function location(id: string, key: string, name: string): LocationDefinition {
|
|
return {
|
|
id,
|
|
key,
|
|
name,
|
|
description: `${name} description`,
|
|
regionKey: 'ashen-fields',
|
|
minRecommendedLevel: 1,
|
|
maxRecommendedLevel: 2,
|
|
dangerLevel: 1,
|
|
isSafe: id === SOUTH_GATE_ID,
|
|
huntingEnabled: id === BURNED_ROAD_ID,
|
|
artworkPath: `/assets/locations/${key}.webp`,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
characters: [],
|
|
outgoingConnections: [],
|
|
incomingConnections: [],
|
|
};
|
|
}
|
|
|
|
function createState(): FakeState {
|
|
const southGate = location(
|
|
SOUTH_GATE_ID,
|
|
'south-gate',
|
|
'S\u00fcdtor von Graufurt',
|
|
);
|
|
const burnedRoad = location(
|
|
BURNED_ROAD_ID,
|
|
'burned-road',
|
|
'Verbrannte Stra\u00dfe',
|
|
);
|
|
const character: Character = {
|
|
id: CHARACTER_ID,
|
|
name: 'Aric Duskwalker',
|
|
level: 1,
|
|
experience: 0,
|
|
baseHp: 100,
|
|
baseAttack: 6,
|
|
currentHp: 100,
|
|
currentLocationId: SOUTH_GATE_ID,
|
|
currentLocation: southGate,
|
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
|
};
|
|
const connection = {
|
|
id: CONNECTION_ID,
|
|
fromLocationId: SOUTH_GATE_ID,
|
|
toLocationId: BURNED_ROAD_ID,
|
|
travelDurationSeconds: 10,
|
|
ambushChance: '0.0500',
|
|
enabled: true,
|
|
fromLocation: southGate,
|
|
toLocation: burnedRoad,
|
|
} as LocationConnection;
|
|
|
|
return {
|
|
characters: [character],
|
|
locations: [southGate, burnedRoad],
|
|
connections: [connection],
|
|
travels: [],
|
|
};
|
|
}
|
|
|
|
function activeTravel(arrivesAt: Date): Travel {
|
|
return {
|
|
id: TRAVEL_ID,
|
|
characterId: CHARACTER_ID,
|
|
originLocationId: SOUTH_GATE_ID,
|
|
targetLocationId: BURNED_ROAD_ID,
|
|
startedAt: new Date('2026-08-18T09:59:50.000Z'),
|
|
arrivesAt,
|
|
status: TravelStatus.TRAVELLING,
|
|
createdAt: new Date('2026-08-18T09:59:50.000Z'),
|
|
} as Travel;
|
|
}
|
|
|
|
function createService(state = createState()) {
|
|
const dataSource = new FakeDataSource(state);
|
|
const clock: Clock = { now: () => new Date(NOW) };
|
|
const service = new TravelService(dataSource as unknown as DataSource, clock);
|
|
return { dataSource, service };
|
|
}
|
|
|
|
describe('TravelService', () => {
|
|
it('starts travel for an enabled directed connection', async () => {
|
|
const { dataSource, service } = createService();
|
|
|
|
await expect(
|
|
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
|
|
).resolves.toEqual({
|
|
status: TravelStatus.TRAVELLING,
|
|
originLocation: {
|
|
id: SOUTH_GATE_ID,
|
|
key: 'south-gate',
|
|
name: 'S\u00fcdtor von Graufurt',
|
|
},
|
|
targetLocation: {
|
|
id: BURNED_ROAD_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Stra\u00dfe',
|
|
},
|
|
startedAt: new Date('2026-08-18T10:00:00.000Z'),
|
|
arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
|
|
});
|
|
expect(dataSource.state.travels).toHaveLength(1);
|
|
expect(dataSource.state.travels[0]).toMatchObject({
|
|
characterId: CHARACTER_ID,
|
|
originLocationId: SOUTH_GATE_ID,
|
|
targetLocationId: BURNED_ROAD_ID,
|
|
status: TravelStatus.TRAVELLING,
|
|
});
|
|
expect(dataSource.locks).toEqual([
|
|
{ target: Character, mode: 'pessimistic_write' },
|
|
{ target: Travel, mode: 'pessimistic_write' },
|
|
]);
|
|
});
|
|
|
|
it('rejects a target without an enabled connection', async () => {
|
|
const state = createState();
|
|
state.connections[0].enabled = false;
|
|
const { dataSource, service } = createService(state);
|
|
|
|
await expect(
|
|
service.startTravel(CHARACTER_ID, BURNED_ROAD_ID),
|
|
).rejects.toMatchObject<Partial<TravelDomainError>>({
|
|
code: 'INVALID_TRAVEL_TARGET',
|
|
});
|
|
expect(dataSource.state.travels).toEqual([]);
|
|
});
|
|
|
|
it('derives arrivesAt from the injected clock and connection duration', async () => {
|
|
const { dataSource, service } = createService();
|
|
|
|
const result = await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID);
|
|
|
|
expect(result.startedAt).toEqual(new Date('2026-08-18T10:00:00.000Z'));
|
|
expect(result.arrivesAt).toEqual(new Date('2026-08-18T10:00:10.000Z'));
|
|
expect(dataSource.state.travels[0].arrivesAt).toEqual(
|
|
new Date('2026-08-18T10:00:10.000Z'),
|
|
);
|
|
});
|
|
|
|
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);
|
|
|
|
let error: unknown;
|
|
try {
|
|
await service.startTravel(CHARACTER_ID, BURNED_ROAD_ID);
|
|
} catch (cause) {
|
|
error = cause;
|
|
}
|
|
|
|
expect(error).toBeInstanceOf(TravelDomainError);
|
|
if (!(error instanceof TravelDomainError)) {
|
|
throw new Error('Expected TravelDomainError');
|
|
}
|
|
expect(error.getResponse()).toEqual({
|
|
statusCode: 409,
|
|
code: 'TRAVEL_ALREADY_ACTIVE',
|
|
message: 'The character is already travelling.',
|
|
});
|
|
expect(dataSource.state.travels).toHaveLength(1);
|
|
});
|
|
|
|
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);
|
|
|
|
await expect(service.getCurrentTravel(CHARACTER_ID)).resolves.toEqual({
|
|
status: TravelStatus.TRAVELLING,
|
|
originLocation: {
|
|
id: SOUTH_GATE_ID,
|
|
key: 'south-gate',
|
|
name: 'S\u00fcdtor von Graufurt',
|
|
},
|
|
targetLocation: {
|
|
id: BURNED_ROAD_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Stra\u00dfe',
|
|
},
|
|
startedAt: new Date('2026-08-18T09:59:50.000Z'),
|
|
arrivesAt: new Date('2026-08-18T10:00:10.000Z'),
|
|
});
|
|
});
|
|
|
|
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);
|
|
|
|
await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
|
|
status: TravelStatus.TRAVELLING,
|
|
originLocation: {
|
|
id: SOUTH_GATE_ID,
|
|
key: 'south-gate',
|
|
name: 'S\u00fcdtor von Graufurt',
|
|
},
|
|
targetLocation: {
|
|
id: BURNED_ROAD_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Stra\u00dfe',
|
|
},
|
|
startedAt: new Date('2026-08-18T09:59:50.000Z'),
|
|
arrivesAt: new Date('2026-08-18T10:00:00.001Z'),
|
|
});
|
|
expect(dataSource.state.characters[0].currentLocationId).toBe(
|
|
SOUTH_GATE_ID,
|
|
);
|
|
expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING);
|
|
});
|
|
|
|
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);
|
|
|
|
await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
|
|
status: TravelStatus.COMPLETED,
|
|
targetLocation: {
|
|
id: BURNED_ROAD_ID,
|
|
key: 'burned-road',
|
|
name: 'Verbrannte Stra\u00dfe',
|
|
},
|
|
});
|
|
expect(dataSource.state.characters[0].currentLocationId).toBe(
|
|
BURNED_ROAD_ID,
|
|
);
|
|
expect(dataSource.state.travels[0].status).toBe(TravelStatus.COMPLETED);
|
|
|
|
await expect(service.completeTravelIfDue(CHARACTER_ID)).resolves.toEqual({
|
|
status: 'IDLE',
|
|
});
|
|
expect(dataSource.state.characters[0].currentLocationId).toBe(
|
|
BURNED_ROAD_ID,
|
|
);
|
|
});
|
|
|
|
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);
|
|
dataSource.failSaveTarget = Character;
|
|
|
|
await expect(service.completeTravelIfDue(CHARACTER_ID)).rejects.toThrow(
|
|
'Failed to save Character',
|
|
);
|
|
expect(dataSource.state.characters[0].currentLocationId).toBe(
|
|
SOUTH_GATE_ID,
|
|
);
|
|
expect(dataSource.state.travels[0].status).toBe(TravelStatus.TRAVELLING);
|
|
});
|
|
});
|