Files
Genarrative/server-node/src/services/customWorldEntityGenerationService.ts
2026-04-19 20:33:18 +08:00

902 lines
26 KiB
TypeScript

import { parseJsonResponseText } from '../../../packages/shared/src/llm/parsers.js';
import { badRequest } from '../errors.js';
import {
buildLandmarkPrompt,
buildPlayablePrompt,
buildStoryPrompt,
CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT,
} from '../prompts/customWorldEntityPrompts.js';
import type { UpstreamLlmClient } from './llmClient.js';
type CustomWorldEntityKind = 'playable' | 'story' | 'landmark';
type GenerateCustomWorldEntityInput = {
profile: Record<string, unknown>;
kind: CustomWorldEntityKind;
};
type ParsedRole = {
id: string;
name: string;
title: string;
role: string;
description: string;
visualDescription: string;
actionDescription: string;
sceneVisualDescription: string;
backstory: string;
personality: string;
motivation: string;
combatStyle: string;
initialAffinity: number;
relationshipHooks: string[];
tags: string[];
};
type ParsedLandmarkConnection = {
targetLandmarkId: string;
summary: string;
relativePosition: string;
};
type ParsedLandmark = {
id: string;
name: string;
description: string;
visualDescription: string;
dangerLevel: string;
sceneNpcIds: string[];
connections: ParsedLandmarkConnection[];
};
type ParsedProfile = {
name: string;
settingText: string;
summary: string;
tone: string;
playerGoal: string;
playableNpcs: ParsedRole[];
storyNpcs: ParsedRole[];
landmarks: ParsedLandmark[];
};
const BACKSTORY_CHAPTERS = [
{ id: 'surface', title: '表层来意', affinityRequired: 6 },
{ id: 'scar', title: '旧事裂痕', affinityRequired: 12 },
{ id: 'hidden', title: '隐藏执念', affinityRequired: 18 },
{ id: 'final', title: '最终底牌', affinityRequired: 24 },
] as const;
const ROLE_SURNAME_POOL = [
'沈',
'顾',
'裴',
'闻',
'纪',
'苏',
'岑',
'陆',
'白',
'商',
'温',
'严',
'黎',
'季',
] as const;
const ROLE_GIVEN_POOL = [
'砺',
'岚',
'澄',
'栖',
'弦',
'朔',
'遥',
'霁',
'衡',
'铃',
'潮',
'燧',
'宁',
'鸢',
] as const;
const PLAYABLE_ROLE_POOL = [
'同行策士',
'前线斥候',
'旧誓护卫',
'异闻译者',
'禁制解读者',
'地脉向导',
] as const;
const STORY_ROLE_POOL = [
'守望者',
'情报掮客',
'巡夜人',
'渡口看守',
'旧案证人',
'异类来客',
] as const;
const LANDMARK_PREFIX_POOL = [
'潮碑',
'沉钟',
'雾湾',
'灰塔',
'回潮',
'旧航',
'断潮',
'盐火',
] as const;
const LANDMARK_SUFFIX_POOL = [
'前哨',
'档案楼',
'栈桥',
'工坊',
'集市',
'观测台',
'驿站',
'藏书阁',
] as const;
function toRecord(value: unknown) {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function toText(value: unknown, fallback = '') {
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
}
function toStringArray(value: unknown, maxCount = 12) {
if (!Array.isArray(value)) {
return [];
}
return value
.map((item) => toText(item))
.filter(Boolean)
.slice(0, maxCount);
}
function clampText(value: string, maxLength: number) {
const normalized = value.replace(/\s+/gu, ' ').trim();
if (!normalized) {
return '';
}
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}`;
}
function slugify(value: string) {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9\u4e00-\u9fa5]+/gu, '-')
.replace(/^-+|-+$/gu, '');
return normalized || 'entry';
}
function createStableId(prefix: string, label: string, seed: string) {
return `${prefix}-${slugify(label || prefix)}-${seed}`;
}
function dedupeStrings(values: string[], maxCount = 8) {
return [...new Set(values.map((value) => value.trim()).filter(Boolean))].slice(
0,
maxCount,
);
}
function extractJsonPayload(content: string) {
const fencedMatch = content.match(/```(?:json)?\s*([\s\S]+?)\s*```/u);
if (fencedMatch?.[1]) {
return fencedMatch[1].trim();
}
const objectStart = content.indexOf('{');
const objectEnd = content.lastIndexOf('}');
if (objectStart >= 0 && objectEnd > objectStart) {
return content.slice(objectStart, objectEnd + 1);
}
return content.trim();
}
function normalizeRole(value: unknown): ParsedRole | null {
const record = toRecord(value);
if (!record) {
return null;
}
const id = toText(record.id);
const name = toText(record.name);
if (!id || !name) {
return null;
}
const title = toText(record.title);
const role = toText(record.role, title || '角色');
return {
id,
name,
title: title || role || '角色',
role,
description: toText(record.description),
visualDescription: toText(record.visualDescription),
actionDescription: toText(record.actionDescription),
sceneVisualDescription: toText(record.sceneVisualDescription),
backstory: toText(record.backstory),
personality: toText(record.personality),
motivation: toText(record.motivation),
combatStyle: toText(record.combatStyle),
initialAffinity:
typeof record.initialAffinity === 'number' &&
Number.isFinite(record.initialAffinity)
? Math.round(record.initialAffinity)
: 0,
relationshipHooks: toStringArray(record.relationshipHooks, 6),
tags: toStringArray(record.tags, 8),
};
}
function normalizeLandmark(value: unknown): ParsedLandmark | null {
const record = toRecord(value);
if (!record) {
return null;
}
const id = toText(record.id);
const name = toText(record.name);
if (!id || !name) {
return null;
}
const connections = Array.isArray(record.connections)
? record.connections
.map((item) => {
const connection = toRecord(item);
if (!connection) {
return null;
}
const targetLandmarkId = toText(connection.targetLandmarkId);
if (!targetLandmarkId) {
return null;
}
return {
targetLandmarkId,
summary: toText(connection.summary),
relativePosition: toText(connection.relativePosition, 'forward'),
} satisfies ParsedLandmarkConnection;
})
.filter(
(item): item is ParsedLandmarkConnection => item !== null,
)
.slice(0, 8)
: [];
return {
id,
name,
description: toText(record.description),
visualDescription: toText(record.visualDescription),
dangerLevel: toText(record.dangerLevel, 'medium'),
sceneNpcIds: toStringArray(record.sceneNpcIds, 12),
connections,
};
}
function normalizeProfile(value: unknown): ParsedProfile {
const record = toRecord(value);
if (!record) {
throw badRequest('profile is required');
}
return {
name: toText(record.name, '自定义世界'),
settingText: toText(record.settingText),
summary: toText(record.summary),
tone: toText(record.tone),
playerGoal: toText(record.playerGoal),
playableNpcs: Array.isArray(record.playableNpcs)
? record.playableNpcs
.map(normalizeRole)
.filter((item): item is ParsedRole => item !== null)
: [],
storyNpcs: Array.isArray(record.storyNpcs)
? record.storyNpcs
.map(normalizeRole)
.filter((item): item is ParsedRole => item !== null)
: [],
landmarks: Array.isArray(record.landmarks)
? record.landmarks
.map(normalizeLandmark)
.filter((item): item is ParsedLandmark => item !== null)
: [],
};
}
function buildUniqueRoleName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt;
const surname = ROLE_SURNAME_POOL[index % ROLE_SURNAME_POOL.length];
const firstName =
ROLE_GIVEN_POOL[
Math.floor(index / ROLE_SURNAME_POOL.length) % ROLE_GIVEN_POOL.length
];
const secondName = ROLE_GIVEN_POOL[(index + 5) % ROLE_GIVEN_POOL.length];
const candidate = `${surname}${firstName}${secondName}`;
if (!existingNames.has(candidate)) {
existingNames.add(candidate);
return candidate;
}
}
const fallback = `新角色${existingNames.size + 1}`;
existingNames.add(fallback);
return fallback;
}
function buildUniqueLandmarkName(existingNames: Set<string>, startIndex: number) {
for (let attempt = 0; attempt < 120; attempt += 1) {
const index = startIndex + attempt;
const candidate = `${LANDMARK_PREFIX_POOL[index % LANDMARK_PREFIX_POOL.length]}${
LANDMARK_SUFFIX_POOL[
Math.floor(index / LANDMARK_PREFIX_POOL.length) %
LANDMARK_SUFFIX_POOL.length
]
}`;
if (!existingNames.has(candidate)) {
existingNames.add(candidate);
return candidate;
}
}
const fallback = `新场景${existingNames.size + 1}`;
existingNames.add(fallback);
return fallback;
}
function buildFallbackRoleDraft(
profile: ParsedProfile,
kind: 'playable' | 'story',
) {
const existingNames = new Set(
[...profile.playableNpcs, ...profile.storyNpcs].map((role) => role.name),
);
const name = buildUniqueRoleName(existingNames, existingNames.size + 1);
const roleTitlePool =
kind === 'playable' ? PLAYABLE_ROLE_POOL : STORY_ROLE_POOL;
const role = roleTitlePool[existingNames.size % roleTitlePool.length];
const relationHook =
kind === 'playable'
? `愿意与玩家共同推进“${profile.playerGoal || profile.summary || profile.name}`
: `与“${profile.playerGoal || profile.summary || profile.name}”这条局势线直接相关`;
return {
name,
title: role,
role,
description: clampText(
kind === 'playable'
? `适合与玩家同行,能补足当前队伍短板的关键角色。`
: `长期活跃于当前世界暗面,能补足场景视角的关键角色。`,
60,
),
visualDescription: '',
actionDescription: '',
sceneVisualDescription: '',
backstory: clampText(
`他与${profile.name}当前正在扩张的冲突链紧密相连,知道一些还未公开的内情。`,
80,
),
personality: kind === 'playable' ? '克制、敏锐,擅长合作推进。' : '谨慎、耐心,擅长观察局势。',
motivation: clampText(
kind === 'playable'
? `希望借玩家的选择改变当前世界里已经失衡的局面。`
: `想借玩家的介入撬动一条已经僵住的关系链。`,
72,
),
combatStyle:
kind === 'playable'
? '偏向协作压制与局势调度。'
: '偏向试探牵制与环境借势。',
initialAffinity: kind === 'playable' ? 22 : 6,
relationshipHooks: dedupeStrings(
[relationHook, profile.landmarks[0]?.name ? `常在${profile.landmarks[0].name}附近活动` : '', profile.playableNpcs[0]?.name ? `会先试探${profile.playableNpcs[0].name}与玩家的关系` : '会先试探玩家立场'],
3,
),
tags: dedupeStrings(
[profile.name, profile.tone, kind === 'playable' ? '可同行' : '场景线'],
4,
),
publicSummary:
kind === 'playable'
? '一名能与玩家形成互补的新同行者。'
: '一名能补足当前场景关系网的新角色。',
chapterTeasers: [
'他出现得并不偶然。',
'他与旧局势之间有一道未说透的裂痕。',
'他真正站队的理由比表面更复杂。',
'他留着最后一张不会轻易掀开的牌。',
],
chapterContents: [
`他在${profile.name}当前局势里早就留下了自己的位置。`,
'一段旧事让他无法再把自己完全抽离出去。',
'他真正想守住的并不是表面上说出口的东西。',
'一旦走到临界点,他会把最关键的底牌押上桌面。',
],
skills: [
{
name: kind === 'playable' ? '协作先手' : '观察起手',
summary: '先稳住局面,再把主动权拉回自己手里。',
style: '起手压制',
},
{
name: kind === 'playable' ? '阵线补位' : '地形借势',
summary: '借助环境和站位撕开短暂缺口。',
style: '机动周旋',
},
{
name: kind === 'playable' ? '压轴回合' : '暗线反制',
summary: '在关键回合揭出隐藏准备,改变节奏。',
style: '爆发终结',
},
],
initialItems: [
{
name: kind === 'playable' ? '随身兵装' : '私藏器具',
category: '武器',
quantity: 1,
rarity: 'rare' as const,
description: '与其身份长期绑定的常备装备。',
tags: ['自定义'],
},
{
name: kind === 'playable' ? '路书残页' : '情报残页',
category: '专属物品',
quantity: 1,
rarity: 'rare' as const,
description: '记录着只属于他自己的线索与判断。',
tags: ['线索'],
},
{
name: '应急补给',
category: '消耗品',
quantity: 2,
rarity: 'uncommon' as const,
description: '面对突发局势时会先拿出来的保底物资。',
tags: ['备用'],
},
],
};
}
function buildFallbackLandmarkDraft(profile: ParsedProfile) {
const existingNames = new Set(profile.landmarks.map((landmark) => landmark.name));
const name = buildUniqueLandmarkName(existingNames, profile.landmarks.length + 1);
const sceneNpcNames = profile.storyNpcs.slice(0, 3).map((npc) => npc.name);
const targetLandmarkNames = profile.landmarks.slice(0, 2).map((landmark) => landmark.name);
return {
name,
description: clampText(
`承接${profile.name}当前主冲突的一处关键新场景,适合继续向外扩张世界关系网。`,
72,
),
visualDescription: '',
dangerLevel: 'medium',
sceneNpcNames,
connections: targetLandmarkNames.map((targetLandmarkName, index) => ({
targetLandmarkName,
relativePosition: index === 0 ? 'forward' : 'inside',
summary: index === 0 ? `沿主路可抵达${targetLandmarkName}` : `可从暗线进入${targetLandmarkName}`,
})),
};
}
function ensureUniqueName(name: string, existingNames: string[], fallbackName: string) {
const normalized = name.trim() || fallbackName;
if (!existingNames.includes(normalized)) {
return normalized;
}
let index = 2;
let nextName = `${normalized}${index}`;
while (existingNames.includes(nextName)) {
index += 1;
nextName = `${normalized}${index}`;
}
return nextName;
}
function sanitizeGeneratedRole(
rawValue: unknown,
profile: ParsedProfile,
kind: 'playable' | 'story',
) {
const record = toRecord(rawValue);
const fallbackDraft = buildFallbackRoleDraft(profile, kind);
const existingNames = [...profile.playableNpcs, ...profile.storyNpcs].map(
(role) => role.name,
);
const seed = Date.now().toString(36);
const relationshipHooks = dedupeStrings(
toStringArray(record?.relationshipHooks, 6).concat(
fallbackDraft.relationshipHooks,
),
4,
);
const tags = dedupeStrings(
toStringArray(record?.tags, 8).concat(fallbackDraft.tags),
6,
);
const chapterTeasers = toStringArray(record?.chapterTeasers, 4);
const chapterContents = toStringArray(record?.chapterContents, 4);
const skillRecords = Array.isArray(record?.skills) ? record.skills : [];
const itemRecords = Array.isArray(record?.initialItems)
? record.initialItems
: [];
const name = ensureUniqueName(
toText(record?.name, fallbackDraft.name),
existingNames,
fallbackDraft.name,
);
return {
id: createStableId(
kind === 'playable' ? 'playable-npc' : 'story-npc',
name,
seed,
),
name,
title: clampText(toText(record?.title, fallbackDraft.title), 20),
role: clampText(
toText(record?.role, fallbackDraft.role || fallbackDraft.title),
20,
),
description: clampText(
toText(record?.description, fallbackDraft.description),
120,
),
visualDescription: clampText(
toText(record?.visualDescription, fallbackDraft.visualDescription),
180,
),
actionDescription: clampText(
toText(record?.actionDescription, fallbackDraft.actionDescription),
140,
),
sceneVisualDescription: clampText(
toText(
record?.sceneVisualDescription,
fallbackDraft.sceneVisualDescription,
),
180,
),
backstory: clampText(toText(record?.backstory, fallbackDraft.backstory), 260),
personality: clampText(
toText(record?.personality, fallbackDraft.personality),
120,
),
motivation: clampText(
toText(record?.motivation, fallbackDraft.motivation),
120,
),
combatStyle: clampText(
toText(record?.combatStyle, fallbackDraft.combatStyle),
120,
),
initialAffinity:
typeof record?.initialAffinity === 'number' &&
Number.isFinite(record.initialAffinity)
? Math.round(
Math.max(
kind === 'playable' ? 12 : -40,
Math.min(90, record.initialAffinity),
),
)
: fallbackDraft.initialAffinity,
relationshipHooks,
relations: [],
tags,
backstoryReveal: {
publicSummary: clampText(
toText(record?.publicSummary, fallbackDraft.publicSummary),
120,
),
chapters: BACKSTORY_CHAPTERS.map((chapter, index) => ({
id: chapter.id,
title: chapter.title,
affinityRequired: chapter.affinityRequired,
teaser:
chapterTeasers[index] ??
fallbackDraft.chapterTeasers[index] ??
fallbackDraft.chapterTeasers[0],
content:
chapterContents[index] ??
fallbackDraft.chapterContents[index] ??
fallbackDraft.chapterContents[0],
contextSnippet: clampText(
chapterContents[index] ??
fallbackDraft.chapterContents[index] ??
fallbackDraft.chapterContents[0],
36,
),
})),
},
skills:
skillRecords.length >= 3
? skillRecords.slice(0, 3).map((skill, index) => {
const skillRecord = toRecord(skill);
const fallbackSkill =
fallbackDraft.skills[index] ?? fallbackDraft.skills[0];
return {
id: createStableId(
'skill',
`${name}-${toText(skillRecord?.name, fallbackSkill.name)}`,
`${seed}-${index + 1}`,
),
name: clampText(
toText(skillRecord?.name, fallbackSkill.name),
20,
),
summary: clampText(
toText(skillRecord?.summary, fallbackSkill.summary),
60,
),
style: clampText(
toText(skillRecord?.style, fallbackSkill.style),
20,
),
};
})
: fallbackDraft.skills.map((skill, index) => ({
id: createStableId('skill', `${name}-${skill.name}`, `${seed}-${index + 1}`),
name: skill.name,
summary: skill.summary,
style: skill.style,
})),
initialItems:
itemRecords.length >= 3
? itemRecords.slice(0, 3).map((item, index) => {
const itemRecord = toRecord(item);
const fallbackItem =
fallbackDraft.initialItems[index] ?? fallbackDraft.initialItems[0];
const rarity = toText(itemRecord?.rarity, fallbackItem.rarity);
return {
id: createStableId(
'item',
`${name}-${toText(itemRecord?.name, fallbackItem.name)}`,
`${seed}-${index + 1}`,
),
name: clampText(toText(itemRecord?.name, fallbackItem.name), 20),
category: clampText(
toText(itemRecord?.category, fallbackItem.category),
16,
),
quantity:
typeof itemRecord?.quantity === 'number' &&
Number.isFinite(itemRecord.quantity)
? Math.max(1, Math.min(9, Math.round(itemRecord.quantity)))
: fallbackItem.quantity,
rarity:
rarity === 'common' ||
rarity === 'uncommon' ||
rarity === 'rare' ||
rarity === 'epic' ||
rarity === 'legendary'
? rarity
: fallbackItem.rarity,
description: clampText(
toText(itemRecord?.description, fallbackItem.description),
80,
),
tags: dedupeStrings(
toStringArray(itemRecord?.tags, 4).concat(fallbackItem.tags),
4,
),
};
})
: fallbackDraft.initialItems.map((item, index) => ({
id: createStableId('item', `${name}-${item.name}`, `${seed}-${index + 1}`),
name: item.name,
category: item.category,
quantity: item.quantity,
rarity: item.rarity,
description: item.description,
tags: item.tags,
})),
};
}
function sanitizeGeneratedLandmark(rawValue: unknown, profile: ParsedProfile) {
const record = toRecord(rawValue);
const fallbackDraft = buildFallbackLandmarkDraft(profile);
const existingNames = profile.landmarks.map((landmark) => landmark.name);
const name = ensureUniqueName(
toText(record?.name, fallbackDraft.name),
existingNames,
fallbackDraft.name,
);
const seed = Date.now().toString(36);
const storyNpcByName = new Map(
profile.storyNpcs.map((npc) => [npc.name.trim(), npc.id]),
);
const landmarkByName = new Map(
profile.landmarks.map((landmark) => [landmark.name.trim(), landmark.id]),
);
const rawSceneNpcNames = toStringArray(record?.sceneNpcNames, 12);
const rawConnections = Array.isArray(record?.connections) ? record.connections : [];
const resolvedSceneNpcIds = dedupeStrings(
rawSceneNpcNames
.map((npcName) => storyNpcByName.get(npcName.trim()) ?? '')
.concat(
fallbackDraft.sceneNpcNames
.map((npcName) => storyNpcByName.get(npcName.trim()) ?? '')
.filter(Boolean),
),
3,
);
const fallbackSceneNpcIds = dedupeStrings(
profile.storyNpcs.slice(0, 3).map((npc) => npc.id),
3,
);
const sceneNpcIds =
resolvedSceneNpcIds.length >= 3 ? resolvedSceneNpcIds : fallbackSceneNpcIds;
const connections = rawConnections
.map((item, index) => {
const connection = toRecord(item);
if (!connection) {
return null;
}
const targetLandmarkId =
landmarkByName.get(toText(connection.targetLandmarkName)) ??
landmarkByName.get(toText(connection.targetLandmarkId)) ??
'';
if (!targetLandmarkId) {
return null;
}
return {
targetLandmarkId,
relativePosition: toText(
connection.relativePosition,
index === 0 ? 'forward' : 'inside',
),
summary: clampText(
toText(
connection.summary,
fallbackDraft.connections[index]?.summary || '可通往相邻区域',
),
24,
),
};
})
.filter((item): item is ParsedLandmarkConnection => item !== null)
.filter((item) => item.targetLandmarkId);
const fallbackConnections = fallbackDraft.connections
.map((connection) => {
const targetLandmarkId =
landmarkByName.get(connection.targetLandmarkName.trim()) ?? '';
if (!targetLandmarkId) {
return null;
}
return {
targetLandmarkId,
relativePosition: connection.relativePosition,
summary: connection.summary,
} satisfies ParsedLandmarkConnection;
})
.filter((item): item is ParsedLandmarkConnection => item !== null);
return {
id: createStableId('landmark', name, seed),
name,
description: clampText(
toText(record?.description, fallbackDraft.description),
140,
),
visualDescription: clampText(
toText(record?.visualDescription, fallbackDraft.visualDescription),
180,
),
dangerLevel: (() => {
const level = toText(record?.dangerLevel, fallbackDraft.dangerLevel);
return level === 'low' ||
level === 'medium' ||
level === 'high' ||
level === 'extreme'
? level
: 'medium';
})(),
sceneNpcIds,
connections: (connections.length > 0 ? connections : fallbackConnections).slice(0, 3),
};
}
async function requestGeneratedEntity(
llmClient: UpstreamLlmClient,
kind: CustomWorldEntityKind,
profile: ParsedProfile,
) {
const userPrompt =
kind === 'playable'
? buildPlayablePrompt(profile)
: kind === 'story'
? buildStoryPrompt(profile)
: buildLandmarkPrompt(profile);
const content = await llmClient.requestMessageContent({
systemPrompt: CUSTOM_WORLD_ENTITY_GENERATOR_SYSTEM_PROMPT,
userPrompt,
timeoutMs: 45000,
debugLabel: `custom-world-generate-${kind}`,
});
return parseJsonResponseText(extractJsonPayload(content));
}
export async function generateCustomWorldEntity(
llmClient: UpstreamLlmClient,
input: GenerateCustomWorldEntityInput,
) {
const profile = normalizeProfile(input.profile);
try {
const parsed = await requestGeneratedEntity(llmClient, input.kind, profile);
const record = toRecord(parsed);
if (input.kind === 'playable') {
return {
kind: 'playable' as const,
entity: sanitizeGeneratedRole(record?.playableNpc ?? parsed, profile, 'playable'),
};
}
if (input.kind === 'story') {
return {
kind: 'story' as const,
entity: sanitizeGeneratedRole(record?.storyNpc ?? parsed, profile, 'story'),
};
}
return {
kind: 'landmark' as const,
entity: sanitizeGeneratedLandmark(record?.landmark ?? parsed, profile),
};
} catch {
if (input.kind === 'playable') {
return {
kind: 'playable' as const,
entity: sanitizeGeneratedRole(null, profile, 'playable'),
};
}
if (input.kind === 'story') {
return {
kind: 'story' as const,
entity: sanitizeGeneratedRole(null, profile, 'story'),
};
}
return {
kind: 'landmark' as const,
entity: sanitizeGeneratedLandmark(null, profile),
};
}
}