diff --git a/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-5-report.md b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-5-report.md new file mode 100644 index 0000000..2ddf12b --- /dev/null +++ b/.superpowers/sdd/2026-08-18-first-visible-vertical-slice/task-5-report.md @@ -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 +node_modules/.bin/eslint.cmd +``` + +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. diff --git a/apps/api/src/app.config.ts b/apps/api/src/app.config.ts index 1a4dc9e..015b6f7 100644 --- a/apps/api/src/app.config.ts +++ b/apps/api/src/app.config.ts @@ -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, + }), + ); } diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 6f7dd5c..9c646e3 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -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 {} diff --git a/apps/api/src/travel/clock.ts b/apps/api/src/travel/clock.ts new file mode 100644 index 0000000..dc8733d --- /dev/null +++ b/apps/api/src/travel/clock.ts @@ -0,0 +1,9 @@ +export const CLOCK = Symbol('CLOCK'); + +export interface Clock { + now(): Date; +} + +export const systemClock: Clock = { + now: () => new Date(), +}; diff --git a/apps/api/src/travel/dto/start-travel.dto.ts b/apps/api/src/travel/dto/start-travel.dto.ts new file mode 100644 index 0000000..7f51267 --- /dev/null +++ b/apps/api/src/travel/dto/start-travel.dto.ts @@ -0,0 +1,6 @@ +import { IsUUID } from 'class-validator'; + +export class StartTravelDto { + @IsUUID() + targetLocationId!: string; +} diff --git a/apps/api/src/travel/travel.controller.spec.ts b/apps/api/src/travel/travel.controller.spec.ts new file mode 100644 index 0000000..6059b49 --- /dev/null +++ b/apps/api/src/travel/travel.controller.spec.ts @@ -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; + + 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(); + 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'; diff --git a/apps/api/src/travel/travel.controller.ts b/apps/api/src/travel/travel.controller.ts new file mode 100644 index 0000000..cc2b7f6 --- /dev/null +++ b/apps/api/src/travel/travel.controller.ts @@ -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); + } +} diff --git a/apps/api/src/travel/travel.errors.ts b/apps/api/src/travel/travel.errors.ts new file mode 100644 index 0000000..b605688 --- /dev/null +++ b/apps/api/src/travel/travel.errors.ts @@ -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.', + ); +} diff --git a/apps/api/src/travel/travel.module.ts b/apps/api/src/travel/travel.module.ts new file mode 100644 index 0000000..94053fc --- /dev/null +++ b/apps/api/src/travel/travel.module.ts @@ -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 {} diff --git a/apps/api/src/travel/travel.service.spec.ts b/apps/api/src/travel/travel.service.spec.ts new file mode 100644 index 0000000..d6996f0 --- /dev/null +++ b/apps/api/src/travel/travel.service.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/api/src/travel/travel.service.ts b/apps/api/src/travel/travel.service.ts new file mode 100644 index 0000000..f7e046a --- /dev/null +++ b/apps/api/src/travel/travel.service.ts @@ -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 { + 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 { + 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 { + 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, + characterId: string, + ): Promise { + const character = await characters.findOne({ + where: { id: characterId }, + lock: { mode: 'pessimistic_write' }, + }); + if (!character) { + throw characterNotFound(); + } + return character; + } + + private lockActiveTravel( + travels: Repository, + characterId: string, + ): Promise { + return travels.findOne({ + where: { characterId, status: TravelStatus.TRAVELLING }, + lock: { mode: 'pessimistic_write' }, + }); + } + + private async loadTravelLocations( + locations: Repository, + 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, + }; + } +} diff --git a/apps/api/test/app.e2e-spec.ts b/apps/api/test/app.e2e-spec.ts index 8e8165f..ea77d0b 100644 --- a/apps/api/test/app.e2e-spec.ts +++ b/apps/api/test/app.e2e-spec.ts @@ -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; @@ -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(); }); });