902 lines
26 KiB
TypeScript
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),
|
|
};
|
|
}
|
|
}
|