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.
252 lines
8.5 KiB
TypeScript
252 lines
8.5 KiB
TypeScript
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';
|
||
}
|
||
}
|