Refine NPC interactions and runtime item generation
This commit is contained in:
@@ -135,10 +135,14 @@ function createPlayableNpc(index: number) {
|
||||
return {
|
||||
name: `角色${index + 1}`,
|
||||
title: `身份${index + 1}`,
|
||||
role: `世界职责${index + 1}`,
|
||||
description: `角色描述${index + 1}`,
|
||||
backstory: `角色背景${index + 1}`,
|
||||
personality: `角色性格${index + 1}`,
|
||||
motivation: `角色动机${index + 1}`,
|
||||
combatStyle: `战斗风格${index + 1}`,
|
||||
initialAffinity: 18,
|
||||
relationshipHooks: [`接触点${index + 1}`],
|
||||
tags: [`标签${index + 1}`],
|
||||
};
|
||||
}
|
||||
@@ -146,10 +150,16 @@ function createPlayableNpc(index: number) {
|
||||
function createStoryNpc(index: number) {
|
||||
return {
|
||||
name: `世界NPC${index + 1}`,
|
||||
title: `头衔${index + 1}`,
|
||||
role: `职责${index + 1}`,
|
||||
description: `世界NPC描述${index + 1}`,
|
||||
backstory: `世界NPC背景${index + 1}`,
|
||||
personality: `世界NPC性格${index + 1}`,
|
||||
motivation: `世界NPC动机${index + 1}`,
|
||||
combatStyle: `世界NPC战斗风格${index + 1}`,
|
||||
initialAffinity: index % 4 === 0 ? -10 : 6,
|
||||
relationshipHooks: [`关系${index + 1}`],
|
||||
tags: [`线索${index + 1}`],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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 的 NPC,initialAffinity 应为负数。
|
||||
- 怪物也视为 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
|
||||
|
||||
@@ -10,13 +10,13 @@ const ATTRIBUTE_LABELS = {
|
||||
const RESOURCE_LABELS = {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'Max HP',
|
||||
maxMp: 'Max MP',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana Cost',
|
||||
manaCost: '灵力消耗',
|
||||
} as const;
|
||||
|
||||
export function buildThemedSkillName(_profile: unknown, style: string, index = 0) {
|
||||
|
||||
@@ -99,112 +99,113 @@ const WORLD_PRESENTATIONS: Record<ThemeMode, WorldPresentation> = {
|
||||
},
|
||||
machina: {
|
||||
mode: 'machina',
|
||||
attributeLabels: { strength: 'Power', agility: 'Dexterity', intelligence: 'Logic', spirit: 'Core' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'Energy',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max Energy',
|
||||
damageLabel: 'Firepower',
|
||||
guardLabel: 'Shield',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Recharge',
|
||||
manaCostLabel: 'Energy Cost',
|
||||
campSuffix: 'Mobile Outpost',
|
||||
itemPrefixes: ['Iron', 'Steel', 'Pulse', 'Core', 'Nova', 'Plasma'],
|
||||
itemInfixes: ['-Core','-Drive','-Link','-Grid','-Node','-Unit'],
|
||||
skillPrefixes: ['Over','Ultra','Mega','Core','Pulse','Nova'],
|
||||
attributeLabels: { strength: '动力', agility: '精度', intelligence: '逻辑', spirit: '核心' },
|
||||
hpLabel: '耐久',
|
||||
mpLabel: '能量',
|
||||
maxHpLabel: '耐久上限',
|
||||
maxMpLabel: '能量上限',
|
||||
damageLabel: '火力',
|
||||
guardLabel: '护盾',
|
||||
rangeLabel: '射程',
|
||||
cooldownLabel: '充能',
|
||||
manaCostLabel: '能量消耗',
|
||||
campSuffix: '机动前哨',
|
||||
itemPrefixes: ['铁脊', '钢律', '脉冲', '核列', '新星', '等离'],
|
||||
itemInfixes: ['芯', '驱', '链', '阵', '节', '机'],
|
||||
skillPrefixes: ['超载', '脉冲', '聚核', '磁轨', '新星', '裂火'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
burst: ['爆裂', '齐射', '连发', '倾泻'],
|
||||
steady: ['稳压', '固守', '护持', '锚定'],
|
||||
mobility: ['疾冲', '推进', '跃迁', '闪移'],
|
||||
finisher: ['终断', '歼灭', '过载', '坠落'],
|
||||
projectile: ['弹', '束', '矢', '炮'],
|
||||
},
|
||||
},
|
||||
tide: {
|
||||
mode: 'tide',
|
||||
attributeLabels: { strength: 'Strength', agility: 'Agility', intelligence: 'Intelligence', spirit: 'Spirit' },
|
||||
hpLabel: 'HP',
|
||||
mpLabel: 'MP',
|
||||
maxHpLabel: 'Max HP',
|
||||
maxMpLabel: 'Max MP',
|
||||
damageLabel: 'Damage',
|
||||
guardLabel: 'Guard',
|
||||
rangeLabel: 'Range',
|
||||
cooldownLabel: 'Cooldown',
|
||||
manaCostLabel: 'Mana Cost',
|
||||
campSuffix: 'Camp',
|
||||
itemPrefixes: ['Wave', 'Tide', 'Ocean', 'Sea', 'Storm', 'Surf'],
|
||||
itemInfixes: ['-Wave','-Tide','-Ocean','-Sea','-Storm','-Surf'],
|
||||
skillPrefixes: ['Wave','Tide','Ocean','Sea','Storm','Surf'],
|
||||
attributeLabels: { strength: '潮力', agility: '浪步', intelligence: '潮识', spirit: '潮魄' },
|
||||
hpLabel: '潮命',
|
||||
mpLabel: '潮息',
|
||||
maxHpLabel: '潮命上限',
|
||||
maxMpLabel: '潮息上限',
|
||||
damageLabel: '潮势',
|
||||
guardLabel: '潮护',
|
||||
rangeLabel: '潮距',
|
||||
cooldownLabel: '回潮',
|
||||
manaCostLabel: '潮息消耗',
|
||||
campSuffix: '潮栖营地',
|
||||
itemPrefixes: ['潮纹', '海晕', '霜浪', '天澜', '潮歌', '沧流'],
|
||||
itemInfixes: ['潮', '浪', '汐', '海', '涛', '澜'],
|
||||
skillPrefixes: ['潮', '浪', '汐', '海', '澜', '涌'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['-Burst','-Barrage','-Volley','-Salvo'],
|
||||
steady: ['-Sustain','-Hold','-Guard','-Anchor'],
|
||||
mobility: ['-Dash','-Boost','-Warp','-Blink'],
|
||||
finisher: ['-Strike','-Cascade','-Finale','-Overload'],
|
||||
projectile: ['-Round','-Bolt','-Shell','-Missile'],
|
||||
burst: ['裂潮', '怒涌', '连浪', '奔潮'],
|
||||
steady: ['守潮', '潮护', '定澜', '镇流'],
|
||||
mobility: ['踏浪', '游潮', '跃汐', '逐流'],
|
||||
finisher: ['断潮', '覆海', '终汐', '沉落'],
|
||||
projectile: ['潮矢', '水矛', '浪刃', '飞涌'],
|
||||
},
|
||||
},
|
||||
rift: {
|
||||
mode: 'rift',
|
||||
attributeLabels: { strength: 'çå²', agility: 'è£æ¥', intelligence: 'çè¯', spirit: 'çå' },
|
||||
hpLabel: 'çå½',
|
||||
mpLabel: 'è£è½',
|
||||
maxHpLabel: 'çå½ä¸é',
|
||||
maxMpLabel: 'è£è½ä¸é',
|
||||
damageLabel: 'çå¿',
|
||||
guardLabel: '稳ç',
|
||||
rangeLabel: 'çè·',
|
||||
cooldownLabel: 'å¤ç',
|
||||
manaCostLabel: 'Rift Cost',
|
||||
campSuffix: 'è£çé©»è¥',
|
||||
itemPrefixes: ['è£ç', 'æå±', '边潮', 'ç°å', 'çæ¡¥', 'åå¨'],
|
||||
itemInfixes: ['edge', 'void', 'span', 'seal', 'rift', 'core'],
|
||||
skillPrefixes: ['rift', 'void', 'split', 'break', 'phase', 'warp'],
|
||||
attributeLabels: { strength: '界劲', agility: '裂步', intelligence: '界识', spirit: '界压' },
|
||||
hpLabel: '界命',
|
||||
mpLabel: '裂能',
|
||||
maxHpLabel: '界命上限',
|
||||
maxMpLabel: '裂能上限',
|
||||
damageLabel: '界势',
|
||||
guardLabel: '稳界',
|
||||
rangeLabel: '界距',
|
||||
cooldownLabel: '复界',
|
||||
manaCostLabel: '裂能消耗',
|
||||
campSuffix: '裂界驻营',
|
||||
itemPrefixes: ['裂界', '断层', '边潮', '灰域', '界桥', '前哨'],
|
||||
itemInfixes: ['锋', '隙', '锚', '印', '界', '核'],
|
||||
skillPrefixes: ['裂', '断', '界', '相', '折', '迁'],
|
||||
skillSuffixByStyle: {
|
||||
burst: ['break', 'crash', 'shatter', 'burst'],
|
||||
steady: ['guard', 'hold', 'veil', 'ward'],
|
||||
mobility: ['step', 'shift', 'blink', 'drift'],
|
||||
finisher: ['ending', 'drop', 'break', 'flare'],
|
||||
projectile: ['spike', 'bolt', 'shard', 'wave'],
|
||||
burst: ['崩断', '碎坠', '裂爆', '界崩'],
|
||||
steady: ['守界', '固相', '帷障', '界卫'],
|
||||
mobility: ['裂步', '转相', '闪迁', '漂移'],
|
||||
finisher: ['终坠', '断灭', '裂终', '界燃'],
|
||||
projectile: ['界刺', '裂矢', '碎片', '裂波'],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const CATEGORY_NOUNS: Record<string, string[]> = Object.fromEntries([
|
||||
[CATEGORY_WEAPON, ['blade', 'axe', 'bow', 'staff', 'spear', 'shield']],
|
||||
[CATEGORY_ARMOR, ['armor', 'robe', 'cloak', 'guard', 'mantle', 'bracer']],
|
||||
[CATEGORY_RELIC, ['ring', 'seal', 'badge', 'gem', 'charm', 'orb']],
|
||||
[CATEGORY_CONSUMABLE, ['potion', 'dust', 'draught', 'brew', 'oil', 'scroll']],
|
||||
[CATEGORY_MATERIAL, ['ore', 'crystal', 'bone', 'herb', 'core', 'silk']],
|
||||
[CATEGORY_RARE, ['sigil', 'relic', 'page', 'chart', 'key', 'idol']],
|
||||
[CATEGORY_EXCLUSIVE, ['core', 'seal', 'master-key', 'origin-box', 'true-mark', 'world-core']],
|
||||
[CATEGORY_WEAPON, ['剑', '刃', '弓', '杖', '枪', '盾']],
|
||||
[CATEGORY_ARMOR, ['甲', '袍', '披风', '护具', '肩甲', '护腕']],
|
||||
[CATEGORY_RELIC, ['戒', '印', '徽', '玉', '符', '珠']],
|
||||
[CATEGORY_CONSUMABLE, ['药', '散', '剂', '露', '油', '卷']],
|
||||
[CATEGORY_MATERIAL, ['矿', '晶', '骨', '草', '核', '丝']],
|
||||
[CATEGORY_RARE, ['符', '遗物', '残页', '图', '钥', '像']],
|
||||
[CATEGORY_EXCLUSIVE, ['核心', '封印', '主钥', '源匣', '真印', '界核']],
|
||||
]);
|
||||
const DEFAULT_CATEGORY_NOUNS = ['relic', 'sigil', 'token', 'seal', 'core', 'mark'];
|
||||
const DEFAULT_CATEGORY_NOUNS = ['符', '印', '信物', '匣', '核', '铭片'];
|
||||
|
||||
const ROLE_SKILL_ROOTS: Record<string, string[]> = {
|
||||
'sword-princess': ['çå', 'éå¼', 'è£é', 'è£é'],
|
||||
'archer-hero': ['弦è¯', 'è¿è¢', '追é£', 'è´¯ç¢'],
|
||||
'girl-hero': ['åå', 'å½±è¢', 'ç¾æ©', 'æ å½±'],
|
||||
'punch-hero': ['æ³å¿', 'éå»', 'è£æ³', 'å´©æ¥'],
|
||||
'fighter-4': ['éé', 'ç¾éµ', 'é线', 'åå'],
|
||||
'sword-princess': ['王剑', '锋式', '裁锋', '裂锋'],
|
||||
'archer-hero': ['弦诀', '远袭', '追风', '贯矢'],
|
||||
'girl-hero': ['双刃', '影袭', '疾斩', '掠影'],
|
||||
'punch-hero': ['拳势', '震击', '裂拳', '崩步'],
|
||||
'fighter-4': ['重锋', '盾阵', '镇线', '压城'],
|
||||
};
|
||||
|
||||
const SKILL_ROOT_STOP_WORDS = new Set([
|
||||
'ä¸ç',
|
||||
'设å®',
|
||||
'åºè°',
|
||||
'ç®æ ',
|
||||
'è§è²',
|
||||
'ææ',
|
||||
'飿 ¼',
|
||||
'èæ¯',
|
||||
'æ§æ ¼',
|
||||
'æ
äº',
|
||||
'世界',
|
||||
'设定',
|
||||
'基调',
|
||||
'目标',
|
||||
'角色',
|
||||
'战斗',
|
||||
'风格',
|
||||
'背景',
|
||||
'性格',
|
||||
'故事',
|
||||
'custom-world',
|
||||
'playable-role',
|
||||
]);
|
||||
|
||||
|
||||
function hashText(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -286,7 +287,7 @@ function buildSkillRootOptions(
|
||||
character: Character,
|
||||
role?: Pick<CustomWorldPlayableNpc, 'title' | 'combatStyle' | 'tags'> | null,
|
||||
) {
|
||||
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['çå¼', 'è¡è¯', 'è£é', 'æ½®å°'];
|
||||
const fallbackRoots = ROLE_SKILL_ROOTS[character.id] ?? ['界式', '行诀', '裂锋', '潮印'];
|
||||
const derivedRoots = dedupeStrings([
|
||||
...collectSkillRootFragments(role?.title ?? '', 4),
|
||||
...collectSkillRootFragments(role?.combatStyle ?? '', 6),
|
||||
@@ -310,9 +311,9 @@ export function getAttributeLabelsForWorld(worldType: WorldType | null | undefin
|
||||
const profile = getCustomWorldProfileForDisplay(worldType, explicitProfile);
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return { strength: 'é躯', agility: '御è¡', intelligence: 'ç¥è¯', spirit: 'çµè´' };
|
||||
return { strength: '道骨', agility: '身法', intelligence: '神识', spirit: '灵韵' };
|
||||
}
|
||||
return { strength: 'åé', agility: 'ææ·', intelligence: 'æºå', spirit: 'ç²¾ç¥' };
|
||||
return { strength: '力量', agility: '敏捷', intelligence: '智力', spirit: '精神' };
|
||||
}
|
||||
|
||||
return getWorldPresentation(profile).attributeLabels;
|
||||
@@ -323,27 +324,27 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
|
||||
if (!profile) {
|
||||
if (worldType === WorldType.XIANXIA) {
|
||||
return {
|
||||
hp: 'å½å
',
|
||||
mp: 'çµè´',
|
||||
maxHp: 'å½å
ä¸é',
|
||||
maxMp: 'çµè´ä¸é',
|
||||
damage: 'æ¯å¿',
|
||||
guard: 'æ¤å
',
|
||||
range: 'æ¯è·',
|
||||
cooldown: '忝',
|
||||
manaCost: 'çµè´æ¶è?',
|
||||
hp: '命元',
|
||||
mp: '灵韵',
|
||||
maxHp: '命元上限',
|
||||
maxMp: '灵韵上限',
|
||||
damage: '术势',
|
||||
guard: '护元',
|
||||
range: '术距',
|
||||
cooldown: '回息',
|
||||
manaCost: '灵韵消耗',
|
||||
};
|
||||
}
|
||||
return {
|
||||
hp: 'HP',
|
||||
mp: 'MP',
|
||||
maxHp: 'æå¤?HP',
|
||||
maxMp: 'æå¤?MP',
|
||||
damage: 'Damage',
|
||||
guard: 'Guard',
|
||||
range: 'Range',
|
||||
cooldown: 'Cooldown',
|
||||
manaCost: 'Mana',
|
||||
hp: '生命',
|
||||
mp: '灵力',
|
||||
maxHp: '生命上限',
|
||||
maxMp: '灵力上限',
|
||||
damage: '伤害',
|
||||
guard: '防护',
|
||||
range: '距离',
|
||||
cooldown: '冷却',
|
||||
manaCost: '消耗',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -361,6 +362,7 @@ export function getResourceLabelsForWorld(worldType: WorldType | null | undefine
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export function buildCustomCampSceneName(profile: CustomWorldProfile) {
|
||||
const presentation = getWorldPresentation(profile);
|
||||
return `${presentation.itemPrefixes[0]}${presentation.campSuffix}`;
|
||||
@@ -383,7 +385,7 @@ export function buildThemedSkillName(
|
||||
}
|
||||
|
||||
function getCategoryNouns(category: string) {
|
||||
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS['ç¨æå'];
|
||||
return CATEGORY_NOUNS[category] ?? CATEGORY_NOUNS[CATEGORY_RARE];
|
||||
}
|
||||
|
||||
function getResolvedCategoryNouns(category: string): string[] {
|
||||
@@ -413,20 +415,20 @@ export function buildThemedItemDescription(
|
||||
) {
|
||||
const seed = hashText(`${profile.id}:${category}:${rarity}:${seedKey}`);
|
||||
const hooks = [
|
||||
`Suitable for the current goal "${profile.playerGoal}".`,
|
||||
`Its tone closely matches the world tone "${profile.tone}".`,
|
||||
'Likely to appear in one of this world\'s major conflicts.',
|
||||
`It clearly ties into the expanding conflict inside this world.`,
|
||||
`适合围绕“${profile.playerGoal}”继续推进。`,
|
||||
`它的气质和“${profile.tone}”这条世界基调很贴近。`,
|
||||
'很可能会出现在这个世界的关键冲突里。',
|
||||
'能明显牵出这个世界正在扩大的主要矛盾。',
|
||||
];
|
||||
const rarityText = {
|
||||
common: '常è§',
|
||||
uncommon: 'è¿é¶',
|
||||
rare: 'Rare',
|
||||
epic: 'æ ¸å¿',
|
||||
legendary: 'å
³é®',
|
||||
common: '常见',
|
||||
uncommon: '进阶',
|
||||
rare: '稀有',
|
||||
epic: '核心',
|
||||
legendary: '关键',
|
||||
}[rarity];
|
||||
|
||||
return `${rarityText} ${category}. ${hooks[seed % hooks.length]}`;
|
||||
return `${rarityText}${category}。${hooks[seed % hooks.length]}`;
|
||||
}
|
||||
|
||||
export function inferCustomItemMechanics(
|
||||
@@ -449,7 +451,7 @@ export function inferCustomItemMechanics(
|
||||
legendary: 5,
|
||||
}[rarity];
|
||||
|
||||
if (category === 'æ¦å¨') {
|
||||
if (category === CATEGORY_WEAPON) {
|
||||
return {
|
||||
equipmentSlotId: 'weapon',
|
||||
statProfile: {
|
||||
@@ -459,7 +461,7 @@ export function inferCustomItemMechanics(
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¤ç²') {
|
||||
if (category === CATEGORY_ARMOR) {
|
||||
return {
|
||||
equipmentSlotId: 'armor',
|
||||
statProfile: {
|
||||
@@ -470,7 +472,7 @@ export function inferCustomItemMechanics(
|
||||
};
|
||||
}
|
||||
|
||||
if (category === '??' || category === '???' || category === '????') {
|
||||
if (category === CATEGORY_RELIC || category === CATEGORY_RARE || category === CATEGORY_EXCLUSIVE) {
|
||||
return {
|
||||
equipmentSlotId: 'relic',
|
||||
statProfile: {
|
||||
@@ -481,7 +483,7 @@ export function inferCustomItemMechanics(
|
||||
};
|
||||
}
|
||||
|
||||
if (category === 'æ¶èå') {
|
||||
if (category === CATEGORY_CONSUMABLE) {
|
||||
const heals = tags.includes('healing') || seed % 2 === 0;
|
||||
return {
|
||||
useProfile: heals
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {TextStreamOptions} from './aiTypes';
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_LLM_PROXY_BASE_URL || '/api/llm';
|
||||
const MODEL = import.meta.env.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
|
||||
const ENABLE_LLM_DEBUG_LOG = import.meta.env.DEV || import.meta.env.VITE_LLM_DEBUG_LOG === 'true';
|
||||
const ENV: Partial<ImportMetaEnv> = import.meta.env ?? {};
|
||||
|
||||
const API_BASE_URL = ENV.VITE_LLM_PROXY_BASE_URL || '/api/llm';
|
||||
const MODEL = ENV.VITE_LLM_MODEL || 'doubao-1-5-pro-32k-character-250715';
|
||||
const ENABLE_LLM_DEBUG_LOG = Boolean(ENV.DEV) || ENV.VITE_LLM_DEBUG_LOG === 'true';
|
||||
|
||||
export interface PlainTextCompletionOptions {
|
||||
timeoutMs?: number;
|
||||
@@ -21,9 +23,9 @@ export function resolveTimeoutMs(rawValue: string | undefined, fallback: number)
|
||||
return Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(import.meta.env.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const REQUEST_TIMEOUT_MS = resolveTimeoutMs(ENV.VITE_LLM_REQUEST_TIMEOUT_MS, 15000);
|
||||
export const CUSTOM_WORLD_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
|
||||
import.meta.env.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
ENV.VITE_LLM_CUSTOM_WORLD_TIMEOUT_MS,
|
||||
Math.max(REQUEST_TIMEOUT_MS, 45000),
|
||||
);
|
||||
|
||||
|
||||
@@ -693,7 +693,9 @@ function describeProvidedOptionCore(option: StoryOption) {
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'gift') {
|
||||
return '向面前角色送礼,以改善关系或表达诚意。';
|
||||
return option.detailText
|
||||
? `向面前角色送礼,以改善关系或表达诚意。当前礼物线索:${option.detailText}`
|
||||
: '向面前角色送礼,以改善关系或表达诚意。';
|
||||
}
|
||||
|
||||
if (option.interaction?.kind === 'npc' && option.interaction.action === 'recruit') {
|
||||
|
||||
94
src/services/runtimeItemAiDirector.ts
Normal file
94
src/services/runtimeItemAiDirector.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
RuntimeItemAiIntent,
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
} from '../types';
|
||||
import {buildRuntimeItemAiIntent} from '../data/runtimeItemNarrative';
|
||||
import {requestChatMessageContent} from './llmClient';
|
||||
import {parseJsonResponseText} from './llmParsers';
|
||||
import {
|
||||
buildRuntimeItemIntentPrompt,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
} from './runtimeItemAiPrompt';
|
||||
|
||||
const RUNTIME_ITEM_INTENT_TIMEOUT_MS = 9000;
|
||||
|
||||
function coerceString(value: unknown, fallback: string) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : fallback;
|
||||
}
|
||||
|
||||
function coerceStringArray(value: unknown, fallback: string[], limit: number) {
|
||||
if (!Array.isArray(value)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.map(item => (typeof item === 'string' ? item.trim() : ''))
|
||||
.filter(Boolean)
|
||||
.slice(0, limit);
|
||||
|
||||
return normalized.length > 0 ? normalized : fallback;
|
||||
}
|
||||
|
||||
function sanitizeRuntimeItemAiIntent(
|
||||
rawIntent: unknown,
|
||||
fallback: RuntimeItemAiIntent,
|
||||
): RuntimeItemAiIntent {
|
||||
if (!rawIntent || typeof rawIntent !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const intent = rawIntent as Record<string, unknown>;
|
||||
const desiredFunctionalBias = coerceStringArray(
|
||||
intent.desiredFunctionalBias,
|
||||
fallback.desiredFunctionalBias,
|
||||
2,
|
||||
).filter(
|
||||
(
|
||||
item,
|
||||
): item is RuntimeItemAiIntent['desiredFunctionalBias'][number] =>
|
||||
['heal', 'mana', 'cooldown', 'guard', 'damage'].includes(item),
|
||||
);
|
||||
const tone = coerceString(intent.tone, fallback.tone);
|
||||
|
||||
return {
|
||||
shortNameSeed: coerceString(intent.shortNameSeed, fallback.shortNameSeed),
|
||||
sourcePhrase: coerceString(intent.sourcePhrase, fallback.sourcePhrase),
|
||||
reasonToAppear: coerceString(intent.reasonToAppear, fallback.reasonToAppear),
|
||||
relationHooks: coerceStringArray(intent.relationHooks, fallback.relationHooks, 2),
|
||||
desiredBuildTags: coerceStringArray(intent.desiredBuildTags, fallback.desiredBuildTags, 3),
|
||||
desiredFunctionalBias:
|
||||
desiredFunctionalBias.length > 0
|
||||
? desiredFunctionalBias
|
||||
: fallback.desiredFunctionalBias,
|
||||
tone: ['grim', 'mysterious', 'martial', 'ritual', 'survival'].includes(tone)
|
||||
? (tone as RuntimeItemAiIntent['tone'])
|
||||
: fallback.tone,
|
||||
};
|
||||
}
|
||||
|
||||
export async function generateRuntimeItemAiIntents(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
const fallbackIntents = params.plans.map(plan =>
|
||||
buildRuntimeItemAiIntent(params.context, plan),
|
||||
);
|
||||
|
||||
const content = await requestChatMessageContent(
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
buildRuntimeItemIntentPrompt(params),
|
||||
{
|
||||
timeoutMs: RUNTIME_ITEM_INTENT_TIMEOUT_MS,
|
||||
debugLabel: 'runtime-item-intent',
|
||||
},
|
||||
);
|
||||
const parsed = parseJsonResponseText(content) as {
|
||||
intents?: unknown[];
|
||||
};
|
||||
const rawIntents = Array.isArray(parsed.intents) ? parsed.intents : [];
|
||||
|
||||
return params.plans.map((_, index) =>
|
||||
sanitizeRuntimeItemAiIntent(rawIntents[index], fallbackIntents[index]),
|
||||
);
|
||||
}
|
||||
85
src/services/runtimeItemAiPrompt.ts
Normal file
85
src/services/runtimeItemAiPrompt.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import {buildRuntimeItemAiPromptInput} from '../data/runtimeItemNarrative';
|
||||
import type {
|
||||
RuntimeItemGenerationContext,
|
||||
RuntimeItemPlan,
|
||||
RuntimeRelationAnchor,
|
||||
} from '../types';
|
||||
|
||||
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
|
||||
switch (anchor.type) {
|
||||
case 'npc':
|
||||
return `NPC:${anchor.npcName}`;
|
||||
case 'scene':
|
||||
return `场景:${anchor.sceneName}`;
|
||||
case 'monster':
|
||||
return `怪物:${anchor.monsterName}`;
|
||||
case 'quest':
|
||||
return `任务:${anchor.questName}`;
|
||||
case 'faction':
|
||||
return `势力:${anchor.factionName}`;
|
||||
default:
|
||||
return `地标:${anchor.landmarkName}`;
|
||||
}
|
||||
}
|
||||
|
||||
function describePlan(
|
||||
context: RuntimeItemGenerationContext,
|
||||
plan: RuntimeItemPlan,
|
||||
index: number,
|
||||
) {
|
||||
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
|
||||
|
||||
return [
|
||||
`物品 ${index + 1}`,
|
||||
`- slot: ${plan.slot}`,
|
||||
`- 物品类型: ${promptInput.desiredItemKind}`,
|
||||
`- 持续性: ${promptInput.permanence}`,
|
||||
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
|
||||
`- 世界摘要: ${promptInput.worldSummary}`,
|
||||
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
|
||||
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
|
||||
`- 相关人物: ${promptInput.relatedNpcSummary}`,
|
||||
`- 近期剧情: ${promptInput.recentStorySummary}`,
|
||||
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
|
||||
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
|
||||
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
|
||||
你只返回 JSON,不要输出 Markdown、解释或代码块。
|
||||
|
||||
输出结构:
|
||||
{
|
||||
"intents": [
|
||||
{
|
||||
"shortNameSeed": "中文短种子",
|
||||
"sourcePhrase": "中文来源短语",
|
||||
"reasonToAppear": "中文出现理由",
|
||||
"relationHooks": ["中文关系钩子"],
|
||||
"desiredBuildTags": ["中文 build 标签"],
|
||||
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
|
||||
"tone": "grim|mysterious|martial|ritual|survival"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
规则:
|
||||
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
|
||||
- 所有自然语言字段都必须使用中文。
|
||||
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
|
||||
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
|
||||
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
|
||||
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
|
||||
|
||||
export function buildRuntimeItemIntentPrompt(params: {
|
||||
context: RuntimeItemGenerationContext;
|
||||
plans: RuntimeItemPlan[];
|
||||
}) {
|
||||
return [
|
||||
`生成渠道:${params.context.generationChannel}`,
|
||||
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
|
||||
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
|
||||
'请严格返回 JSON。',
|
||||
].join('\n\n');
|
||||
}
|
||||
Reference in New Issue
Block a user