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:
@@ -0,0 +1,82 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* Adds the local location view content to `location_definitions`.
|
||||
*
|
||||
* The existing `description`/`artwork_path` columns are deliberately left
|
||||
* alone — the map and hunt screens still render them. Backfill defaults keep
|
||||
* existing rows valid; the seed replaces them with authored content.
|
||||
*/
|
||||
export class CreateLocalLocationView1788600000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "region_name" character varying(150) NOT NULL DEFAULT \'\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "region_tier_label" character varying(50) NOT NULL DEFAULT \'\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "location_type" character varying(50) NOT NULL DEFAULT \'TRANSITION\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "local_description" text NOT NULL DEFAULT \'\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "local_artwork_path" character varying(255) NOT NULL DEFAULT \'\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "local_points_of_interest" jsonb NOT NULL DEFAULT \'[]\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "local_primary_actions" jsonb NOT NULL DEFAULT \'[]\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" ADD COLUMN "local_reward_preview" jsonb NOT NULL DEFAULT \'[]\'',
|
||||
);
|
||||
|
||||
// Existing rows fall back to their map-level content until the seed runs,
|
||||
// so the view never renders an empty breadcrumb or a missing artwork.
|
||||
await queryRunner.query(
|
||||
'UPDATE "location_definitions" SET "region_name" = "region_key", "local_description" = "description", "local_artwork_path" = "artwork_path" WHERE "region_name" = \'\'',
|
||||
);
|
||||
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path" character varying(255) NOT NULL DEFAULT \'\'',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'UPDATE "monster_definitions" SET "icon_path" = "artwork_path" WHERE "icon_path" = \'\'',
|
||||
);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "local_reward_preview"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "local_primary_actions"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "local_points_of_interest"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "local_artwork_path"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "local_description"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "location_type"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "region_tier_label"',
|
||||
);
|
||||
await queryRunner.query(
|
||||
'ALTER TABLE "location_definitions" DROP COLUMN "region_name"',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import 'reflect-metadata';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { getMetadataArgsStorage } from 'typeorm';
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
|
||||
const MIGRATION_SQL = readFileSync(
|
||||
join(__dirname, '1788600000000-CreateLocalLocationView.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
function columnNames(target: unknown): string[] {
|
||||
return getMetadataArgsStorage()
|
||||
.columns.filter((column) => column.target === target)
|
||||
.map((column) => column.options.name)
|
||||
.filter((name): name is string => typeof name === 'string');
|
||||
}
|
||||
|
||||
describe('local location view schema', () => {
|
||||
const newLocationColumns = [
|
||||
'region_name',
|
||||
'region_tier_label',
|
||||
'location_type',
|
||||
'local_description',
|
||||
'local_artwork_path',
|
||||
'local_points_of_interest',
|
||||
'local_primary_actions',
|
||||
'local_reward_preview',
|
||||
];
|
||||
|
||||
it.each(newLocationColumns)(
|
||||
'maps %s on LocationDefinition',
|
||||
(name: string) => {
|
||||
expect(columnNames(LocationDefinition)).toContain(name);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(newLocationColumns)('adds %s in the migration', (name: string) => {
|
||||
expect(MIGRATION_SQL).toContain(
|
||||
`ALTER TABLE "location_definitions" ADD COLUMN "${name}"`,
|
||||
);
|
||||
expect(MIGRATION_SQL).toContain(
|
||||
`ALTER TABLE "location_definitions" DROP COLUMN "${name}"`,
|
||||
);
|
||||
});
|
||||
|
||||
it('adds the monster icon path in both the entity and the migration', () => {
|
||||
expect(columnNames(MonsterDefinition)).toContain('icon_path');
|
||||
expect(MIGRATION_SQL).toContain(
|
||||
'ALTER TABLE "monster_definitions" ADD COLUMN "icon_path"',
|
||||
);
|
||||
expect(MIGRATION_SQL).toContain(
|
||||
'ALTER TABLE "monster_definitions" DROP COLUMN "icon_path"',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the map-level columns untouched so the world screen is unaffected', () => {
|
||||
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "description"');
|
||||
expect(MIGRATION_SQL).not.toContain('DROP COLUMN "artwork_path"');
|
||||
expect(columnNames(LocationDefinition)).toEqual(
|
||||
expect.arrayContaining(['description', 'artwork_path', 'region_key']),
|
||||
);
|
||||
});
|
||||
|
||||
it('backfills existing rows so no location renders an empty breadcrumb', () => {
|
||||
expect(MIGRATION_SQL).toContain(
|
||||
'UPDATE "location_definitions" SET "region_name" = "region_key"',
|
||||
);
|
||||
});
|
||||
});
|
||||
204
apps/api/src/database/seeds/local-location.content.ts
Normal file
204
apps/api/src/database/seeds/local-location.content.ts
Normal file
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
LocationPointOfInterestContent,
|
||||
LocationPrimaryActionContent,
|
||||
LocationRewardPreviewContent,
|
||||
LocationType,
|
||||
} from '../../world/local-location.types';
|
||||
|
||||
/**
|
||||
* Authored local-view content per location (plan §7–§11).
|
||||
*
|
||||
* Coordinates are percentages of the artwork box, so a hotspot stays on the
|
||||
* same painted detail at every viewport width. They are tuned against the real
|
||||
* artwork in `apps/web/public/images/backgrounds/`, not against the
|
||||
* composition mockup in `docs/references/`.
|
||||
*/
|
||||
export interface LocalLocationContent {
|
||||
regionName: string;
|
||||
regionTierLabel: string;
|
||||
locationType: LocationType;
|
||||
localDescription: string;
|
||||
localArtworkPath: string;
|
||||
localPointsOfInterest: LocationPointOfInterestContent[];
|
||||
localPrimaryActions: LocationPrimaryActionContent[];
|
||||
localRewardPreview: LocationRewardPreviewContent[];
|
||||
}
|
||||
|
||||
export const BURNED_ROAD_LOCAL_CONTENT: LocalLocationContent = {
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'HUNTING_GROUND',
|
||||
localDescription:
|
||||
'Ein alter Handelsweg, der durch Feuer und Krieg in Asche gelegt wurde. Verbrannte Karren, zerbrochene Waffen und verstummte Schreie säumen den Pfad in die Aschenfelder.',
|
||||
localArtworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||
localPointsOfInterest: [
|
||||
{
|
||||
key: 'hunt-area',
|
||||
title: 'Jagdgebiet',
|
||||
actionLabel: 'Jagd beginnen',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
xPercent: 52,
|
||||
yPercent: 44,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'wounded-scout',
|
||||
title: 'Verwundeter Kundschafter',
|
||||
actionLabel: 'Sprechen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 20,
|
||||
yPercent: 60,
|
||||
enabled: true,
|
||||
resultTitle: 'Verwundeter Kundschafter',
|
||||
resultText:
|
||||
'„Die Straße ist nicht mehr sicher. Die Plünderer kommen aus Richtung des alten Wachtpostens. Wenn du weitergehst, halte die Augen offen."',
|
||||
},
|
||||
{
|
||||
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. Sie führen nach Osten, in Richtung des verlassenen Wachtpostens.',
|
||||
},
|
||||
{
|
||||
key: 'search-abandoned-wagon',
|
||||
title: 'Verlassener Wagen',
|
||||
actionLabel: 'Durchsuchen',
|
||||
type: 'SEARCH',
|
||||
iconKey: 'search',
|
||||
xPercent: 80,
|
||||
yPercent: 68,
|
||||
enabled: true,
|
||||
resultTitle: 'Verlassener Wagen',
|
||||
resultText:
|
||||
'Der Wagen wurde gründlich geplündert. Zwischen verbrannten Brettern findest du nur leere Kisten und Spuren eines hastigen Aufbruchs.',
|
||||
},
|
||||
],
|
||||
localPrimaryActions: [
|
||||
{
|
||||
key: 'start-hunt',
|
||||
label: 'Jagd beginnen',
|
||||
description: 'Im Gebiet jagen',
|
||||
type: 'HUNT',
|
||||
iconKey: 'hunt',
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'investigate-tracks',
|
||||
label: 'Spuren untersuchen',
|
||||
description: 'Hinweise finden',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
enabled: true,
|
||||
poiKey: 'inspect-tracks',
|
||||
},
|
||||
{
|
||||
key: 'search-surroundings',
|
||||
label: 'Umgebung durchsuchen',
|
||||
description: 'Beute finden',
|
||||
type: 'SEARCH',
|
||||
iconKey: 'search',
|
||||
enabled: true,
|
||||
poiKey: 'search-abandoned-wagon',
|
||||
},
|
||||
{
|
||||
key: 'open-map',
|
||||
label: 'Zur Karte',
|
||||
description: 'Gebiet wechseln',
|
||||
type: 'MAP',
|
||||
iconKey: 'map',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
// Only what the game actually grants today. Combat hands out silver and
|
||||
// experience; there is no loot system yet, so nothing else is promised
|
||||
// (spec §8, "Mögliche Belohnungen").
|
||||
localRewardPreview: [
|
||||
{ key: 'silver', label: 'Silber', iconKey: 'silver' },
|
||||
{ key: 'experience', label: 'Erfahrung', iconKey: 'experience' },
|
||||
],
|
||||
};
|
||||
|
||||
export const SOUTH_GATE_LOCAL_CONTENT: LocalLocationContent = {
|
||||
regionName: 'Aschenfelder',
|
||||
regionTierLabel: 'Gebiet 1',
|
||||
locationType: 'TRANSITION',
|
||||
localDescription:
|
||||
'Am schwarzen Südtor endet der Schutz Graufurts. Hinter den Wachtfeuern beginnt die stille Weite der Aschenfelder.',
|
||||
localArtworkPath: '/images/backgrounds/Suedtor.png',
|
||||
localPointsOfInterest: [
|
||||
{
|
||||
key: 'gate-notice',
|
||||
title: 'Aushangtafel',
|
||||
actionLabel: 'Lesen',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
xPercent: 22,
|
||||
yPercent: 42,
|
||||
enabled: true,
|
||||
resultTitle: 'Aushangtafel',
|
||||
resultText:
|
||||
'Verwitterte Anschläge flattern im Wind. Ein frischer Zettel warnt vor Plünderern auf der Verbrannten Straße und verspricht Silber für jeden erlegten Räuber.',
|
||||
},
|
||||
{
|
||||
key: 'gate-watch',
|
||||
title: 'Torwache',
|
||||
actionLabel: 'Sprechen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
xPercent: 45,
|
||||
yPercent: 52,
|
||||
enabled: true,
|
||||
resultTitle: 'Torwache',
|
||||
resultText:
|
||||
'„Hinter dem Tor endet Graufurts Schutz. Wer nach Süden geht, geht auf eigene Gefahr — und kommt selten so zurück, wie er gegangen ist."',
|
||||
},
|
||||
{
|
||||
key: 'south-road',
|
||||
title: 'Straße nach Süden',
|
||||
actionLabel: 'Zur Karte',
|
||||
type: 'MAP',
|
||||
iconKey: 'map',
|
||||
xPercent: 66,
|
||||
yPercent: 72,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
localPrimaryActions: [
|
||||
{
|
||||
key: 'talk-to-watch',
|
||||
label: 'Wache ansprechen',
|
||||
description: 'Lage erfragen',
|
||||
type: 'NPC',
|
||||
iconKey: 'speak',
|
||||
enabled: true,
|
||||
poiKey: 'gate-watch',
|
||||
},
|
||||
{
|
||||
key: 'read-notice',
|
||||
label: 'Aushang lesen',
|
||||
description: 'Hinweise finden',
|
||||
type: 'INVESTIGATE',
|
||||
iconKey: 'investigate',
|
||||
enabled: true,
|
||||
poiKey: 'gate-notice',
|
||||
},
|
||||
{
|
||||
key: 'open-map',
|
||||
label: 'Zur Karte',
|
||||
description: 'Gebiet wechseln',
|
||||
type: 'MAP',
|
||||
iconKey: 'map',
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
localRewardPreview: [],
|
||||
};
|
||||
@@ -2,3 +2,5 @@ export const SOUTH_GATE_ID = '20000000-0000-4000-8000-000000000001';
|
||||
export const BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
||||
export const WILD_ROAD_DOG_MONSTER_ID = '30000000-0000-4000-8000-000000000003';
|
||||
export const CHARRED_LOOTER_MONSTER_ID = '30000000-0000-4000-8000-000000000004';
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -6,11 +6,17 @@ import { LocationMonster } from '../../monsters/entities/location-monster.entity
|
||||
import { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||
import {
|
||||
BURNED_ROAD_LOCAL_CONTENT,
|
||||
SOUTH_GATE_LOCAL_CONTENT,
|
||||
} from './local-location.content';
|
||||
import {
|
||||
ASH_RAT_MONSTER_ID,
|
||||
BURNED_ROAD_ID,
|
||||
CHARRED_LOOTER_MONSTER_ID,
|
||||
ROAD_BANDIT_MONSTER_ID,
|
||||
SOUTH_GATE_ID,
|
||||
WILD_ROAD_DOG_MONSTER_ID,
|
||||
} from './vertical-slice.constants';
|
||||
|
||||
export async function seedVisibleVerticalSlice(
|
||||
@@ -36,6 +42,7 @@ export async function seedVisibleVerticalSlice(
|
||||
isSafe: true,
|
||||
huntingEnabled: false,
|
||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||
...SOUTH_GATE_LOCAL_CONTENT,
|
||||
},
|
||||
{
|
||||
id: BURNED_ROAD_ID,
|
||||
@@ -50,17 +57,14 @@ export async function seedVisibleVerticalSlice(
|
||||
isSafe: false,
|
||||
huntingEnabled: true,
|
||||
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
||||
...BURNED_ROAD_LOCAL_CONTENT,
|
||||
},
|
||||
];
|
||||
let southGateId = SOUTH_GATE_ID;
|
||||
let burnedRoadId = BURNED_ROAD_ID;
|
||||
|
||||
const locationIds = new Map<string, string>();
|
||||
for (const location of locations) {
|
||||
const existing = await locationRepository.findOneBy({
|
||||
key: location.key,
|
||||
});
|
||||
const existing = await locationRepository.findOneBy({ key: location.key });
|
||||
const { id, key, ...definition } = location;
|
||||
const persistedId = existing?.id ?? id;
|
||||
|
||||
if (existing) {
|
||||
await locationRepository.update(existing.id, definition);
|
||||
@@ -68,13 +72,12 @@ export async function seedVisibleVerticalSlice(
|
||||
await locationRepository.insert(location);
|
||||
}
|
||||
|
||||
if (key === 'south-gate') {
|
||||
southGateId = persistedId;
|
||||
} else {
|
||||
burnedRoadId = persistedId;
|
||||
}
|
||||
locationIds.set(key, existing?.id ?? id);
|
||||
}
|
||||
|
||||
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
|
||||
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
|
||||
|
||||
await connectionRepository.upsert(
|
||||
[
|
||||
{
|
||||
@@ -108,6 +111,21 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMin: 4,
|
||||
silverMax: 7,
|
||||
artworkPath: '/images/monsters/ash-rat.png',
|
||||
iconPath: '/images/monsters/icons/ash-rat-128.png',
|
||||
},
|
||||
{
|
||||
id: WILD_ROAD_DOG_MONSTER_ID,
|
||||
key: 'wild-road-dog',
|
||||
name: 'Verwilderter Straßenhund',
|
||||
level: 1,
|
||||
maxHp: 55,
|
||||
attack: 7,
|
||||
armor: 0,
|
||||
experienceReward: 11,
|
||||
silverMin: 5,
|
||||
silverMax: 9,
|
||||
artworkPath: '/images/monsters/wild-road-dog.png',
|
||||
iconPath: '/images/monsters/icons/wild-road-dog-128.png',
|
||||
},
|
||||
{
|
||||
id: ROAD_BANDIT_MONSTER_ID,
|
||||
@@ -121,17 +139,30 @@ export async function seedVisibleVerticalSlice(
|
||||
silverMin: 9,
|
||||
silverMax: 15,
|
||||
artworkPath: '/images/monsters/road-bandit.png',
|
||||
iconPath: '/images/monsters/icons/road-bandit-128.png',
|
||||
},
|
||||
{
|
||||
id: CHARRED_LOOTER_MONSTER_ID,
|
||||
key: 'charred-looter',
|
||||
name: 'Verkohlter Plünderer',
|
||||
level: 2,
|
||||
maxHp: 85,
|
||||
attack: 11,
|
||||
armor: 6,
|
||||
experienceReward: 20,
|
||||
silverMin: 12,
|
||||
silverMax: 19,
|
||||
artworkPath: '/images/monsters/charred-looter.png',
|
||||
iconPath: '/images/monsters/icons/charred-looter-128.png',
|
||||
},
|
||||
];
|
||||
let ashRatId = ASH_RAT_MONSTER_ID;
|
||||
let roadBanditId = ROAD_BANDIT_MONSTER_ID;
|
||||
|
||||
const monsterIds = new Map<string, string>();
|
||||
for (const monster of monsters) {
|
||||
const existingMonster = await monsterRepository.findOneBy({
|
||||
key: monster.key,
|
||||
});
|
||||
const { id, key, ...definition } = monster;
|
||||
const persistedId = existingMonster?.id ?? id;
|
||||
|
||||
if (existingMonster) {
|
||||
await monsterRepository.update(existingMonster.id, definition);
|
||||
@@ -139,30 +170,27 @@ export async function seedVisibleVerticalSlice(
|
||||
await monsterRepository.insert(monster);
|
||||
}
|
||||
|
||||
if (key === 'ash-rat') {
|
||||
ashRatId = persistedId;
|
||||
} else {
|
||||
roadBanditId = persistedId;
|
||||
}
|
||||
monsterIds.set(key, existingMonster?.id ?? id);
|
||||
}
|
||||
|
||||
// Weights read as "how often you meet this on the road". They also drive the
|
||||
// location's danger rating, which is computed from the weighted average of
|
||||
// the pool rather than from its single worst entry.
|
||||
const encounterWeights: Readonly<Record<string, number>> = {
|
||||
'ash-rat': 40,
|
||||
'wild-road-dog': 30,
|
||||
'road-bandit': 20,
|
||||
'charred-looter': 10,
|
||||
};
|
||||
|
||||
await locationMonsterRepository.upsert(
|
||||
[
|
||||
{
|
||||
locationId: burnedRoadId,
|
||||
monsterId: ashRatId,
|
||||
weight: 70,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
locationId: burnedRoadId,
|
||||
monsterId: roadBanditId,
|
||||
weight: 30,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
enabled: true,
|
||||
},
|
||||
],
|
||||
Object.entries(encounterWeights).map(([key, weight]) => ({
|
||||
locationId: burnedRoadId,
|
||||
monsterId: monsterIds.get(key) as string,
|
||||
weight,
|
||||
encounterType: EncounterType.NORMAL,
|
||||
enabled: true,
|
||||
})),
|
||||
['locationId', 'monsterId'],
|
||||
);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user