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>
@@ -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
@@ -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 BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
export const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
export const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
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 BURNED_ROAD_ID = '20000000-0000-4000-8000-000000000002';
|
||||||
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
const ASH_RAT_MONSTER_ID = '30000000-0000-4000-8000-000000000001';
|
||||||
const ROAD_BANDIT_MONSTER_ID = '30000000-0000-4000-8000-000000000002';
|
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 {
|
class InMemoryRepository {
|
||||||
readonly rows: Row[] = [];
|
readonly rows: Row[] = [];
|
||||||
@@ -153,8 +155,8 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(monsterRepository.insert).toHaveBeenCalledTimes(2);
|
expect(monsterRepository.insert).toHaveBeenCalledTimes(4);
|
||||||
expect(monsterRepository.rows).toHaveLength(2);
|
expect(monsterRepository.rows).toHaveLength(4);
|
||||||
expect(monsterRepository.rows).toEqual(
|
expect(monsterRepository.rows).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -181,6 +183,20 @@ describe('seedVisibleVerticalSlice', () => {
|
|||||||
silverMax: 15,
|
silverMax: 15,
|
||||||
artworkPath: '/images/monsters/road-bandit.png',
|
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({
|
expect.objectContaining({
|
||||||
locationId: BURNED_ROAD_ID,
|
locationId: BURNED_ROAD_ID,
|
||||||
monsterId: ASH_RAT_MONSTER_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({
|
expect.objectContaining({
|
||||||
locationId: BURNED_ROAD_ID,
|
locationId: BURNED_ROAD_ID,
|
||||||
monsterId: ROAD_BANDIT_MONSTER_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'],
|
['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 () => {
|
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 { MonsterDefinition } from '../../monsters/entities/monster-definition.entity';
|
||||||
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
import { LocationConnection } from '../../world/entities/location-connection.entity';
|
||||||
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
import { LocationDefinition } from '../../world/entities/location-definition.entity';
|
||||||
|
import {
|
||||||
|
BURNED_ROAD_LOCAL_CONTENT,
|
||||||
|
SOUTH_GATE_LOCAL_CONTENT,
|
||||||
|
} from './local-location.content';
|
||||||
import {
|
import {
|
||||||
ASH_RAT_MONSTER_ID,
|
ASH_RAT_MONSTER_ID,
|
||||||
BURNED_ROAD_ID,
|
BURNED_ROAD_ID,
|
||||||
|
CHARRED_LOOTER_MONSTER_ID,
|
||||||
ROAD_BANDIT_MONSTER_ID,
|
ROAD_BANDIT_MONSTER_ID,
|
||||||
SOUTH_GATE_ID,
|
SOUTH_GATE_ID,
|
||||||
|
WILD_ROAD_DOG_MONSTER_ID,
|
||||||
} from './vertical-slice.constants';
|
} from './vertical-slice.constants';
|
||||||
|
|
||||||
export async function seedVisibleVerticalSlice(
|
export async function seedVisibleVerticalSlice(
|
||||||
@@ -36,6 +42,7 @@ export async function seedVisibleVerticalSlice(
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/images/backgrounds/Suedtor.png',
|
artworkPath: '/images/backgrounds/Suedtor.png',
|
||||||
|
...SOUTH_GATE_LOCAL_CONTENT,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: BURNED_ROAD_ID,
|
id: BURNED_ROAD_ID,
|
||||||
@@ -50,17 +57,14 @@ export async function seedVisibleVerticalSlice(
|
|||||||
isSafe: false,
|
isSafe: false,
|
||||||
huntingEnabled: true,
|
huntingEnabled: true,
|
||||||
artworkPath: '/images/backgrounds/Aschestrasse.png',
|
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) {
|
for (const location of locations) {
|
||||||
const existing = await locationRepository.findOneBy({
|
const existing = await locationRepository.findOneBy({ key: location.key });
|
||||||
key: location.key,
|
|
||||||
});
|
|
||||||
const { id, key, ...definition } = location;
|
const { id, key, ...definition } = location;
|
||||||
const persistedId = existing?.id ?? id;
|
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await locationRepository.update(existing.id, definition);
|
await locationRepository.update(existing.id, definition);
|
||||||
@@ -68,13 +72,12 @@ export async function seedVisibleVerticalSlice(
|
|||||||
await locationRepository.insert(location);
|
await locationRepository.insert(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'south-gate') {
|
locationIds.set(key, existing?.id ?? id);
|
||||||
southGateId = persistedId;
|
|
||||||
} else {
|
|
||||||
burnedRoadId = persistedId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const southGateId = locationIds.get('south-gate') ?? SOUTH_GATE_ID;
|
||||||
|
const burnedRoadId = locationIds.get('burned-road') ?? BURNED_ROAD_ID;
|
||||||
|
|
||||||
await connectionRepository.upsert(
|
await connectionRepository.upsert(
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
@@ -108,6 +111,21 @@ export async function seedVisibleVerticalSlice(
|
|||||||
silverMin: 4,
|
silverMin: 4,
|
||||||
silverMax: 7,
|
silverMax: 7,
|
||||||
artworkPath: '/images/monsters/ash-rat.png',
|
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,
|
id: ROAD_BANDIT_MONSTER_ID,
|
||||||
@@ -121,17 +139,30 @@ export async function seedVisibleVerticalSlice(
|
|||||||
silverMin: 9,
|
silverMin: 9,
|
||||||
silverMax: 15,
|
silverMax: 15,
|
||||||
artworkPath: '/images/monsters/road-bandit.png',
|
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) {
|
for (const monster of monsters) {
|
||||||
const existingMonster = await monsterRepository.findOneBy({
|
const existingMonster = await monsterRepository.findOneBy({
|
||||||
key: monster.key,
|
key: monster.key,
|
||||||
});
|
});
|
||||||
const { id, key, ...definition } = monster;
|
const { id, key, ...definition } = monster;
|
||||||
const persistedId = existingMonster?.id ?? id;
|
|
||||||
|
|
||||||
if (existingMonster) {
|
if (existingMonster) {
|
||||||
await monsterRepository.update(existingMonster.id, definition);
|
await monsterRepository.update(existingMonster.id, definition);
|
||||||
@@ -139,30 +170,27 @@ export async function seedVisibleVerticalSlice(
|
|||||||
await monsterRepository.insert(monster);
|
await monsterRepository.insert(monster);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (key === 'ash-rat') {
|
monsterIds.set(key, existingMonster?.id ?? id);
|
||||||
ashRatId = persistedId;
|
|
||||||
} else {
|
|
||||||
roadBanditId = persistedId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(
|
await locationMonsterRepository.upsert(
|
||||||
[
|
Object.entries(encounterWeights).map(([key, weight]) => ({
|
||||||
{
|
locationId: burnedRoadId,
|
||||||
locationId: burnedRoadId,
|
monsterId: monsterIds.get(key) as string,
|
||||||
monsterId: ashRatId,
|
weight,
|
||||||
weight: 70,
|
encounterType: EncounterType.NORMAL,
|
||||||
encounterType: EncounterType.NORMAL,
|
enabled: true,
|
||||||
enabled: true,
|
})),
|
||||||
},
|
|
||||||
{
|
|
||||||
locationId: burnedRoadId,
|
|
||||||
monsterId: roadBanditId,
|
|
||||||
weight: 30,
|
|
||||||
encounterType: EncounterType.NORMAL,
|
|
||||||
enabled: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['locationId', 'monsterId'],
|
['locationId', 'monsterId'],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -43,6 +43,12 @@ export class MonsterDefinition {
|
|||||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||||
artworkPath!: string;
|
artworkPath!: string;
|
||||||
|
|
||||||
|
// Round medallion portrait used wherever a monster appears at icon size
|
||||||
|
// (encounter preview in the local location view). `artworkPath` stays the
|
||||||
|
// wide combat/hunt portrait.
|
||||||
|
@Column({ name: 'icon_path', type: 'varchar', length: 255 })
|
||||||
|
iconPath!: string;
|
||||||
|
|
||||||
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ import {
|
|||||||
UpdateDateColumn,
|
UpdateDateColumn,
|
||||||
} from 'typeorm';
|
} from 'typeorm';
|
||||||
import { Character } from '../../characters/entities/character.entity';
|
import { Character } from '../../characters/entities/character.entity';
|
||||||
|
import type {
|
||||||
|
LocationPointOfInterestContent,
|
||||||
|
LocationPrimaryActionContent,
|
||||||
|
LocationRewardPreviewContent,
|
||||||
|
LocationType,
|
||||||
|
} from '../local-location.types';
|
||||||
import { LocationConnection } from './location-connection.entity';
|
import { LocationConnection } from './location-connection.entity';
|
||||||
|
|
||||||
@Entity({ name: 'location_definitions' })
|
@Entity({ name: 'location_definitions' })
|
||||||
@@ -46,6 +52,35 @@ export class LocationDefinition {
|
|||||||
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
@Column({ name: 'artwork_path', type: 'varchar', length: 255 })
|
||||||
artworkPath!: string;
|
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' })
|
@CreateDateColumn({ name: 'created_at', type: 'timestamptz' })
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
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
@@ -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
@@ -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 { DEMO_CHARACTER_ID } from '../demo/demo-character.constants';
|
||||||
|
import { LocationInteractionResultDto } from './local-location.types';
|
||||||
import { WorldService } from './world.service';
|
import { WorldService } from './world.service';
|
||||||
|
|
||||||
@Controller('world')
|
@Controller('world')
|
||||||
@@ -10,4 +11,19 @@ export class WorldController {
|
|||||||
getCurrentLocation() {
|
getCurrentLocation() {
|
||||||
return this.worldService.getCurrentLocation(DEMO_CHARACTER_ID);
|
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
@@ -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 { TravelService } from '../travel/travel.service';
|
||||||
import { LocationConnection } from './entities/location-connection.entity';
|
import { LocationConnection } from './entities/location-connection.entity';
|
||||||
import { LocationDefinition } from './entities/location-definition.entity';
|
import { LocationDefinition } from './entities/location-definition.entity';
|
||||||
|
import type {
|
||||||
|
LocationPointOfInterestContent,
|
||||||
|
LocationPrimaryActionContent,
|
||||||
|
} from './local-location.types';
|
||||||
import { WorldService } from './world.service';
|
import { WorldService } from './world.service';
|
||||||
|
|
||||||
const CHARACTER_ID = '10000000-0000-4000-8000-000000000001';
|
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 {
|
function currentLocation(): LocationDefinition {
|
||||||
return {
|
return {
|
||||||
id: SOUTH_GATE_ID,
|
id: SOUTH_GATE_ID,
|
||||||
@@ -27,6 +139,14 @@ function currentLocation(): LocationDefinition {
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/assets/locations/south-gate.webp',
|
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'),
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
characters: [],
|
characters: [],
|
||||||
@@ -48,6 +168,14 @@ function burnedRoad(): LocationDefinition {
|
|||||||
isSafe: false,
|
isSafe: false,
|
||||||
huntingEnabled: true,
|
huntingEnabled: true,
|
||||||
artworkPath: '/assets/locations/burned-road.webp',
|
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'),
|
createdAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
updatedAt: new Date('2026-08-18T09:00:00.000Z'),
|
||||||
characters: [],
|
characters: [],
|
||||||
@@ -69,11 +197,7 @@ describe('WorldService', () => {
|
|||||||
} as unknown as TravelService;
|
} as unknown as TravelService;
|
||||||
const findCharacter = jest.fn().mockImplementation(() => {
|
const findCharacter = jest.fn().mockImplementation(() => {
|
||||||
callOrder.push('findCharacter');
|
callOrder.push('findCharacter');
|
||||||
return Promise.resolve({
|
return Promise.resolve(character(SOUTH_GATE_ID, location));
|
||||||
id: CHARACTER_ID,
|
|
||||||
currentLocationId: SOUTH_GATE_ID,
|
|
||||||
currentLocation: location,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
const characters = {
|
const characters = {
|
||||||
findOne: findCharacter,
|
findOne: findCharacter,
|
||||||
@@ -130,6 +254,28 @@ describe('WorldService', () => {
|
|||||||
isSafe: true,
|
isSafe: true,
|
||||||
huntingEnabled: false,
|
huntingEnabled: false,
|
||||||
artworkPath: '/assets/locations/south-gate.webp',
|
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: [
|
connections: [
|
||||||
{
|
{
|
||||||
targetLocation: {
|
targetLocation: {
|
||||||
@@ -160,21 +306,12 @@ describe('WorldService', () => {
|
|||||||
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
completeTravelIfDue: jest.fn().mockResolvedValue({ status: 'IDLE' }),
|
||||||
} as unknown as TravelService;
|
} as unknown as TravelService;
|
||||||
const characters = {
|
const characters = {
|
||||||
findOne: jest.fn().mockResolvedValue({
|
findOne: jest.fn().mockResolvedValue(character(BURNED_ROAD_ID, location)),
|
||||||
id: CHARACTER_ID,
|
|
||||||
currentLocationId: BURNED_ROAD_ID,
|
|
||||||
currentLocation: location,
|
|
||||||
}),
|
|
||||||
} as unknown as Repository<Character>;
|
} as unknown as Repository<Character>;
|
||||||
const connections = {
|
const connections = {
|
||||||
find: jest.fn().mockResolvedValue([]),
|
find: jest.fn().mockResolvedValue([]),
|
||||||
} as unknown as Repository<LocationConnection>;
|
} as unknown as Repository<LocationConnection>;
|
||||||
const findLocationMonsters = jest
|
const findLocationMonsters = jest.fn().mockResolvedValue(BURNED_ROAD_POOL);
|
||||||
.fn()
|
|
||||||
.mockResolvedValue([
|
|
||||||
{ monster: { name: 'Aschenratte' } },
|
|
||||||
{ monster: { name: 'Stra\u00dfenr\u00e4uber' } },
|
|
||||||
]);
|
|
||||||
const locationMonsters = {
|
const locationMonsters = {
|
||||||
find: findLocationMonsters,
|
find: findLocationMonsters,
|
||||||
} as unknown as Repository<LocationMonster>;
|
} as unknown as Repository<LocationMonster>;
|
||||||
@@ -230,4 +367,131 @@ describe('WorldService', () => {
|
|||||||
expect(findConnections).not.toHaveBeenCalled();
|
expect(findConnections).not.toHaveBeenCalled();
|
||||||
expect(findLocationMonsters).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 { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { Repository } from 'typeorm';
|
import { Repository } from 'typeorm';
|
||||||
import { Character } from '../characters/entities/character.entity';
|
import { Character } from '../characters/entities/character.entity';
|
||||||
|
import {
|
||||||
|
calculateDangerRating,
|
||||||
|
DangerRating,
|
||||||
|
} from '../hunting/danger-rating';
|
||||||
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
import { LocationMonster } from '../monsters/entities/location-monster.entity';
|
||||||
import { TravelService } from '../travel/travel.service';
|
import { TravelService } from '../travel/travel.service';
|
||||||
import { LocationConnection } from './entities/location-connection.entity';
|
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 {
|
export interface LocationSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +45,18 @@ export interface CurrentLocationResponse {
|
|||||||
isSafe: boolean;
|
isSafe: boolean;
|
||||||
huntingEnabled: boolean;
|
huntingEnabled: boolean;
|
||||||
artworkPath: string;
|
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[];
|
connections: CurrentLocationConnection[];
|
||||||
possibleMonsters: string[];
|
possibleMonsters: string[];
|
||||||
}
|
}
|
||||||
@@ -49,15 +76,7 @@ export class WorldService {
|
|||||||
async getCurrentLocation(
|
async getCurrentLocation(
|
||||||
characterId: string,
|
characterId: string,
|
||||||
): Promise<CurrentLocationResponse> {
|
): Promise<CurrentLocationResponse> {
|
||||||
await this.travelService.completeTravelIfDue(characterId);
|
const character = await this.loadCharacterAtCurrentLocation(characterId);
|
||||||
|
|
||||||
const character = await this.characters.findOne({
|
|
||||||
where: { id: characterId },
|
|
||||||
relations: { currentLocation: true },
|
|
||||||
});
|
|
||||||
if (!character) {
|
|
||||||
throw new NotFoundException('Character not found.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const connections = await this.connections.find({
|
const connections = await this.connections.find({
|
||||||
where: { fromLocationId: character.currentLocationId, enabled: true },
|
where: { fromLocationId: character.currentLocationId, enabled: true },
|
||||||
@@ -65,8 +84,8 @@ export class WorldService {
|
|||||||
});
|
});
|
||||||
const location = character.currentLocation;
|
const location = character.currentLocation;
|
||||||
|
|
||||||
const possibleMonsters = location.huntingEnabled
|
const pool = location.huntingEnabled
|
||||||
? await this.getPossibleMonsters(location.id)
|
? await this.getEncounterPool(location.id)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -81,6 +100,24 @@ export class WorldService {
|
|||||||
isSafe: location.isSafe,
|
isSafe: location.isSafe,
|
||||||
huntingEnabled: location.huntingEnabled,
|
huntingEnabled: location.huntingEnabled,
|
||||||
artworkPath: location.artworkPath,
|
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
|
connections: connections
|
||||||
.filter((connection) => connection.enabled)
|
.filter((connection) => connection.enabled)
|
||||||
.map((connection) => ({
|
.map((connection) => ({
|
||||||
@@ -92,17 +129,102 @@ export class WorldService {
|
|||||||
travelDurationSeconds: connection.travelDurationSeconds,
|
travelDurationSeconds: connection.travelDurationSeconds,
|
||||||
danger: this.toDangerRating(connection.ambushChance),
|
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 },
|
where: { locationId, enabled: true },
|
||||||
relations: { monster: true },
|
relations: { monster: true },
|
||||||
order: { weight: 'DESC' },
|
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' {
|
private toDangerRating(ambushChance: string): 'LOW' | 'HIGH' {
|
||||||
|
|||||||
BIN
apps/web/public/images/combat/sprites/charred-looter-620.png
Normal file
|
After Width: | Height: | Size: 369 KiB |
BIN
apps/web/public/images/combat/sprites/wild-road-dog-760.png
Normal file
|
After Width: | Height: | Size: 323 KiB |
BIN
apps/web/public/images/monsters/charred-looter.png
Normal file
|
After Width: | Height: | Size: 2.5 MiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/images/monsters/icons/charred-looter-128.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 7.6 KiB |
BIN
apps/web/public/images/monsters/icons/wild-road-dog-128.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
apps/web/public/images/monsters/runtime/charred-looter-560.jpg
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/web/public/images/monsters/runtime/wild-road-dog-560.jpg
Normal file
|
After Width: | Height: | Size: 30 KiB |
BIN
apps/web/public/images/monsters/wild-road-dog.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
@@ -61,7 +61,7 @@ describe('EncounterCardComponent', () => {
|
|||||||
const element = render(ashRatEncounter);
|
const element = render(ashRatEncounter);
|
||||||
const crest = element.querySelector('.encounter-card__crest-icon');
|
const crest = element.querySelector('.encounter-card__crest-icon');
|
||||||
|
|
||||||
expect(crest?.getAttribute('src')).toBe('/images/combat/icons/ash-rat-128.png');
|
expect(crest?.getAttribute('src')).toBe('/images/monsters/icons/ash-rat-128.png');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('leaves the crest empty for a monster without an icon', () => {
|
it('leaves the crest empty for a monster without an icon', () => {
|
||||||
|
|||||||
@@ -16,6 +16,12 @@ describe('monsterCutoutPath', () => {
|
|||||||
it('returns the background-free cut-out for a known monster key', () => {
|
it('returns the background-free cut-out for a known monster key', () => {
|
||||||
expect(monsterCutoutPath('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png');
|
expect(monsterCutoutPath('ash-rat')).toBe('/images/combat/sprites/ash-rat-760.png');
|
||||||
expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.png');
|
expect(monsterCutoutPath('road-bandit')).toBe('/images/combat/sprites/road-bandit-620.png');
|
||||||
|
expect(monsterCutoutPath('wild-road-dog')).toBe(
|
||||||
|
'/images/combat/sprites/wild-road-dog-760.png',
|
||||||
|
);
|
||||||
|
expect(monsterCutoutPath('charred-looter')).toBe(
|
||||||
|
'/images/combat/sprites/charred-looter-620.png',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a monster without a cut-out', () => {
|
it('returns undefined for a monster without a cut-out', () => {
|
||||||
@@ -25,8 +31,11 @@ describe('monsterCutoutPath', () => {
|
|||||||
|
|
||||||
describe('monsterIconPath', () => {
|
describe('monsterIconPath', () => {
|
||||||
it('returns the medallion icon for a known monster key', () => {
|
it('returns the medallion icon for a known monster key', () => {
|
||||||
expect(monsterIconPath('ash-rat')).toBe('/images/combat/icons/ash-rat-128.png');
|
expect(monsterIconPath('ash-rat')).toBe('/images/monsters/icons/ash-rat-128.png');
|
||||||
expect(monsterIconPath('road-bandit')).toBe('/images/combat/icons/road-bandit-128.png');
|
expect(monsterIconPath('road-bandit')).toBe('/images/monsters/icons/road-bandit-128.png');
|
||||||
|
expect(monsterIconPath('charred-looter')).toBe(
|
||||||
|
'/images/monsters/icons/charred-looter-128.png',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns undefined for a monster without an icon', () => {
|
it('returns undefined for a monster without an icon', () => {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
|
const RUNTIME_MONSTER_ARTWORK: Readonly<Record<string, string>> = {
|
||||||
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
|
'/images/monsters/ash-rat.png': '/images/monsters/runtime/ash-rat-560.jpg',
|
||||||
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
|
'/images/monsters/road-bandit.png': '/images/monsters/runtime/road-bandit-560.jpg',
|
||||||
|
'/images/monsters/wild-road-dog.png': '/images/monsters/runtime/wild-road-dog-560.jpg',
|
||||||
|
'/images/monsters/charred-looter.png': '/images/monsters/runtime/charred-looter-560.jpg',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
|
export function runtimeMonsterArtworkPath(artworkPath: string): string | undefined {
|
||||||
@@ -12,11 +14,18 @@ export function runtimeMonsterArtworkPath(artworkPath: string): string | undefin
|
|||||||
const MONSTER_CUTOUT: Readonly<Record<string, string>> = {
|
const MONSTER_CUTOUT: Readonly<Record<string, string>> = {
|
||||||
'ash-rat': '/images/combat/sprites/ash-rat-760.png',
|
'ash-rat': '/images/combat/sprites/ash-rat-760.png',
|
||||||
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
|
'road-bandit': '/images/combat/sprites/road-bandit-620.png',
|
||||||
|
'wild-road-dog': '/images/combat/sprites/wild-road-dog-760.png',
|
||||||
|
'charred-looter': '/images/combat/sprites/charred-looter-620.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Medallions live under `monsters/`, not `combat/`: the local location view
|
||||||
|
// shows the same icons in its encounter preview, where they are served
|
||||||
|
// straight from `MonsterDefinition.iconPath`.
|
||||||
const MONSTER_ICON: Readonly<Record<string, string>> = {
|
const MONSTER_ICON: Readonly<Record<string, string>> = {
|
||||||
'ash-rat': '/images/combat/icons/ash-rat-128.png',
|
'ash-rat': '/images/monsters/icons/ash-rat-128.png',
|
||||||
'road-bandit': '/images/combat/icons/road-bandit-128.png',
|
'road-bandit': '/images/monsters/icons/road-bandit-128.png',
|
||||||
|
'wild-road-dog': '/images/monsters/icons/wild-road-dog-128.png',
|
||||||
|
'charred-looter': '/images/monsters/icons/charred-looter-128.png',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Share of the battlefield height each monster sprite occupies, so a hulking
|
// Share of the battlefield height each monster sprite occupies, so a hulking
|
||||||
@@ -24,6 +33,8 @@ const MONSTER_ICON: Readonly<Record<string, string>> = {
|
|||||||
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
|
const COMBAT_MONSTER_SCALE: Readonly<Record<string, number>> = {
|
||||||
'ash-rat': 0.46,
|
'ash-rat': 0.46,
|
||||||
'road-bandit': 0.82,
|
'road-bandit': 0.82,
|
||||||
|
'wild-road-dog': 0.58,
|
||||||
|
'charred-looter': 0.86,
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT_MONSTER_SCALE = 0.6;
|
const DEFAULT_MONSTER_SCALE = 0.6;
|
||||||
|
|||||||