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:
@@ -13,6 +13,8 @@ const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||
const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
||||
const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||
const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
|
||||
|
||||
class InMemoryRepository {
|
||||
readonly rows: Row[] = [];
|
||||
@@ -153,8 +155,8 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
|
||||
expect(monsterRepository.rows).toHaveLength(2);
|
||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
|
||||
expect(monsterRepository.rows).toHaveLength(4);
|
||||
expect(monsterRepository.rows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
@@ -181,6 +183,20 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
silverMax: 15,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'wild-road-dog',
|
||||
name: 'Verwilderter Straßenhund',
|
||||
level: 1,
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/monsters/icons/wild-road-dog-128.png',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: 'charred-looter',
|
||||
name: 'Verkohlter Plünderer',
|
||||
level: 2,
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/monsters/icons/charred-looter-128.png',
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -189,17 +205,114 @@ describe('seedVisibleVerticalSlice', () => {
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: ASH_RAT_MONSTER_ID,
|
||||
weight: 70,
|
||||
weight: 40,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: WILD_ROAD_DOG_MONSTER_ID,
|
||||
weight: 30,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: ROAD_BANDIT_MONSTER_ID,
|
||||
weight: 30,
|
||||
weight: 20,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
locationId: BURNED_ROAD_ID,
|
||||
monsterId: CHARRED_LOOTER_MONSTER_ID,
|
||||
weight: 10,
|
||||
}),
|
||||
]),
|
||||
['locationId', 'monsterId'],
|
||||
);
|
||||
expect(locationMonsterRepository.rows).toHaveLength(2);
|
||||
expect(locationMonsterRepository.rows).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('seeds the local view content of the Verbrannte Straße with four points of interest', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
const burnedRoad = locationRepository.rows.find(
|
||||
(row) => row.key === 'burned-road',
|
||||
) as Row;
|
||||
|
||||
expect(burnedRoad).toEqual(
|
||||
expect.objectContaining({
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'HUNTING_GROUND',
|
||||
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||
}),
|
||||
);
|
||||
expect(burnedRoad.localDescription).toContain(
|
||||
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde.',
|
||||
);
|
||||
|
||||
const pointsOfInterest = burnedRoad.localPointsOfInterest as {
|
||||
key: string;
|
||||
type: string;
|
||||
}[];
|
||||
expect(pointsOfInterest.map((poi) => poi.key)).toEqual([
|
||||
'hunt-area',
|
||||
'wounded-scout',
|
||||
'inspect-tracks',
|
||||
'search-abandoned-wagon',
|
||||
]);
|
||||
expect(pointsOfInterest.map((poi) => poi.type)).toEqual([
|
||||
'HUNT',
|
||||
'NPC',
|
||||
'INVESTIGATE',
|
||||
'SEARCH',
|
||||
]);
|
||||
|
||||
const primaryActions = burnedRoad.localPrimaryActions as {
|
||||
label: string;
|
||||
}[];
|
||||
expect(primaryActions.map((action) => action.label)).toEqual([
|
||||
'Jagd beginnen',
|
||||
'Spuren untersuchen',
|
||||
'Umgebung durchsuchen',
|
||||
'Zur Karte',
|
||||
]);
|
||||
});
|
||||
|
||||
it('gives the Südtor its own local content so a second location needs no new component', async () => {
|
||||
const locationRepository = new InMemoryRepository();
|
||||
const dataSource = createDataSource(
|
||||
locationRepository,
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
new InMemoryRepository(),
|
||||
);
|
||||
|
||||
await seedVisibleVerticalSlice(dataSource);
|
||||
|
||||
const southGate = locationRepository.rows.find(
|
||||
(row) => row.key === 'south-gate',
|
||||
) as Row;
|
||||
|
||||
expect(southGate).toEqual(
|
||||
expect.objectContaining({
|
||||
locationType: 'TRANSITION',
|
||||
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||
}),
|
||||
);
|
||||
expect(southGate.localPointsOfInterest).toHaveLength(3);
|
||||
// A transition location offers no hunt, so no HUNT hotspot may appear.
|
||||
expect(
|
||||
(southGate.localPointsOfInterest as { type: string }[]).some(
|
||||
(poi) => poi.type === 'HUNT',
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves existing location IDs and uses them for the directed connections', async () => {
|
||||
|
||||
Reference in New Issue
Block a user