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, }; } }