Refine NPC interactions and runtime item generation

This commit is contained in:
2026-04-05 17:13:07 +08:00
parent c49c64896a
commit 89cecda7da
58 changed files with 4199 additions and 1562 deletions

View File

@@ -17,6 +17,10 @@ const CUSTOM_WORLD_RARITIES: ItemRarity[] = [
'epic',
'legendary',
];
const MIN_CUSTOM_WORLD_AFFINITY = -40;
const MAX_CUSTOM_WORLD_AFFINITY = 90;
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
@@ -39,20 +43,30 @@ export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `你正在为像素动作 R
{
"name": "角色名称",
"title": "称号",
"role": "在世界中的身份/职责",
"description": "简短描述",
"backstory": "背景经历",
"personality": "性格特点",
"motivation": "当前动机",
"combatStyle": "战斗风格",
"initialAffinity": 18,
"relationshipHooks": ["关系切入口1", "关系切入口2"],
"tags": ["标签1", "标签2"]
}
],
"storyNpcs": [
{
"name": "场景角色名称",
"title": "称号",
"role": "身份",
"description": "简短描述",
"backstory": "背景经历",
"personality": "性格特点",
"motivation": "动机",
"relationshipHooks": ["关系切入口1", "关系切入口2"]
"combatStyle": "战斗风格",
"initialAffinity": 6,
"relationshipHooks": ["关系切入口1", "关系切入口2"],
"tags": ["标签1", "标签2"]
}
],
"landmarks": [
@@ -72,8 +86,14 @@ export const CUSTOM_WORLD_GENERATION_SYSTEM_PROMPT = `你正在为像素动作 R
- 必须生成足够多的 storyNpcs使唯一角色总数至少达到 30。
- 至少生成 10 个 landmarks。
- 不要生成 items 字段。
- playableNpcs 与 storyNpcs 中的每个角色都必须使用完全相同的字段结构,不要省略字段,也不要只给其中一类角色加私有字段。
- initialAffinity 必须是整数,范围控制在 -40 到 90。
- 可扮演角色通常从基础信任起步initialAffinity 建议不低于 18敌对角色、怪物型角色或开局明显 hostile 的 NPCinitialAffinity 应为负数。
- 怪物也视为 NPC可以直接出现在 storyNpcs 中;不要额外拆出 monster 字段。
- 如果某个 NPC 更适合走怪物素材,请在 role、description、backstory、combatStyle、tags 中明确写出怪物特征、栖息环境、攻击方式或异形外观,方便后续形象解析同时引用 Medieval 和怪物素材。
- 名称必须具体且有辨识度,不要使用 角色1、场景1 之类的占位名。
- 名册中要覆盖多种社会身份,不能只有战斗角色。
- storyNpcs 里既要有可交流、可合作的角色,也要允许出现敌对、怪物型或强压迫感的角色。
- 地标必须像真实可游玩的场景,能够承载探索、战斗、旅行和剧情推进。
- 不要引用现实品牌、受版权保护的 IP 或知名既有人物。`;
@@ -98,6 +118,19 @@ function normalizeTags(value: unknown, fallbackTags: string[] = []) {
].slice(0, 5);
}
function clampCustomWorldAffinity(value: number) {
return Math.max(
MIN_CUSTOM_WORLD_AFFINITY,
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
);
}
function normalizeInitialAffinity(value: unknown, fallback: number) {
return typeof value === 'number' && Number.isFinite(value)
? clampCustomWorldAffinity(value)
: fallback;
}
function normalizeWorldType(value: unknown, sourceText: string) {
const worldType = toText(value).toUpperCase();
if (worldType === WorldType.WUXIA || worldType === WorldType.XIANXIA) {
@@ -207,15 +240,28 @@ function normalizePlayableNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const title = toText(item.title) || toText(item.role) || '未定称号';
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
return {
id: createEntryId('playable-npc', name, index),
name,
title: toText(item.title),
title,
role,
description: toText(item.description),
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation) || toText(item.description),
combatStyle: toText(item.combatStyle),
tags: normalizeTags(item.tags),
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
DEFAULT_PLAYABLE_INITIAL_AFFINITY,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
} satisfies CustomWorldPlayableNpc;
})
.filter((entry) => entry.name)
@@ -226,13 +272,28 @@ function normalizeStoryNpcList(value: unknown) {
return toRecordArray(value)
.map((item, index) => {
const name = toText(item.name);
const title = toText(item.title) || toText(item.role) || '未定称号';
const role = toText(item.role) || title;
const relationshipHooks = normalizeTags(
item.relationshipHooks,
normalizeTags(item.tags),
);
return {
id: createEntryId('story-npc', name, index),
name,
role: toText(item.role),
title,
role,
description: toText(item.description),
backstory: toText(item.backstory),
personality: toText(item.personality),
motivation: toText(item.motivation),
relationshipHooks: normalizeTags(item.relationshipHooks),
combatStyle: toText(item.combatStyle),
initialAffinity: normalizeInitialAffinity(
item.initialAffinity,
DEFAULT_STORY_NPC_INITIAL_AFFINITY,
),
relationshipHooks,
tags: normalizeTags(item.tags, relationshipHooks),
} satisfies CustomWorldNpc;
})
.filter((entry) => entry.name);
@@ -339,8 +400,11 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
'- 必须生成足够多的 storyNpcs使唯一角色总数至少达到 30。',
'- 至少生成 10 个真正可游玩的 landmarks。',
'- 不要生成任何 items也不要包含 items 字段。',
'- playableNpcs 与 storyNpcs 必须使用同一套字段结构name、title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
'- initialAffinity 必须是 -40 到 90 的整数;可扮演角色通常不低于 18敌对或怪物型 NPC 应使用负数。',
'- 每个场景角色和地标都必须直接源自玩家设定。',
'- 要覆盖多种社会身份,不能只有战斗角色。',
'- 怪物也视为 NPC怪物型角色仍然放进 storyNpcs并在文字里明确写出怪物特征、栖息环境或攻击方式方便后续形象解析引用怪物素材。',
].join('\n');
}
@@ -349,14 +413,14 @@ export function buildCustomWorldReferenceText(profile: CustomWorldProfile) {
.slice(0, 3)
.map(
(npc) =>
`- ${npc.name} / ${npc.title}${npc.description};背景:${npc.backstory}风格:${npc.combatStyle}`,
`- ${npc.name} / ${npc.title}${npc.description}身份:${npc.role}背景:${npc.backstory}动机:${npc.motivation};风格:${npc.combatStyle};初始好感:${npc.initialAffinity}`,
)
.join('\n');
const storyNpcText = profile.storyNpcs
.slice(0, 8)
.map(
(npc) =>
`- ${npc.name} / ${npc.role}${npc.description}动机:${npc.motivation}`,
`- ${npc.name} / ${npc.role}${npc.description}称号:${npc.title};背景:${npc.backstory};动机:${npc.motivation};风格:${npc.combatStyle};初始好感:${npc.initialAffinity}`,
)
.join('\n');
const landmarkText = profile.landmarks