Implement scene-based chapter quest progression
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-08 11:58:47 +08:00
parent 9d2fc9e4b8
commit bd9fdcbe31
170 changed files with 18259 additions and 1049 deletions

View File

@@ -14,6 +14,7 @@ import {
CharacterBackstoryChapter,
CharacterBackstoryRevealConfig,
CustomWorldAnchorPack,
CustomWorldCampScene,
CustomWorldItem,
CustomWorldLandmark,
CustomWorldNpc,
@@ -27,6 +28,7 @@ import {
WorldType,
} from '../types';
import { generateWorldAttributeSchema } from './attributeSchemaGenerator';
import { buildFallbackCustomWorldCampScene } from './customWorldCamp';
import {
buildCustomWorldAnchorPackFromIntent,
deriveCustomWorldLockStateFromIntent,
@@ -104,6 +106,12 @@ export interface CustomWorldGenerationLandmarkOutline {
connections: CustomWorldGenerationLandmarkConnectionOutline[];
}
export interface CustomWorldGenerationCampOutline {
name: string;
description: string;
dangerLevel: string;
}
export interface CustomWorldGenerationFramework {
settingText: string;
name: string;
@@ -114,6 +122,7 @@ export interface CustomWorldGenerationFramework {
templateWorldType: WorldType;
majorFactions: string[];
coreConflicts: string[];
camp: CustomWorldGenerationCampOutline;
playableNpcs: CustomWorldGenerationRoleOutline[];
storyNpcs: CustomWorldGenerationRoleOutline[];
landmarks: CustomWorldGenerationLandmarkOutline[];
@@ -508,32 +517,28 @@ function buildSeedPhrase(settingText: string, fallback: string) {
}
function buildWorldName(settingText: string, worldType: WorldType) {
const seed = buildSeedPhrase(
settingText,
worldType === WorldType.XIANXIA ? '灵潮' : '江湖',
);
const suffix = worldType === WorldType.XIANXIA ? '界' : '录';
const seed = buildSeedPhrase(settingText, '新旅');
const suffix = worldType === WorldType.XIANXIA ? '境' : '域';
return `${seed}${suffix}`;
}
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
const templateWorldType = inferWorldTypeFromSetting(settingText);
const name = buildWorldName(settingText, templateWorldType);
const subtitle =
templateWorldType === WorldType.XIANXIA ? '灵潮未定' : '风云将起';
const subtitle = '前路未明';
const summary = settingText.trim()
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
: templateWorldType === WorldType.XIANXIA
? '灵潮未定,旧秩序正在崩裂。'
: '旧案复起,江湖格局正在改变。';
const tone =
templateWorldType === WorldType.XIANXIA
? '空灵、危险、层层递进'
: '紧张、克制、暗流涌动';
const playerGoal =
templateWorldType === WorldType.XIANXIA
? '查清异变源头,在诸方势力之前抢到关键线索'
: '沿着旧案痕迹追查幕后之人,并守住仍值得相信的人与路';
: '一个仍待展开的独立世界正在成形。';
const tone = '未知、紧绷、仍在展开';
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
const camp = buildFallbackCustomWorldCampScene({
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
templateWorldType,
});
return {
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
@@ -559,6 +564,7 @@ function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
playableNpcs: [],
storyNpcs: [],
items: [],
camp,
landmarks: [],
themePack: null,
storyGraph: null,
@@ -592,6 +598,11 @@ export function normalizeCustomWorldGenerationFramework(
templateWorldType: fallback.templateWorldType,
majorFactions: [],
coreConflicts: [fallback.summary],
camp: {
name: fallback.camp?.name ?? '归舍',
description: fallback.camp?.description ?? '',
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
},
playableNpcs: [],
storyNpcs: [],
landmarks: [],
@@ -623,6 +634,14 @@ export function normalizeCustomWorldGenerationFramework(
templateWorldType,
majorFactions: normalizeTags(item.majorFactions, []),
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
camp: normalizeCampOutline(item.camp, {
name,
summary: toText(item.summary) || fallback.summary,
tone: toText(item.tone) || fallback.tone,
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
settingText: settingText.trim(),
templateWorldType,
}),
playableNpcs: normalizeRoleOutlineList(item.playableNpcs, {
titleFallback: '未定称号',
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
@@ -649,6 +668,11 @@ export function buildCustomWorldRawProfileFromFramework(
templateWorldType: framework.templateWorldType,
majorFactions: framework.majorFactions,
coreConflicts: framework.coreConflicts,
camp: {
name: framework.camp.name,
description: framework.camp.description,
dangerLevel: framework.camp.dangerLevel,
},
playableNpcs: framework.playableNpcs.map((npc) => ({
name: npc.name,
title: npc.title,
@@ -818,6 +842,24 @@ function normalizeRoleOutlineList(
return normalized;
}
function normalizeCampOutline(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
): CustomWorldGenerationCampOutline {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
};
}
function normalizeLandmarkOutlineList(value: unknown) {
return toRecordArray(value)
.map((item) => {
@@ -910,6 +952,25 @@ function normalizeLandmarkDraftList(value: unknown) {
.filter((entry) => entry.name);
}
function normalizeCampScene(
value: unknown,
fallbackProfile: Pick<
CustomWorldProfile,
'name' | 'summary' | 'tone' | 'playerGoal' | 'settingText' | 'templateWorldType'
>,
): CustomWorldCampScene {
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
const item =
value && typeof value === 'object' ? (value as Record<string, unknown>) : {};
return {
name: toText(item.name) || fallback.name,
description: toText(item.description) || fallback.description,
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
imageSrc: toText(item.imageSrc) || undefined,
};
}
export function normalizeCustomWorldProfile(
raw: unknown,
settingText: string,
@@ -949,6 +1010,14 @@ export function normalizeCustomWorldProfile(
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
const landmarkDrafts = normalizeLandmarkDraftList(item.landmarks);
const camp = normalizeCampScene(item.camp, {
name,
summary,
tone,
playerGoal,
settingText: settingText.trim(),
templateWorldType,
});
return {
id:
@@ -970,6 +1039,7 @@ export function normalizeCustomWorldProfile(
playableNpcs,
storyNpcs,
items: normalizeItemList(item.items),
camp,
landmarks: normalizeCustomWorldLandmarks({
landmarks: landmarkDrafts,
storyNpcs,
@@ -1033,6 +1103,7 @@ function buildFrameworkSummaryText(
framework.coreConflicts.length > 0
? `核心冲突:${framework.coreConflicts.join('、')}`
: '',
`开局归处:${framework.camp.name}${framework.camp.description}`,
landmarkText ? `关键场景:${landmarkText}` : '',
]
.filter(Boolean)
@@ -1113,13 +1184,17 @@ export function validateCustomWorldGenerationFramework(
`自定义世界框架至少需要 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅有 ${landmarkCount} 个。`,
);
}
if (!framework.camp.name.trim() || !framework.camp.description.trim()) {
throw new Error('自定义世界框架必须包含一个有效的开局归处场景。');
}
}
export function buildCustomWorldFrameworkPrompt(settingText: string) {
return [
'请先根据下面的玩家设定创建一份“世界核心骨架”,后续我会分步骤生成角色名单、场景名单和详细档案。',
'你必须只输出一个能被 JSON.parse 直接解析的 JSON 对象,不要输出 Markdown、代码块、注释或解释。',
'这一步只保留世界顶层信息,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。',
'这一步只保留世界顶层信息与一个开局归处场景,不要输出 playableNpcs、storyNpcs、landmarks也不要展开人物和地图细节。',
'玩家设定:',
settingText.trim(),
'',
@@ -1132,16 +1207,24 @@ export function buildCustomWorldFrameworkPrompt(settingText: string) {
' "playerGoal": "玩家核心目标",',
' "templateWorldType": "WUXIA|XIANXIA",',
' "majorFactions": ["势力甲", "势力乙"],',
' "coreConflicts": ["冲突甲", "冲突乙"]',
' "coreConflicts": ["冲突甲", "冲突乙"],',
' "camp": {',
' "name": "开局归处名称",',
' "description": "这是玩家进入世界后的第一处落脚点描述",',
' "dangerLevel": "low|medium|high|extreme"',
' }',
'}',
'',
'要求:',
'- 所有生成文本都必须使用中文。',
'- 这一步只输出顶层 8 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts。',
'- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和场景细节。',
'- 这一步只输出顶层 9 个字段name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
'- 这是一个完全独立的自定义世界;不要在任何正文里直接写出“武侠世界”“仙侠世界”等现成世界名。',
'- templateWorldType 只是系统兼容字段,不代表正文应当引用的世界名称。',
'- camp 必须表示玩家开局时的落脚处,名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
'- 不要输出 playableNpcs、storyNpcs、landmarks、items也不要输出任何角色和地图细节。',
'- majorFactions 保持 2 到 3 个coreConflicts 保持 2 到 3 个。',
'- 世界设定必须直接源自玩家输入,不要脱离主题乱扩写。',
'- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内。',
'- 每个字符串尽量简洁subtitle 控制在 8 到 18 个汉字内summary 控制在 16 到 32 个汉字内tone 控制在 6 到 16 个汉字内playerGoal 控制在 16 到 32 个汉字内camp.description 控制在 18 到 40 个汉字内。',
'- 返回前自检:必须是一个能被 JSON.parse 直接解析的单个 JSON 对象。',
].join('\n');
}
@@ -1392,9 +1475,10 @@ export function buildCustomWorldFrameworkJsonRepairPrompt(
return [
'下面这段文本本应是自定义世界核心骨架的单个 JSON 对象,但当前不能被 JSON.parse 直接解析。',
'请只输出修复后的 JSON 对象。',
'顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts。',
'顶层必须只包含name、subtitle、summary、tone、playerGoal、templateWorldType、majorFactions、coreConflicts、camp。',
'不要输出 playableNpcs、storyNpcs、landmarks、items 或任何其他字段。',
'majorFactions 与 coreConflicts 必须是字符串数组。',
'camp 必须是对象且包含name、description、dangerLevel。',
'原始文本:',
responseText.trim(),
].join('\n');
@@ -1437,6 +1521,7 @@ export function buildCustomWorldRoleOutlineBatchPrompt(params: {
'',
'要求:',
`- 必须生成恰好 ${batchCount}${label}`,
'- 这是一个完全独立的自定义世界;不要把角色写成来自“武侠世界”“仙侠世界”等现成世界。',
'- 名称必须具体且互不重复,不要使用 角色1、NPC1、场景角色1 之类的占位名。',
'- 只保留name、title、role、description、initialAffinity、relationshipHooks、tags。',
'- relationshipHooks 最多 1 条tags 保持 1 到 2 个。',
@@ -1509,6 +1594,7 @@ export function buildCustomWorldLandmarkSeedBatchPrompt(params: {
'',
'要求:',
`- 必须生成恰好 ${batchCount} 个 landmarks。`,
'- 这是一个完全独立的自定义世界;不要在场景描述里直接写“武侠世界”“仙侠世界”等现成世界名。',
'- 这一步只保留name、description、dangerLevel。',
'- 不要输出 sceneNpcNames、connections、imageSrc 或其他字段。',
'- 名称必须具体且互不重复,不要使用 场景1、地点1 之类的占位名。',
@@ -1590,6 +1676,7 @@ export function buildCustomWorldLandmarkNetworkBatchPrompt(params: {
'',
'要求:',
`- 只输出这批 ${landmarkBatch.length} 个场景,不要输出其他场景。`,
'- 这是一个完全独立的自定义世界summary 不要带入“武侠”“仙侠”等现成世界名称。',
'- 名称必须与本批次场景骨架完全一致,不得改名。',
'- 每个场景必须提供恰好 3 个唯一 sceneNpcNames且只能从可用场景角色名里选择。',
`- 每个场景必须提供恰好 2 条 connectionsrelativePosition 只能使用:${relativePositionValues}`,
@@ -1662,6 +1749,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'',
'要求:',
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
'- 这是一个完全独立的自定义世界;不要在角色背景、性格、动机或战斗风格里直接写“武侠世界”“仙侠世界”等现成世界名。',
`- ${key} 的数量必须与本批次名单完全一致。`,
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 只补全 backstory、personality、motivation、combatStyle 这 4 个字段,不要输出 backstoryReveal、skills、initialItems。',
@@ -1717,6 +1805,7 @@ export function buildCustomWorldRoleBatchPrompt(params: {
'',
'要求:',
`- 只输出这批${label},不要输出其他角色、场景或额外顶层字段。`,
'- 这是一个完全独立的自定义世界;不要在公开背景、技能名、物品名或说明里直接带入“武侠”“仙侠”等现成世界名。',
`- ${key} 的数量必须与本批次名单完全一致。`,
'- 名称必须与批次名单完全一致,不得增删改名。',
'- 这一阶段只补全 backstoryReveal、skills、initialItems不要重复输出 title、role、description、backstory、personality、motivation、combatStyle、initialAffinity、relationshipHooks、tags。',
@@ -1791,6 +1880,11 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
' "playerGoal": "玩家核心目标",',
' "majorFactions": ["势力甲", "势力乙"],',
' "coreConflicts": ["冲突甲", "冲突乙"],',
' "camp": {',
' "name": "开局归处名称",',
' "description": "玩家进入世界后的第一处落脚点描述",',
' "dangerLevel": "low|medium|high|extreme"',
' },',
' "playableNpcs": [',
' {',
' "name": "角色名称",',
@@ -1878,6 +1972,7 @@ export function buildCustomWorldGenerationPrompt(settingText: string) {
'',
'要求:',
'- 所有生成文本都必须使用中文。',
'- camp 必须存在,代表玩家开局时的落脚处;名字不要直接写成“某某营地”,更接近归舍、住处、栖居、前哨居所这类“家/归处”的概念。',
'- 必须生成恰好 5 个 playableNpcs。',
'- 至少生成 25 个 storyNpcs并保证 playableNpcs + storyNpcs 的唯一名称总数不少于 30。',
'- 至少生成 10 个真正可游玩的 landmarks。',
@@ -1982,6 +2077,7 @@ export function buildCustomWorldReferenceText(
`世界概述:${profile.summary}`,
`世界基调:${profile.tone}`,
`玩家核心目标:${profile.playerGoal}`,
`开局归处:${profile.camp?.name ?? '未设定'}${profile.camp?.description ? `${profile.camp.description}` : ''}`,
`题材适配层:${themePack.displayName};制度词汇 ${themePack.institutionLexicon.slice(0, 4).join('、')};禁忌词 ${themePack.tabooLexicon.slice(0, 4).join('、')};载体类型 ${themePack.artifactClasses.slice(0, 4).join('、')}`,
`当前激活线程:\n${activeThreads.map((thread) => `- ${thread.title}${thread.summary}`).join('\n') || '- 暂无'}`,
`世界属性轴:${profile.attributeSchema.slots.map((slot) => `${slot.name}${slot.definition}`).join('')}`,