feat: add server authoritative travel flow

This commit is contained in:
Bastian Wagner
2026-08-18 20:30:09 +02:00
parent e9b111daf8
commit d06f05047b
12 changed files with 904 additions and 3 deletions

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