feat(world): serve local location view content from the API
Adds the server side of the local location view: location_definitions carries region naming, a location type, a scene-level description and artwork, plus JSONB points of interest, primary actions and a reward preview. Locations become content, so a second location renders through the same components with different data. GET /api/world/current-location gains those fields, a recommendation label and a danger rating derived from the weighted average of the location's own monster pool — a rare elite no longer makes a beginner road read as lethal. The encounter preview is derived from that same pool rather than duplicating it. POST /api/world/current-location/interactions/:key reveals a hotspot's authored result. The location is resolved from the character, never from the request, and result text never ships with the location payload, so a caller cannot read or trigger a hotspot it has not travelled to. Seeds the Verbrannte Straße with its four hotspots and the Südtor with its own transition content. Adds Verwilderter Straßenhund and Verkohlter Plünderer to the road's pool, including combat sprites, so the preview shows encounters the hunt can actually roll. Medallion icons move to images/monsters/icons, where both combat and the location view read them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,24 @@ 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 { 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;
|
||||
@@ -30,6 +45,18 @@ export interface CurrentLocationResponse {
|
||||
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[];
|
||||
}
|
||||
@@ -49,15 +76,7 @@ export class WorldService {
|
||||
async getCurrentLocation(
|
||||
characterId: string,
|
||||
): Promise<CurrentLocationResponse> {
|
||||
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.');
|
||||
}
|
||||
const character = await this.loadCharacterAtCurrentLocation(characterId);
|
||||
|
||||
const connections = await this.connections.find({
|
||||
where: { fromLocationId: character.currentLocationId, enabled: true },
|
||||
@@ -65,8 +84,8 @@ export class WorldService {
|
||||
});
|
||||
const location = character.currentLocation;
|
||||
|
||||
const possibleMonsters = location.huntingEnabled
|
||||
? await this.getPossibleMonsters(location.id)
|
||||
const pool = location.huntingEnabled
|
||||
? await this.getEncounterPool(location.id)
|
||||
: [];
|
||||
|
||||
return {
|
||||
@@ -81,6 +100,24 @@ export class WorldService {
|
||||
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: connections
|
||||
.filter((connection) => connection.enabled)
|
||||
.map((connection) => ({
|
||||
@@ -92,17 +129,102 @@ export class WorldService {
|
||||
travelDurationSeconds: connection.travelDurationSeconds,
|
||||
danger: this.toDangerRating(connection.ambushChance),
|
||||
})),
|
||||
possibleMonsters,
|
||||
possibleMonsters: pool.map((entry) => entry.monster.name),
|
||||
};
|
||||
}
|
||||
|
||||
private async getPossibleMonsters(locationId: string): Promise<string[]> {
|
||||
const pool = await this.locationMonsters.find({
|
||||
/**
|
||||
* 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();
|
||||
}
|
||||
|
||||
return {
|
||||
interactionKey: poi.key,
|
||||
title: poi.resultTitle ?? poi.title,
|
||||
text: poi.resultText,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' },
|
||||
});
|
||||
return pool.map((entry) => entry.monster.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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' {
|
||||
|
||||
Reference in New Issue
Block a user