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, @InjectRepository(LocationConnection) private readonly connections: Repository, @InjectRepository(LocationMonster) private readonly locationMonsters: Repository, private readonly worldDiscovery: WorldDiscoveryService, ) {} async getCurrentLocation( characterId: string, ): Promise { 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 { 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 { 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 { 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'; } }