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; 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; const findLocationMonsters = jest.fn(); const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; 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; const connections = { find: jest.fn().mockResolvedValue([]), } as unknown as Repository; const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL); const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; 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; const findConnections = jest.fn(); const connections = { find: findConnections, } as unknown as Repository; const findLocationMonsters = jest.fn(); const locationMonsters = { find: findLocationMonsters, } as unknown as Repository; 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: '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[], ) { 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, { find: jest.fn().mockResolvedValue([]), } as unknown as Repository, { find: jest.fn().mockResolvedValue(pool), } as unknown as Repository, ); return service.getCurrentLocation(CHARACTER_ID); }