Files
ashen-realms/apps/api/src/world/world.service.ts
Bastian Wagner ac7750902a perf(api): load the discovery set once per location request
getCurrentLocation was calling isTravelAllowed per connection, re-querying
the character's discovered locations once per gated exit. Extract the
gating rule into a pure, synchronous WorldDiscoveryService.isRouteOpen so
the map filter can load the discovery set once and filter in memory, while
isTravelAllowed (TravelService's entry point) keeps its exact signature and
behaviour by delegating to the same rule. Also pins that discoversLocationKey
never reaches the client payload.
2026-08-23 11:28:42 +02:00

252 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Character } from '../characters/entities/character.entity';
import { calculateDangerRating, DangerRating } from '../hunting/danger-rating';
import { LocationMonster } from '../monsters/entities/location-monster.entity';
import { TravelService } from '../travel/travel.service';
import { WorldDiscoveryService } from './discovery/world-discovery.service';
import { LocationConnection } from './entities/location-connection.entity';
import { LocationDefinition } from './entities/location-definition.entity';
import {
EncounterPreviewDto,
LocalLocationPointOfInterestDto,
LocalLocationPrimaryActionDto,
LocationInteractionResultDto,
LocationType,
RewardPreviewDto,
toPointOfInterestDto,
} from './local-location.types';
import { locationInteractionUnavailable } from './world.errors';
export interface LocationSummary {
id: string;
key: string;
name: string;
}
export interface CurrentLocationConnection {
targetLocation: LocationSummary;
travelDurationSeconds: number;
danger: 'LOW' | 'HIGH';
}
export interface CurrentLocationResponse {
id: string;
key: string;
name: string;
description: string;
regionKey: string;
minRecommendedLevel: number;
maxRecommendedLevel: number;
dangerLevel: number;
isSafe: boolean;
huntingEnabled: boolean;
artworkPath: string;
regionName: string;
regionTierLabel: string;
locationType: LocationType;
localDescription: string;
localArtworkPath: string;
/** `null` where nothing hostile can be met — the view reads that as safe. */
dangerRating: DangerRating | null;
recommendationLabel: string;
pointsOfInterest: LocalLocationPointOfInterestDto[];
primaryActions: LocalLocationPrimaryActionDto[];
encounterPreview: EncounterPreviewDto[];
rewardPreview: RewardPreviewDto[];
connections: CurrentLocationConnection[];
possibleMonsters: string[];
}
@Injectable()
export class WorldService {
constructor(
private readonly travelService: TravelService,
@InjectRepository(Character)
private readonly characters: Repository<Character>,
@InjectRepository(LocationConnection)
private readonly connections: Repository<LocationConnection>,
@InjectRepository(LocationMonster)
private readonly locationMonsters: Repository<LocationMonster>,
private readonly worldDiscovery: WorldDiscoveryService,
) {}
async getCurrentLocation(
characterId: string,
): Promise<CurrentLocationResponse> {
const character = await this.loadCharacterAtCurrentLocation(characterId);
const connections = await this.connections.find({
where: { fromLocationId: character.currentLocationId, enabled: true },
relations: { toLocation: true },
});
const location = character.currentLocation;
const pool = location.huntingEnabled
? await this.getEncounterPool(location.id)
: [];
// Loaded once per request rather than per connection: `isRouteOpen` is
// synchronous, so a location with several gated exits costs one query
// here instead of one per gated connection.
const discoveredLocationIds =
await this.worldDiscovery.getDiscoveredLocationIds(characterId);
const visibleConnections = connections.filter(
(connection) =>
connection.enabled &&
this.worldDiscovery.isRouteOpen(discoveredLocationIds, connection),
);
return {
id: location.id,
key: location.key,
name: location.name,
description: location.description,
regionKey: location.regionKey,
minRecommendedLevel: location.minRecommendedLevel,
maxRecommendedLevel: location.maxRecommendedLevel,
dangerLevel: location.dangerLevel,
isSafe: location.isSafe,
huntingEnabled: location.huntingEnabled,
artworkPath: location.artworkPath,
regionName: location.regionName,
regionTierLabel: location.regionTierLabel,
locationType: location.locationType,
localDescription: location.localDescription,
localArtworkPath: location.localArtworkPath,
dangerRating: this.toLocalDangerRating(character, pool),
recommendationLabel: this.toRecommendationLabel(location),
pointsOfInterest:
location.localPointsOfInterest.map(toPointOfInterestDto),
primaryActions: location.localPrimaryActions,
encounterPreview: pool.map((entry) => ({
key: entry.monster.key,
name: entry.monster.name,
level: entry.monster.level,
iconPath: entry.monster.iconPath,
})),
rewardPreview: location.localRewardPreview,
connections: visibleConnections.map((connection) => ({
targetLocation: {
id: connection.toLocation.id,
key: connection.toLocation.key,
name: connection.toLocation.name,
},
travelDurationSeconds: connection.travelDurationSeconds,
danger: this.toDangerRating(connection.ambushChance),
})),
possibleMonsters: pool.map((entry) => entry.monster.name),
};
}
/**
* Runs a short local interaction (investigate, search, talk) and reveals its
* authored result.
*
* The location is taken from the character, never from the request, so a
* caller cannot reach a hotspot it has not travelled to. Hotspots that only
* navigate (HUNT, MAP) carry no result text and are rejected here — the
* client routes those itself.
*/
async runLocalInteraction(
characterId: string,
interactionKey: string,
): Promise<LocationInteractionResultDto> {
const character = await this.loadCharacterAtCurrentLocation(characterId);
const poi = character.currentLocation.localPointsOfInterest.find(
(candidate) => candidate.key === interactionKey,
);
if (!poi?.enabled || !poi.resultText) {
throw locationInteractionUnavailable();
}
// A hotspot that reveals a route writes before it speaks. Idempotent by
// the unique pair, so a second click simply reports nothing new.
const discoveredLocation = poi.discoversLocationKey
? await this.worldDiscovery.discover(
characterId,
poi.discoversLocationKey,
)
: null;
return {
interactionKey: poi.key,
title: poi.resultTitle ?? poi.title,
text: poi.resultText,
...(poi.resultImg === undefined ? {} : { img: poi.resultImg }),
discoveredLocation,
};
}
/**
* Loads the character together with the location it actually stands at.
*
* Every local interaction resolves through here, so a request can never name
* the location it wants to act on (plan §5).
*/
async loadCharacterAtCurrentLocation(
characterId: string,
): Promise<Character> {
await this.travelService.completeTravelIfDue(characterId);
const character = await this.characters.findOne({
where: { id: characterId },
relations: { currentLocation: true },
});
if (!character) {
throw new NotFoundException('Character not found.');
}
return character;
}
private getEncounterPool(locationId: string): Promise<LocationMonster[]> {
return this.locationMonsters.find({
where: { locationId, enabled: true },
relations: { monster: true },
order: { weight: 'DESC' },
});
}
/**
* Rates the location by the encounter a traveller can typically expect: the
* pool's weight-averaged stats, not its single worst entry. A rare elite
* would otherwise make a beginner road read as lethal.
*/
private toLocalDangerRating(
character: Character,
pool: LocationMonster[],
): DangerRating | null {
const totalWeight = pool.reduce((sum, entry) => sum + entry.weight, 0);
if (totalWeight === 0) {
return null;
}
const average = (pick: (entry: LocationMonster) => number) =>
pool.reduce((sum, entry) => sum + entry.weight * pick(entry), 0) /
totalWeight;
return calculateDangerRating(
{ attack: character.baseAttack, armor: 0, hp: character.baseHp },
{
attack: average((entry) => entry.monster.attack),
armor: average((entry) => entry.monster.armor),
hp: average((entry) => entry.monster.maxHp),
},
);
}
private toRecommendationLabel(location: LocationDefinition): string {
return location.minRecommendedLevel === location.maxRecommendedLevel
? `${location.minRecommendedLevel}`
: `${location.minRecommendedLevel}${location.maxRecommendedLevel}`;
}
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
return Number(ambushChance) <= 0.05 ? 'LOW' : 'HIGH';
}
}