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:
@@ -8,6 +8,12 @@ import {
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Character } from '../../characters/entities/character.entity';
|
||||
import type {
|
||||
LocationPointOfInterestContent,
|
||||
LocationPrimaryActionContent,
|
||||
LocationRewardPreviewContent,
|
||||
LocationType,
|
||||
} from '../local-location.types';
|
||||
import { LocationConnection } from './location-connection.entity';
|
||||
|
||||
@Entity({ name: 'location_definitions' })
|
||||
@@ -46,6 +52,35 @@ export class LocationDefinition {
|
||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||
artworkPath!: string;
|
||||
|
||||
// --- Local location view (spec §4, §10) -----------------------------------
|
||||
// `description`/`artworkPath` above stay untouched: the map and the hunt
|
||||
// screen keep rendering them. The `local*` columns below feed the local
|
||||
// location view, which needs a longer scene description and a wide artwork.
|
||||
|
||||
@Column({ name: 'region_name', type: 'varchar', length: 150 })
|
||||
regionName!: string;
|
||||
|
||||
@Column({ name: 'region_tier_label', type: 'varchar', length: 50 })
|
||||
regionTierLabel!: string;
|
||||
|
||||
@Column({ name: 'location_type', type: 'varchar', length: 50 })
|
||||
locationType!: LocationType;
|
||||
|
||||
@Column({ name: 'local_description', type: 'text' })
|
||||
localDescription!: string;
|
||||
|
||||
@Column({ name: 'local_artwork_path', type: 'varchar', length: 255 })
|
||||
localArtworkPath!: string;
|
||||
|
||||
@Column({ name: 'local_points_of_interest', type: 'jsonb' })
|
||||
localPointsOfInterest!: LocationPointOfInterestContent[];
|
||||
|
||||
@Column({ name: 'local_primary_actions', type: 'jsonb' })
|
||||
localPrimaryActions!: LocationPrimaryActionContent[];
|
||||
|
||||
@Column({ name: 'local_reward_preview', type: 'jsonb' })
|
||||
localRewardPreview!: LocationRewardPreviewContent[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||
createdAt!: Date;
|
||||
|
||||
|
||||
160
apps/api/src/world/local-location-interaction.spec.ts
Normal file
160
apps/api/src/world/local-location-interaction.spec.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import { Repository } from 'typeorm';
|
||||
import { Character } from '../characters/entities/character.entity';
|
||||
import {
|
||||
BURNED_ROAD_ID,
|
||||
SOUTH_GATE_ID,
|
||||
} from '../database/seeds/vertical-slice.constants';
|
||||
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 type { LocationPointOfInterestContent } from './local-location.types';
|
||||
import { WorldDomainError } from './world.errors';
|
||||
import { WorldService } from './world.service';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
|
||||
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
|
||||
{
|
||||
key: 'hunt-area',
|
||||
title: 'Jagdgebiet',
|
||||
actionLabel: 'Jagd beginnen',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
xPercent: 52,
|
||||
yPercent: 44,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
actionLabel: 'Untersuchen',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
xPercent: 32,
|
||||
yPercent: 78,
|
||||
enabled: true,
|
||||
resultTitle: 'Verdächtige Spuren',
|
||||
resultText:
|
||||
'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
|
||||
},
|
||||
{
|
||||
key: 'sealed-crypt',
|
||||
title: 'Versiegelte Krypta',
|
||||
type: 'DUNGEON',
|
||||
iconKey: 'search',
|
||||
xPercent: 90,
|
||||
yPercent: 20,
|
||||
enabled: false,
|
||||
resultTitle: 'Versiegelte Krypta',
|
||||
resultText: 'Noch verschlossen.',
|
||||
},
|
||||
];
|
||||
|
||||
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
|
||||
{
|
||||
key: 'gate-watch',
|
||||
title: 'Torwache',
|
||||
actionLabel: 'Sprechen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 45,
|
||||
yPercent: 52,
|
||||
enabled: true,
|
||||
resultTitle: 'Torwache',
|
||||
resultText: 'Nur am Südtor zu hören.',
|
||||
},
|
||||
];
|
||||
|
||||
function location(
|
||||
id: string,
|
||||
pointsOfInterest: LocationPointOfInterestContent[],
|
||||
): LocationDefinition {
|
||||
return {
|
||||
id,
|
||||
localPointsOfInterest: pointsOfInterest,
|
||||
} as LocationDefinition;
|
||||
}
|
||||
|
||||
function createService(
|
||||
currentLocation: LocationDefinition,
|
||||
completeTravelIfDue = jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||
) {
|
||||
return new WorldService(
|
||||
{ completeTravelIfDue } as unknown as TravelService,
|
||||
{
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: CHARACTER_ID,
|
||||
currentLocationId: currentLocation.id,
|
||||
currentLocation,
|
||||
}),
|
||||
} as unknown as Repository<Character>,
|
||||
{ find: jest.fn() } as unknown as Repository<LocationConnection>,
|
||||
{ find: jest.fn() } as unknown as Repository<LocationMonster>,
|
||||
);
|
||||
}
|
||||
|
||||
async function expectRejected(promise: Promise<unknown>): Promise<void> {
|
||||
await expect(promise).rejects.toBeInstanceOf(WorldDomainError);
|
||||
await expect(promise).rejects.toMatchObject({
|
||||
code: 'LOCATION_INTERACTION_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
|
||||
describe('WorldService.runLocalInteraction', () => {
|
||||
it('returns the authored result of an enabled interaction at the current location', async () => {
|
||||
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||
|
||||
await expect(
|
||||
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
|
||||
).resolves.toEqual({
|
||||
interactionKey: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
text: 'Zwischen Asche und zerbrochenen Steinen erkennst du mehrere frische Stiefelabdrücke.',
|
||||
});
|
||||
});
|
||||
|
||||
it('settles travel before resolving which location the character stands at', async () => {
|
||||
const completeTravelIfDue = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ status: 'IDLE' });
|
||||
const service = createService(
|
||||
location(BURNED_ROAD_ID, BURNED_ROAD_POIS),
|
||||
completeTravelIfDue,
|
||||
);
|
||||
|
||||
await service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks');
|
||||
|
||||
expect(completeTravelIfDue).toHaveBeenCalledWith(CHARACTER_ID);
|
||||
});
|
||||
|
||||
it('rejects an interaction that belongs to a different location', async () => {
|
||||
const service = createService(location(SOUTH_GATE_ID, SOUTH_GATE_POIS));
|
||||
|
||||
await expectRejected(
|
||||
service.runLocalInteraction(CHARACTER_ID, 'inspect-tracks'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an unknown interaction key', async () => {
|
||||
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||
|
||||
await expectRejected(
|
||||
service.runLocalInteraction(CHARACTER_ID, 'open-vault'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a disabled interaction', async () => {
|
||||
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||
|
||||
await expectRejected(
|
||||
service.runLocalInteraction(CHARACTER_ID, 'sealed-crypt'),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects a navigation hotspot that has no result to reveal', async () => {
|
||||
const service = createService(location(BURNED_ROAD_ID, BURNED_ROAD_POIS));
|
||||
|
||||
await expectRejected(service.runLocalInteraction(CHARACTER_ID, 'hunt-area'));
|
||||
});
|
||||
});
|
||||
126
apps/api/src/world/local-location.types.ts
Normal file
126
apps/api/src/world/local-location.types.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Content and transport types for the local location view (spec §10).
|
||||
*
|
||||
* A location's local presentation is content, not code: points of interest,
|
||||
* primary actions and the reward preview are stored as JSONB on
|
||||
* `LocationDefinition` so a new location renders through the same components
|
||||
* by supplying different data.
|
||||
*/
|
||||
|
||||
export type LocationInteractionType =
|
||||
| 'HUNT'
|
||||
| 'INVESTIGATE'
|
||||
| 'SEARCH'
|
||||
| 'NPC'
|
||||
| 'MAP'
|
||||
| 'TRAVEL'
|
||||
| 'SHOP'
|
||||
| 'QUEST'
|
||||
| 'BOSS'
|
||||
| 'DUNGEON';
|
||||
|
||||
export type LocationType =
|
||||
| 'SAFE_HUB'
|
||||
| 'TRANSITION'
|
||||
| 'HUNTING_GROUND'
|
||||
| 'QUEST_LOCATION'
|
||||
| 'OUTPOST'
|
||||
| 'ELITE_ZONE'
|
||||
| 'BOSS_LOCATION'
|
||||
| 'DUNGEON_ENTRANCE';
|
||||
|
||||
/**
|
||||
* Stored shape of a point of interest. `resultTitle`/`resultText` never leave
|
||||
* the server through the location payload — they are revealed only by the
|
||||
* interaction endpoint, which first verifies the character actually stands
|
||||
* here (plan §5).
|
||||
*/
|
||||
export interface LocationPointOfInterestContent {
|
||||
key: string;
|
||||
title: string;
|
||||
actionLabel?: string;
|
||||
type: LocationInteractionType;
|
||||
iconKey: string;
|
||||
xPercent: number;
|
||||
yPercent: number;
|
||||
enabled: boolean;
|
||||
resultTitle?: string;
|
||||
resultText?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stored shape of a primary action. Interaction-backed actions carry `poiKey`
|
||||
* instead of their own result text, so a POI and the action bar entry that
|
||||
* duplicates it can never drift apart.
|
||||
*/
|
||||
export interface LocationPrimaryActionContent {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: LocationInteractionType;
|
||||
iconKey: string;
|
||||
enabled: boolean;
|
||||
poiKey?: string;
|
||||
}
|
||||
|
||||
export interface LocationRewardPreviewContent {
|
||||
key: string;
|
||||
label: string;
|
||||
iconKey: string;
|
||||
}
|
||||
|
||||
export interface LocalLocationPointOfInterestDto {
|
||||
key: string;
|
||||
title: string;
|
||||
actionLabel?: string;
|
||||
type: LocationInteractionType;
|
||||
iconKey: string;
|
||||
xPercent: number;
|
||||
yPercent: number;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface LocalLocationPrimaryActionDto {
|
||||
key: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
type: LocationInteractionType;
|
||||
iconKey: string;
|
||||
enabled: boolean;
|
||||
poiKey?: string;
|
||||
}
|
||||
|
||||
export interface EncounterPreviewDto {
|
||||
key: string;
|
||||
name: string;
|
||||
level: number;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
export interface RewardPreviewDto {
|
||||
key: string;
|
||||
label: string;
|
||||
iconKey: string;
|
||||
}
|
||||
|
||||
export interface LocationInteractionResultDto {
|
||||
interactionKey: string;
|
||||
title: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Strips server-only result text before a POI is sent to the client. */
|
||||
export function toPointOfInterestDto(
|
||||
poi: LocationPointOfInterestContent,
|
||||
): LocalLocationPointOfInterestDto {
|
||||
return {
|
||||
key: poi.key,
|
||||
title: poi.title,
|
||||
...(poi.actionLabel === undefined ? {} : { actionLabel: poi.actionLabel }),
|
||||
type: poi.type,
|
||||
iconKey: poi.iconKey,
|
||||
xPercent: poi.xPercent,
|
||||
yPercent: poi.yPercent,
|
||||
enabled: poi.enabled,
|
||||
};
|
||||
}
|
||||
35
apps/api/src/world/world.controller.spec.ts
Normal file
35
apps/api/src/world/world.controller.spec.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { WorldController } from './world.controller';
|
||||
import { WorldService } from './world.service';
|
||||
|
||||
describe('WorldController', () => {
|
||||
it('resolves the current location for the acting character', () => {
|
||||
const getCurrentLocation = jest.fn().mockResolvedValue({ key: 'burned-road' });
|
||||
const controller = new WorldController({
|
||||
getCurrentLocation,
|
||||
} as unknown as WorldService);
|
||||
|
||||
void controller.getCurrentLocation();
|
||||
|
||||
expect(getCurrentLocation).toHaveBeenCalledWith(DEMO_CHARACTER_ID);
|
||||
});
|
||||
|
||||
it('forwards only the interaction key, never a caller-supplied location', () => {
|
||||
const runLocalInteraction = jest.fn().mockResolvedValue({
|
||||
interactionKey: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
text: 'Frische Stiefelabdrücke.',
|
||||
});
|
||||
const controller = new WorldController({
|
||||
runLocalInteraction,
|
||||
} as unknown as WorldService);
|
||||
|
||||
void controller.runLocalInteraction('inspect-tracks');
|
||||
|
||||
expect(runLocalInteraction).toHaveBeenCalledWith(
|
||||
DEMO_CHARACTER_ID,
|
||||
'inspect-tracks',
|
||||
);
|
||||
expect(runLocalInteraction).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||
import { LocationInteractionResultDto } from './local-location.types';
|
||||
import { WorldService } from './world.service';
|
||||
|
||||
@Controller('world')
|
||||
@@ -10,4 +11,19 @@ export class WorldController {
|
||||
getCurrentLocation() {
|
||||
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
|
||||
}
|
||||
|
||||
/**
|
||||
* The interaction is addressed by key alone. There is deliberately no
|
||||
* location parameter: the server resolves the location from the character,
|
||||
* so the route cannot be pointed at somewhere the player is not.
|
||||
*/
|
||||
@Post('current-location/interactions/:interactionKey')
|
||||
runLocalInteraction(
|
||||
@Param('interactionKey') interactionKey: string,
|
||||
): Promise<LocationInteractionResultDto> {
|
||||
return this.worldService.runLocalInteraction(
|
||||
DEMO_CHARACTER_ID,
|
||||
interactionKey,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
25
apps/api/src/world/world.errors.ts
Normal file
25
apps/api/src/world/world.errors.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
export class WorldDomainError extends HttpException {
|
||||
constructor(
|
||||
public readonly code: string,
|
||||
status: number,
|
||||
message: string,
|
||||
) {
|
||||
super({ statusCode: status, code, message }, status);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised for any interaction key the current location does not offer — an
|
||||
* unknown key, a key belonging to another location, or one that is disabled.
|
||||
* They share a code on purpose: the client learns "not here", not which of the
|
||||
* three it was.
|
||||
*/
|
||||
export function locationInteractionUnavailable(): WorldDomainError {
|
||||
return new WorldDomainError(
|
||||
'LOCATION_INTERACTION_UNAVAILABLE',
|
||||
400,
|
||||
'This interaction is not available at the current location.',
|
||||
);
|
||||
}
|
||||
@@ -9,10 +9,122 @@ 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 type {
|
||||
LocationPointOfInterestContent,
|
||||
LocationPrimaryActionContent,
|
||||
} from './local-location.types';
|
||||
import { WorldService } from './world.service';
|
||||
|
||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
||||
|
||||
const SOUTH_GATE_POIS: LocationPointOfInterestContent[] = [
|
||||
{
|
||||
key: 'gate-watch',
|
||||
title: 'Torwache',
|
||||
actionLabel: 'Sprechen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 45,
|
||||
yPercent: 52,
|
||||
enabled: true,
|
||||
resultTitle: 'Torwache',
|
||||
resultText: 'Geheimer Servertext.',
|
||||
},
|
||||
];
|
||||
|
||||
const BURNED_ROAD_POIS: LocationPointOfInterestContent[] = [
|
||||
{
|
||||
key: 'hunt-area',
|
||||
title: 'Jagdgebiet',
|
||||
actionLabel: 'Jagd beginnen',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
xPercent: 52,
|
||||
yPercent: 44,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
actionLabel: 'Untersuchen',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
xPercent: 32,
|
||||
yPercent: 78,
|
||||
enabled: true,
|
||||
resultTitle: 'Verdächtige Spuren',
|
||||
resultText: 'Frische Stiefelabdrücke führen nach Osten.',
|
||||
},
|
||||
{
|
||||
key: 'sealed-crypt',
|
||||
title: 'Versiegelte Krypta',
|
||||
actionLabel: 'Öffnen',
|
||||
type: 'DUNGEON',
|
||||
iconKey: 'search',
|
||||
xPercent: 90,
|
||||
yPercent: 20,
|
||||
enabled: false,
|
||||
resultTitle: 'Versiegelte Krypta',
|
||||
resultText: 'Noch verschlossen.',
|
||||
},
|
||||
];
|
||||
|
||||
const BURNED_ROAD_ACTIONS: LocationPrimaryActionContent[] = [
|
||||
{
|
||||
key: 'start-hunt',
|
||||
label: 'Jagd beginnen',
|
||||
description: 'Im Gebiet jagen',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'investigate-tracks',
|
||||
label: 'Spuren untersuchen',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
enabled: true,
|
||||
poiKey: 'inspect-tracks',
|
||||
},
|
||||
];
|
||||
|
||||
function character(locationId: string, location: LocationDefinition) {
|
||||
return {
|
||||
id: CHARACTER_ID,
|
||||
baseAttack: 6,
|
||||
baseHp: 100,
|
||||
currentLocationId: locationId,
|
||||
currentLocation: location,
|
||||
};
|
||||
}
|
||||
|
||||
function poolEntry(
|
||||
name: string,
|
||||
weight: number,
|
||||
stats: { level: number; attack: number; armor: number; maxHp: number },
|
||||
) {
|
||||
return {
|
||||
weight,
|
||||
monster: {
|
||||
key: name.toLowerCase(),
|
||||
name,
|
||||
level: stats.level,
|
||||
attack: stats.attack,
|
||||
armor: stats.armor,
|
||||
maxHp: stats.maxHp,
|
||||
iconPath: `/images/monsters/icons/${name.toLowerCase()}-128.png`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Ash rats are common and harmless, bandits rare and dangerous. Judged by its
|
||||
// worst entry this pool reads STRONG; judged by what a traveller actually
|
||||
// meets it reads MATCH.
|
||||
const BURNED_ROAD_POOL = [
|
||||
poolEntry('Aschenratte', 70, { level: 1, attack: 5, armor: 0, maxHp: 45 }),
|
||||
poolEntry('Straßenräuber', 30, { level: 2, attack: 9, armor: 5, maxHp: 75 }),
|
||||
];
|
||||
|
||||
function currentLocation(): LocationDefinition {
|
||||
return {
|
||||
id: SOUTH_GATE_ID,
|
||||
@@ -27,6 +139,14 @@ function currentLocation(): LocationDefinition {
|
||||
isSafe: true,
|
||||
huntingEnabled: false,
|
||||
artworkPath: '/assets/locations/south-gate.webp',
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'TRANSITION',
|
||||
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
|
||||
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||
localPointsOfInterest: SOUTH_GATE_POIS,
|
||||
localPrimaryActions: [],
|
||||
localRewardPreview: [],
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
characters: [],
|
||||
@@ -48,6 +168,14 @@ function burnedRoad(): LocationDefinition {
|
||||
isSafe: false,
|
||||
huntingEnabled: true,
|
||||
artworkPath: '/assets/locations/burned-road.webp',
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'HUNTING_GROUND',
|
||||
localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
|
||||
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||
localPointsOfInterest: BURNED_ROAD_POIS,
|
||||
localPrimaryActions: BURNED_ROAD_ACTIONS,
|
||||
localRewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
|
||||
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||
characters: [],
|
||||
@@ -69,11 +197,7 @@ describe('WorldService', () => {
|
||||
} as unknown as TravelService;
|
||||
const findCharacter = jest.fn().mockImplementation(() => {
|
||||
callOrder.push('findCharacter');
|
||||
return Promise.resolve({
|
||||
id: CHARACTER_ID,
|
||||
currentLocationId: SOUTH_GATE_ID,
|
||||
currentLocation: location,
|
||||
});
|
||||
return Promise.resolve(character(SOUTH_GATE_ID, location));
|
||||
});
|
||||
const characters = {
|
||||
findOne: findCharacter,
|
||||
@@ -130,6 +254,28 @@ describe('WorldService', () => {
|
||||
isSafe: true,
|
||||
huntingEnabled: false,
|
||||
artworkPath: '/assets/locations/south-gate.webp',
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'TRANSITION',
|
||||
localDescription: 'Hinter den Wachtfeuern beginnen die Aschenfelder.',
|
||||
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||
dangerRating: null,
|
||||
recommendationLabel: '1',
|
||||
pointsOfInterest: [
|
||||
{
|
||||
key: 'gate-watch',
|
||||
title: 'Torwache',
|
||||
actionLabel: 'Sprechen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 45,
|
||||
yPercent: 52,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
primaryActions: [],
|
||||
encounterPreview: [],
|
||||
rewardPreview: [],
|
||||
connections: [
|
||||
{
|
||||
targetLocation: {
|
||||
@@ -160,21 +306,12 @@ describe('WorldService', () => {
|
||||
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||
} as unknown as TravelService;
|
||||
const characters = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: CHARACTER_ID,
|
||||
currentLocationId: BURNED_ROAD_ID,
|
||||
currentLocation: location,
|
||||
}),
|
||||
findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
|
||||
} as unknown as Repository<Character>;
|
||||
const connections = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as Repository<LocationConnection>;
|
||||
const findLocationMonsters = jest
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ monster: { name: 'Aschenratte' } },
|
||||
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
|
||||
]);
|
||||
const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
|
||||
const locationMonsters = {
|
||||
find: findLocationMonsters,
|
||||
} as unknown as Repository<LocationMonster>;
|
||||
@@ -230,4 +367,131 @@ describe('WorldService', () => {
|
||||
expect(findConnections).not.toHaveBeenCalled();
|
||||
expect(findLocationMonsters).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exposes the authored local view content of the current location', async () => {
|
||||
const result = await loadBurnedRoad();
|
||||
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'HUNTING_GROUND',
|
||||
localDescription: 'Ein alter Handelsweg, in Asche gelegt.',
|
||||
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||
recommendationLabel: '1–2',
|
||||
rewardPreview: [{ key: 'silver', label: 'Silber', iconKey: 'silver' }],
|
||||
}),
|
||||
);
|
||||
expect(result.primaryActions).toEqual(BURNED_ROAD_ACTIONS);
|
||||
});
|
||||
|
||||
it('renders a single recommended level without a range', async () => {
|
||||
const result = await loadSouthGate();
|
||||
|
||||
expect(result.recommendationLabel).toBe('1');
|
||||
});
|
||||
|
||||
it('serves every point of interest, including disabled ones, without leaking its result text', async () => {
|
||||
const result = await loadBurnedRoad();
|
||||
|
||||
expect(result.pointsOfInterest).toEqual([
|
||||
{
|
||||
key: 'hunt-area',
|
||||
title: 'Jagdgebiet',
|
||||
actionLabel: 'Jagd beginnen',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
xPercent: 52,
|
||||
yPercent: 44,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'inspect-tracks',
|
||||
title: 'Verdächtige Spuren',
|
||||
actionLabel: 'Untersuchen',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
xPercent: 32,
|
||||
yPercent: 78,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'sealed-crypt',
|
||||
title: 'Versiegelte Krypta',
|
||||
actionLabel: 'Öffnen',
|
||||
type: 'DUNGEON',
|
||||
iconKey: 'search',
|
||||
xPercent: 90,
|
||||
yPercent: 20,
|
||||
enabled: false,
|
||||
},
|
||||
]);
|
||||
expect(JSON.stringify(result)).not.toContain('Frische Stiefelabdrücke');
|
||||
expect(JSON.stringify(result)).not.toContain('Noch verschlossen');
|
||||
});
|
||||
|
||||
it('derives the encounter preview from the location monster pool', async () => {
|
||||
const result = await loadBurnedRoad();
|
||||
|
||||
expect(result.encounterPreview).toEqual([
|
||||
{
|
||||
key: 'aschenratte',
|
||||
name: 'Aschenratte',
|
||||
level: 1,
|
||||
iconPath: '/images/monsters/icons/aschenratte-128.png',
|
||||
},
|
||||
{
|
||||
key: 'straßenräuber',
|
||||
name: 'Straßenräuber',
|
||||
level: 2,
|
||||
iconPath: '/images/monsters/icons/straßenräuber-128.png',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rates local danger from the weighted pool average rather than its worst entry', async () => {
|
||||
const result = await loadBurnedRoad();
|
||||
|
||||
// The lone bandit rates STRONG against this character; weighted by how
|
||||
// rarely it appears, the road as a whole is a fair match.
|
||||
expect(result.dangerRating).toBe('MATCH');
|
||||
});
|
||||
|
||||
it('reports no danger rating where nothing can be hunted', async () => {
|
||||
const result = await loadSouthGate();
|
||||
|
||||
expect(result.dangerRating).toBeNull();
|
||||
expect(result.encounterPreview).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadBurnedRoad() {
|
||||
return loadLocation(burnedRoad(), BURNED_ROAD_ID, BURNED_ROAD_POOL);
|
||||
}
|
||||
|
||||
async function loadSouthGate() {
|
||||
return loadLocation(currentLocation(), SOUTH_GATE_ID, []);
|
||||
}
|
||||
|
||||
async function loadLocation(
|
||||
location: LocationDefinition,
|
||||
locationId: string,
|
||||
pool: ReturnType<typeof poolEntry>[],
|
||||
) {
|
||||
const service = new WorldService(
|
||||
{
|
||||
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||
} as unknown as TravelService,
|
||||
{
|
||||
findOne: jest.fn().mockResolvedValue(character(locationId, location)),
|
||||
} as unknown as Repository<Character>,
|
||||
{
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
} as unknown as Repository<LocationConnection>,
|
||||
{
|
||||
find: jest.fn().mockResolvedValue(pool),
|
||||
} as unknown as Repository<LocationMonster>,
|
||||
);
|
||||
|
||||
return service.getCurrentLocation(CHARACTER_ID);
|
||||
}
|
||||
|
||||
@@ -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