feat: add server authoritative travel flow
This commit is contained in:
@@ -1,5 +1,12 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
export function configureApplication(app: INestApplication): void {
|
||||
app.setGlobalPrefix('api');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@ import { Module } from '@nestjs/common';
|
||||
import { CharactersModule } from './characters/characters.module';
|
||||
import { DatabaseModule } from './database/database.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
import { TravelModule } from './travel/travel.module';
|
||||
|
||||
@Module({
|
||||
imports: [DatabaseModule, HealthModule, CharactersModule],
|
||||
imports: [DatabaseModule, HealthModule, CharactersModule, TravelModule],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
9
apps/api/src/travel/clock.ts
Normal file
9
apps/api/src/travel/clock.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export const CLOCK = Symbol('CLOCK');
|
||||
|
||||
export interface Clock {
|
||||
now(): Date;
|
||||
}
|
||||
|
||||
export const systemClock: Clock = {
|
||||
now: () => new Date(),
|
||||
};
|
||||
6
apps/api/src/travel/dto/start-travel.dto.ts
Normal file
6
apps/api/src/travel/dto/start-travel.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsUUID } from 'class-validator';
|
||||
|
||||
export class StartTravelDto {
|
||||
@IsUUID()
|
||||
targetLocationId!: string;
|
||||
}
|
||||
48
apps/api/src/travel/travel.controller.spec.ts
Normal file
48
apps/api/src/travel/travel.controller.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { Test } from '@nestjs/testing';
|
||||
import request from 'supertest';
|
||||
import { App } from 'supertest/types';
|
||||
import { configureApplication } from '../app.config';
|
||||
import { TravelController } from './travel.controller';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
describe('TravelController request validation', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module = await Test.createTestingModule({
|
||||
controllers: [TravelController],
|
||||
providers: [
|
||||
{
|
||||
provide: TravelService,
|
||||
useValue: {
|
||||
startTravel: () => Promise.resolve({ status: 'TRAVELLING' }),
|
||||
completeTravelIfDue: () => Promise.resolve({ status: 'IDLE' }),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
|
||||
app = module.createNestApplication<App>();
|
||||
configureApplication(app);
|
||||
await app.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it('rejects server-owned travel timing and duration fields', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/travel')
|
||||
.send({
|
||||
targetLocationId: BURNED_ROAD_ID,
|
||||
startedAt: '2026-08-18T10:00:00.000Z',
|
||||
arrivesAt: '2026-08-18T10:00:10.000Z',
|
||||
travelDurationSeconds: 10,
|
||||
})
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||
22
apps/api/src/travel/travel.controller.ts
Normal file
22
apps/api/src/travel/travel.controller.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { StartTravelDto } from './dto/start-travel.dto';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
@Controller('travel')
|
||||
export class TravelController {
|
||||
constructor(private readonly travelService: TravelService) {}
|
||||
|
||||
@Post()
|
||||
startTravel(@Body() request: StartTravelDto) {
|
||||
return this.travelService.startTravel(
|
||||
DEMO_CHARACTER_ID,
|
||||
request.targetLocationId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get('current')
|
||||
getCurrentTravel() {
|
||||
return this.travelService.completeTravelIfDue(DEMO_CHARACTER_ID);
|
||||
}
|
||||
}
|
||||
49
apps/api/src/travel/travel.errors.ts
Normal file
49
apps/api/src/travel/travel.errors.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { HttpException, HttpStatus } from '@nestjs/common';
|
||||
|
||||
export type TravelErrorCode =
|
||||
| 'CHARACTER_NOT_FOUND'
|
||||
| 'INVALID_TRAVEL_TARGET'
|
||||
| 'TRAVEL_ALREADY_ACTIVE'
|
||||
| 'TRAVEL_STATE_INVALID';
|
||||
|
||||
export class TravelDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: TravelErrorCode,
|
||||
status: HttpStatus,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
export function characterNotFound(): TravelDomainError {
|
||||
return new TravelDomainError(
|
||||
'CHARACTER_NOT_FOUND',
|
||||
HttpStatus.NOT_FOUND,
|
||||
'The character does not exist.',
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidTravelTarget(): TravelDomainError {
|
||||
return new TravelDomainError(
|
||||
'INVALID_TRAVEL_TARGET',
|
||||
HttpStatus.BAD_REQUEST,
|
||||
'The selected location is not connected to the current location.',
|
||||
);
|
||||
}
|
||||
|
||||
export function travelAlreadyActive(): TravelDomainError {
|
||||
return new TravelDomainError(
|
||||
'TRAVEL_ALREADY_ACTIVE',
|
||||
HttpStatus.CONFLICT,
|
||||
'The character is already travelling.',
|
||||
);
|
||||
}
|
||||
|
||||
export function invalidTravelState(): TravelDomainError {
|
||||
return new TravelDomainError(
|
||||
'TRAVEL_STATE_INVALID',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
'The persisted travel references an unavailable location.',
|
||||
);
|
||||
}
|
||||
24
apps/api/src/travel/travel.module.ts
Normal file
24
apps/api/src/travel/travel.module.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
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 './clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import { TravelController } from './travel.controller';
|
||||
import { TravelService } from './travel.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([
|
||||
Character,
|
||||
LocationDefinition,
|
||||
LocationConnection,
|
||||
Travel,
|
||||
]),
|
||||
],
|
||||
controllers: [TravelController],
|
||||
providers: [TravelService, { provide: CLOCK, useValue: systemClock }],
|
||||
exports: [TravelService],
|
||||
})
|
||||
export class TravelModule {}
|
||||
400
apps/api/src/travel/travel.service.spec.ts
Normal file
400
apps/api/src/travel/travel.service.spec.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
224
apps/api/src/travel/travel.service.ts
Normal file
224
apps/api/src/travel/travel.service.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { DataSource, Repository } from 'typeorm';
|
||||
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 } from './clock';
|
||||
import type { Clock } from './clock';
|
||||
import { Travel } from './entities/travel.entity';
|
||||
import {
|
||||
characterNotFound,
|
||||
invalidTravelState,
|
||||
invalidTravelTarget,
|
||||
travelAlreadyActive,
|
||||
} from './travel.errors';
|
||||
import { TravelStatus } from './travel-status.enum';
|
||||
|
||||
export interface LocationSummary {
|
||||
id: string;
|
||||
key: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface IdleTravelResponse {
|
||||
status: 'IDLE';
|
||||
}
|
||||
|
||||
export interface TravellingResponse {
|
||||
status: TravelStatus.TRAVELLING;
|
||||
originLocation: LocationSummary;
|
||||
targetLocation: LocationSummary;
|
||||
startedAt: Date;
|
||||
arrivesAt: Date;
|
||||
}
|
||||
|
||||
export interface CompletedTravelResponse {
|
||||
status: TravelStatus.COMPLETED;
|
||||
targetLocation: LocationSummary;
|
||||
}
|
||||
|
||||
export type CurrentTravelResponse =
|
||||
IdleTravelResponse | TravellingResponse | CompletedTravelResponse;
|
||||
|
||||
@Injectable()
|
||||
export class TravelService {
|
||||
constructor(
|
||||
private readonly dataSource: DataSource,
|
||||
@Inject(CLOCK) private readonly clock: Clock,
|
||||
) {}
|
||||
|
||||
startTravel(
|
||||
characterId: string,
|
||||
targetLocationId: string,
|
||||
): Promise<TravellingResponse> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const travels = manager.getRepository(Travel);
|
||||
const locations = manager.getRepository(LocationDefinition);
|
||||
const connections = manager.getRepository(LocationConnection);
|
||||
|
||||
const character = await this.lockCharacter(characters, characterId);
|
||||
const activeTravel = await this.lockActiveTravel(travels, characterId);
|
||||
if (activeTravel) {
|
||||
throw travelAlreadyActive();
|
||||
}
|
||||
|
||||
const targetLocation = await locations.findOneBy({
|
||||
id: targetLocationId,
|
||||
});
|
||||
if (!targetLocation) {
|
||||
throw invalidTravelTarget();
|
||||
}
|
||||
|
||||
const connection = await connections.findOneBy({
|
||||
fromLocationId: character.currentLocationId,
|
||||
toLocationId: targetLocationId,
|
||||
enabled: true,
|
||||
});
|
||||
if (!connection) {
|
||||
throw invalidTravelTarget();
|
||||
}
|
||||
|
||||
const originLocation = await locations.findOneBy({
|
||||
id: character.currentLocationId,
|
||||
});
|
||||
if (!originLocation) {
|
||||
throw invalidTravelState();
|
||||
}
|
||||
|
||||
const startedAt = new Date(this.clock.now().getTime());
|
||||
const arrivesAt = new Date(
|
||||
startedAt.getTime() + connection.travelDurationSeconds * 1000,
|
||||
);
|
||||
const travel = travels.create({
|
||||
characterId,
|
||||
originLocationId: character.currentLocationId,
|
||||
targetLocationId,
|
||||
startedAt,
|
||||
arrivesAt,
|
||||
status: TravelStatus.TRAVELLING,
|
||||
});
|
||||
await travels.save(travel);
|
||||
|
||||
return this.toTravellingResponse(travel, originLocation, targetLocation);
|
||||
});
|
||||
}
|
||||
|
||||
async getCurrentTravel(characterId: string): Promise<CurrentTravelResponse> {
|
||||
const travels = this.dataSource.getRepository(Travel);
|
||||
const travel = await travels.findOneBy({
|
||||
characterId,
|
||||
status: TravelStatus.TRAVELLING,
|
||||
});
|
||||
if (!travel) {
|
||||
return { status: 'IDLE' };
|
||||
}
|
||||
|
||||
const locations = this.dataSource.getRepository(LocationDefinition);
|
||||
const { originLocation, targetLocation } = await this.loadTravelLocations(
|
||||
locations,
|
||||
travel,
|
||||
);
|
||||
return this.toTravellingResponse(travel, originLocation, targetLocation);
|
||||
}
|
||||
|
||||
completeTravelIfDue(characterId: string): Promise<CurrentTravelResponse> {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const characters = manager.getRepository(Character);
|
||||
const travels = manager.getRepository(Travel);
|
||||
const locations = manager.getRepository(LocationDefinition);
|
||||
|
||||
const character = await this.lockCharacter(characters, characterId);
|
||||
const travel = await this.lockActiveTravel(travels, characterId);
|
||||
if (!travel) {
|
||||
return { status: 'IDLE' };
|
||||
}
|
||||
|
||||
const { originLocation, targetLocation } = await this.loadTravelLocations(
|
||||
locations,
|
||||
travel,
|
||||
);
|
||||
const now = this.clock.now();
|
||||
if (travel.arrivesAt.getTime() > now.getTime()) {
|
||||
return this.toTravellingResponse(
|
||||
travel,
|
||||
originLocation,
|
||||
targetLocation,
|
||||
);
|
||||
}
|
||||
|
||||
travel.status = TravelStatus.COMPLETED;
|
||||
character.currentLocationId = travel.targetLocationId;
|
||||
await travels.save(travel);
|
||||
await characters.save(character);
|
||||
|
||||
return {
|
||||
status: TravelStatus.COMPLETED,
|
||||
targetLocation: this.toLocationSummary(targetLocation),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async lockCharacter(
|
||||
characters: Repository<Character>,
|
||||
characterId: string,
|
||||
): Promise<Character> {
|
||||
const character = await characters.findOne({
|
||||
where: { id: characterId },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
if (!character) {
|
||||
throw characterNotFound();
|
||||
}
|
||||
return character;
|
||||
}
|
||||
|
||||
private lockActiveTravel(
|
||||
travels: Repository<Travel>,
|
||||
characterId: string,
|
||||
): Promise<Travel | null> {
|
||||
return travels.findOne({
|
||||
where: { characterId, status: TravelStatus.TRAVELLING },
|
||||
lock: { mode: 'pessimistic_write' },
|
||||
});
|
||||
}
|
||||
|
||||
private async loadTravelLocations(
|
||||
locations: Repository<LocationDefinition>,
|
||||
travel: Travel,
|
||||
): Promise<{
|
||||
originLocation: LocationDefinition;
|
||||
targetLocation: LocationDefinition;
|
||||
}> {
|
||||
const [originLocation, targetLocation] = await Promise.all([
|
||||
locations.findOneBy({ id: travel.originLocationId }),
|
||||
locations.findOneBy({ id: travel.targetLocationId }),
|
||||
]);
|
||||
if (!originLocation || !targetLocation) {
|
||||
throw invalidTravelState();
|
||||
}
|
||||
return { originLocation, targetLocation };
|
||||
}
|
||||
|
||||
private toTravellingResponse(
|
||||
travel: Travel,
|
||||
originLocation: LocationDefinition,
|
||||
targetLocation: LocationDefinition,
|
||||
): TravellingResponse {
|
||||
return {
|
||||
status: TravelStatus.TRAVELLING,
|
||||
originLocation: this.toLocationSummary(originLocation),
|
||||
targetLocation: this.toLocationSummary(targetLocation),
|
||||
startedAt: travel.startedAt,
|
||||
arrivesAt: travel.arrivesAt,
|
||||
};
|
||||
}
|
||||
|
||||
private toLocationSummary(location: LocationDefinition): LocationSummary {
|
||||
return {
|
||||
id: location.id,
|
||||
key: location.key,
|
||||
name: location.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { AppModule } from './../src/app.module';
|
||||
import { CharactersModule } from './../src/characters/characters.module';
|
||||
import { DatabaseModule } from './../src/database/database.module';
|
||||
import { configureApplication } from './../src/app.config';
|
||||
import { TravelModule } from './../src/travel/travel.module';
|
||||
|
||||
@Module({})
|
||||
class TestDatabaseModule {}
|
||||
@@ -13,6 +14,9 @@ class TestDatabaseModule {}
|
||||
@Module({})
|
||||
class TestCharactersModule {}
|
||||
|
||||
@Module({})
|
||||
class TestTravelModule {}
|
||||
|
||||
describe('API (e2e)', () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
@@ -24,6 +28,8 @@ describe('API (e2e)', () => {
|
||||
.useModule(TestDatabaseModule)
|
||||
.overrideModule(CharactersModule)
|
||||
.useModule(TestCharactersModule)
|
||||
.overrideModule(TravelModule)
|
||||
.useModule(TestTravelModule)
|
||||
.compile();
|
||||
|
||||
app = moduleFixture.createNestApplication();
|
||||
@@ -43,6 +49,6 @@ describe('API (e2e)', () => {
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await app.close();
|
||||
await app?.close();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user