Files
ashen-realms/apps/api/src/world/world.service.spec.ts
Bastian Wagner c85a70484f 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>
2026-08-20 10:30:01 +02:00

498 lines
15 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 { NotFoundException } from '@nestjs/common';
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,
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,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
description:
'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
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: [],
outgoingConnections: [],
incomingConnections: [],
};
}
function burnedRoad(): LocationDefinition {
return {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
description: 'Eine verbrannte Handelsroute durch die Aschenfelder.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 2,
dangerLevel: 1,
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: [],
outgoingConnections: [],
incomingConnections: [],
};
}
describe('WorldService', () => {
it('returns the authoritative current location and only enabled public connections', async () => {
const callOrder: string[] = [];
const location = currentLocation();
const completeTravelIfDue = jest.fn().mockImplementation(() => {
callOrder.push('completeTravelIfDue');
return Promise.resolve({ status: 'IDLE' });
});
const travelService = {
completeTravelIfDue,
} as unknown as TravelService;
const findCharacter = jest.fn().mockImplementation(() => {
callOrder.push('findCharacter');
return Promise.resolve(character(SOUTH_GATE_ID, location));
});
const characters = {
findOne: findCharacter,
} as unknown as Repository<Character>;
const findConnections = jest.fn().mockResolvedValue([
{
id: '30000000-0000-4000-8000-000000000001',
fromLocationId: SOUTH_GATE_ID,
toLocationId: BURNED_ROAD_ID,
travelDurationSeconds: 10,
ambushChance: '0.0500',
enabled: true,
fromLocation: location,
toLocation: burnedRoad(),
},
{
id: '30000000-0000-4000-8000-000000000002',
fromLocationId: SOUTH_GATE_ID,
toLocationId: '20000000-0000-4000-8000-000000000003',
travelDurationSeconds: 30,
ambushChance: '0.9000',
enabled: false,
fromLocation: location,
toLocation: burnedRoad(),
},
]);
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const findLocationMonsters = jest.fn();
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
const service = new WorldService(
travelService,
characters,
connections,
locationMonsters,
);
const result = await service.getCurrentLocation(CHARACTER_ID);
expect(callOrder).toEqual(['completeTravelIfDue', 'findCharacter']);
expect(result).toEqual({
id: SOUTH_GATE_ID,
key: 'south-gate',
name: 'S\u00fcdtor von Graufurt',
description:
'Das S\u00fcdtor ist der sichere Ausgangspunkt nach S\u00fcden.',
regionKey: 'ashen-fields',
minRecommendedLevel: 1,
maxRecommendedLevel: 1,
dangerLevel: 0,
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: {
id: BURNED_ROAD_ID,
key: 'burned-road',
name: 'Verbrannte Stra\u00dfe',
},
travelDurationSeconds: 10,
danger: 'LOW',
},
],
possibleMonsters: [],
});
expect(findCharacter).toHaveBeenCalledWith({
where: { id: CHARACTER_ID },
relations: { currentLocation: true },
});
expect(findConnections).toHaveBeenCalledWith({
where: { fromLocationId: SOUTH_GATE_ID, enabled: true },
relations: { toLocation: true },
});
expect(findLocationMonsters).not.toHaveBeenCalled();
});
it('returns the enabled monster pool by name, ordered by weight descending, when hunting is enabled', async () => {
const location = burnedRoad();
const travelService = {
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
} as unknown as TravelService;
const characters = {
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(BURNED_ROAD_POOL);
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
const service = new WorldService(
travelService,
characters,
connections,
locationMonsters,
);
const result = await service.getCurrentLocation(CHARACTER_ID);
expect(result.possibleMonsters).toEqual([
'Aschenratte',
'Stra\u00dfenr\u00e4uber',
]);
expect(findLocationMonsters).toHaveBeenCalledWith({
where: { locationId: BURNED_ROAD_ID, enabled: true },
relations: { monster: true },
order: { weight: 'DESC' },
});
});
it('reports a missing character after travel completion', async () => {
const completeTravelIfDue = jest.fn().mockResolvedValue({
status: 'IDLE',
});
const travelService = {
completeTravelIfDue,
} as unknown as TravelService;
const findCharacter = jest.fn().mockResolvedValue(null);
const characters = {
findOne: findCharacter,
} as unknown as Repository<Character>;
const findConnections = jest.fn();
const connections = {
find: findConnections,
} as unknown as Repository<LocationConnection>;
const findLocationMonsters = jest.fn();
const locationMonsters = {
find: findLocationMonsters,
} as unknown as Repository<LocationMonster>;
const service = new WorldService(
travelService,
characters,
connections,
locationMonsters,
);
await expect(
service.getCurrentLocation(CHARACTER_ID),
).rejects.toBeInstanceOf(NotFoundException);
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: '12',
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);
}