feat: add server authoritative travel flow
This commit is contained in:
@@ -0,0 +1,105 @@
|
|||||||
|
# Task 5 Report: Server-authoritative travel
|
||||||
|
|
||||||
|
## Outcome
|
||||||
|
|
||||||
|
Implemented the NestJS travel domain and public endpoints:
|
||||||
|
|
||||||
|
- `POST /api/travel`
|
||||||
|
- `GET /api/travel/current`
|
||||||
|
- injected deterministic/system clock abstraction
|
||||||
|
- exact `StartTravelDto` input validation
|
||||||
|
- stable idle, travelling, completed, and domain-error response shapes
|
||||||
|
- `TravelModule` registration in the application
|
||||||
|
|
||||||
|
Travel start and completion use `DataSource.transaction`. Both operations acquire a pessimistic write lock on the character row followed by the active travel row. Relations are deliberately not joined into either locking query, which keeps the queries compatible with TypeORM/PostgreSQL `FOR UPDATE` behavior. Due completion changes the travel status and character location through repositories owned by the same transaction; a failed second save rolls back both mutations.
|
||||||
|
|
||||||
|
No ambush evaluation or other later-slice logic was added. Existing untracked frontend images and the visual asset guide were preserved and excluded from the commit.
|
||||||
|
|
||||||
|
## RED evidence
|
||||||
|
|
||||||
|
Command:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/api -- travel.service.spec.ts travel.controller.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
Initial result: exit code 1. Both suites failed to resolve the missing production modules:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Cannot find module './travel.service' from 'travel/travel.service.spec.ts'
|
||||||
|
Cannot find module './travel.controller' from 'travel/travel.controller.spec.ts'
|
||||||
|
Test Suites: 2 failed, 2 total
|
||||||
|
Tests: 0 total
|
||||||
|
```
|
||||||
|
|
||||||
|
The active-travel regression test was also mutation-checked. Temporarily removing the `TRAVEL_ALREADY_ACTIVE` guard produced the expected focused failure (`Expected constructor: TravelDomainError; Received constructor: Object`); restoring the guard returned the test to green.
|
||||||
|
|
||||||
|
## GREEN evidence
|
||||||
|
|
||||||
|
Focused travel tests:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/api -- travel.service.spec.ts travel.controller.spec.ts --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Suites: 2 passed, 2 total
|
||||||
|
Tests: 9 passed, 9 total
|
||||||
|
Time: 10.973 s
|
||||||
|
```
|
||||||
|
|
||||||
|
The service suite covers all five required behaviors plus active-travel rejection, public current-travel mapping, idempotent repeat completion, pessimistic-lock observation, and rollback when the second completion save fails. The controller suite drives a real Nest HTTP pipeline and verifies that server-owned timestamp/duration fields receive HTTP 400.
|
||||||
|
|
||||||
|
Full API suite:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm test --workspace=@ashen-realms/api -- --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Suites: 7 passed, 7 total
|
||||||
|
Tests: 18 passed, 18 total
|
||||||
|
Time: 15.87 s
|
||||||
|
```
|
||||||
|
|
||||||
|
API E2E:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run test:e2e --workspace=@ashen-realms/api -- --runInBand
|
||||||
|
```
|
||||||
|
|
||||||
|
```text
|
||||||
|
Test Suites: 1 passed, 1 total
|
||||||
|
Tests: 2 passed, 2 total
|
||||||
|
Time: 9.406 s
|
||||||
|
```
|
||||||
|
|
||||||
|
API build:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
npm run build:api
|
||||||
|
```
|
||||||
|
|
||||||
|
Result: exit code 0 (`nest build`).
|
||||||
|
|
||||||
|
Formatting and lint:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
apps/api/node_modules/.bin/prettier.cmd --check <Task 5 files>
|
||||||
|
node_modules/.bin/eslint.cmd <Task 5 files>
|
||||||
|
```
|
||||||
|
|
||||||
|
Both commands exited 0. Prettier reported `All matched files use Prettier code style!`; ESLint reported no findings.
|
||||||
|
|
||||||
|
## Integration notes and self-review
|
||||||
|
|
||||||
|
- The first build exposed TypeScript `TS1272` for a decorated `Clock` parameter under `isolatedModules`; importing `Clock` as a type fixed the root cause, after which tests and build were rerun.
|
||||||
|
- Registering the database-backed `TravelModule` exposed that the existing health E2E test replaced `DatabaseModule` and `CharactersModule` but not travel. The harness now replaces `TravelModule` as well, preserving the database-free health test.
|
||||||
|
- Lock order is identical in start and completion, reducing deadlock risk.
|
||||||
|
- The character row serializes concurrent starts even when no active travel row exists yet; the partial unique database index remains the final invariant.
|
||||||
|
- The due comparison treats `arrivesAt === now` as due and never moves the character earlier.
|
||||||
|
- Public responses contain only location summaries and travel timestamps/status, never entities, duration input, ambush probability, or persistence metadata.
|
||||||
|
|
||||||
|
## Remaining concern
|
||||||
|
|
||||||
|
No live-PostgreSQL travel integration test was added because the repository's current E2E harness intentionally runs without a database; clean migration/seed/API database smoke coverage belongs to Task 10. The transaction code uses supported real `EntityManager.getRepository`, `Repository.findOne` lock options, `findOneBy`, `create`, and `save` behavior, and the stateful fake verifies transaction commit/rollback semantics rather than mock call counts.
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { INestApplication } from '@nestjs/common';
|
import { INestApplication, ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
export function configureApplication(app: INestApplication): void {
|
export function configureApplication(app: INestApplication): void {
|
||||||
app.setGlobalPrefix('api');
|
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 { CharactersModule } from './characters/characters.module';
|
||||||
import { DatabaseModule } from './database/database.module';
|
import { DatabaseModule } from './database/database.module';
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { TravelModule } from './travel/travel.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [DatabaseModule, HealthModule, CharactersModule],
|
imports: [DatabaseModule, HealthModule, CharactersModule, TravelModule],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
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 { CharactersModule } from './../src/characters/characters.module';
|
||||||
import { DatabaseModule } from './../src/database/database.module';
|
import { DatabaseModule } from './../src/database/database.module';
|
||||||
import { configureApplication } from './../src/app.config';
|
import { configureApplication } from './../src/app.config';
|
||||||
|
import { TravelModule } from './../src/travel/travel.module';
|
||||||
|
|
||||||
@Module({})
|
@Module({})
|
||||||
class TestDatabaseModule {}
|
class TestDatabaseModule {}
|
||||||
@@ -13,6 +14,9 @@ class TestDatabaseModule {}
|
|||||||
@Module({})
|
@Module({})
|
||||||
class TestCharactersModule {}
|
class TestCharactersModule {}
|
||||||
|
|
||||||
|
@Module({})
|
||||||
|
class TestTravelModule {}
|
||||||
|
|
||||||
describe('API (e2e)', () => {
|
describe('API (e2e)', () => {
|
||||||
let app: INestApplication<App>;
|
let app: INestApplication<App>;
|
||||||
|
|
||||||
@@ -24,6 +28,8 @@ describe('API (e2e)', () => {
|
|||||||
.useModule(TestDatabaseModule)
|
.useModule(TestDatabaseModule)
|
||||||
.overrideModule(CharactersModule)
|
.overrideModule(CharactersModule)
|
||||||
.useModule(TestCharactersModule)
|
.useModule(TestCharactersModule)
|
||||||
|
.overrideModule(TravelModule)
|
||||||
|
.useModule(TestTravelModule)
|
||||||
.compile();
|
.compile();
|
||||||
|
|
||||||
app = moduleFixture.createNestApplication();
|
app = moduleFixture.createNestApplication();
|
||||||
@@ -43,6 +49,6 @@ describe('API (e2e)', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
await app.close();
|
await app?.close();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user