feat: add server authoritative travel flow
This commit is contained in:
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user