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"',
|
||||
);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user