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 { constructor( private readonly state: FakeState, private readonly target: EntityTarget, private readonly inTransaction: boolean, private readonly dataSource: FakeDataSource, ) {} findOne(options: { where: Partial; lock?: { mode: string }; }): Promise { 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): Promise { return Promise.resolve( this.rows().find((row) => this.matches(row, where)) ?? null, ); } create(values: Partial): T { return { ...values } as T; } save(entity: T): Promise { 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): 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(target: EntityTarget) { return new FakeRepository(this.state, target, true, this.dataSource); } } class FakeDataSource { readonly locks: Array<{ target: EntityTarget; mode: string }> = []; failSaveTarget?: EntityTarget; constructor(public state: FakeState) {} getRepository(target: EntityTarget) { return new FakeRepository(this.state, target, false, this); } async transaction( work: (manager: EntityManager) => Promise, ): Promise { 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>({ 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); }); });