225 lines
6.5 KiB
TypeScript
225 lines
6.5 KiB
TypeScript
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,
|
|
};
|
|
}
|
|
}
|