1
This commit is contained in:
@@ -9,7 +9,7 @@ import type {
|
||||
type NpcChatTurnCompletionDirective,
|
||||
NpcChatTurnRequest,
|
||||
NpcRecruitDialogueRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
|
||||
import { parseLineListContent } from '../../../../packages/shared/src/llm/parsers.js';
|
||||
import { prepareEventStreamResponse } from '../../http.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
|
||||
@@ -4,7 +4,7 @@ import test from 'node:test';
|
||||
import type {
|
||||
CharacterChatSuggestionsRequest,
|
||||
NpcChatTurnRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeChat.js';
|
||||
import { createTestPlayerCharacter } from '../../testFixtures/runtimeCharacter.js';
|
||||
import {
|
||||
generateCharacterChatSuggestionsFromOrchestrator,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type {
|
||||
RuntimeBattlePresentation,
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import type {
|
||||
RuntimeStoryChoicePayload,
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
|
||||
import {
|
||||
buildInventoryUseResultText,
|
||||
incrementGameRuntimeStats,
|
||||
@@ -26,7 +28,7 @@ import {
|
||||
getPlayerSkillCooldowns,
|
||||
setEncounterNpcState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
|
||||
|
||||
type CombatActionConfig = {
|
||||
actionText: string;
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
import type {
|
||||
AttributeVector,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
RoleAttributeProfile,
|
||||
WorldAttributeSchema,
|
||||
WorldAttributeSlot,
|
||||
WorldType,
|
||||
} from '../runtimeTypes.js';
|
||||
import { inferWorldTypeFromSetting } from './creatorIntentBridge.js';
|
||||
import { slugify } from './normalizeShared.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 把 attribute schema 构建和角色属性画像编译从主 runtime compiler 中抽离,
|
||||
* 让结果预览编译、世界基础 profile 归一和角色属性推导有清晰边界。
|
||||
*/
|
||||
|
||||
const WORLD_ATTRIBUTE_SLOT_IDS = [
|
||||
'axis_a',
|
||||
'axis_b',
|
||||
'axis_c',
|
||||
'axis_d',
|
||||
'axis_e',
|
||||
'axis_f',
|
||||
] as const;
|
||||
|
||||
const AXIS_KEYWORD_RULES: Array<{
|
||||
slotId: string;
|
||||
patterns: RegExp[];
|
||||
weight: number;
|
||||
}> = [
|
||||
{ slotId: 'axis_a', patterns: [/骨|甲|壳|岩|重|守|镇|顶|硬|锋|体/u], weight: 16 },
|
||||
{ slotId: 'axis_b', patterns: [/身法|迅|影|风|闪|游|机动|追|步|轻灵/u], weight: 16 },
|
||||
{ slotId: 'axis_c', patterns: [/识|眼|谋|算|阵|符|术|察|局|禁制/u], weight: 16 },
|
||||
{ slotId: 'axis_d', patterns: [/心|焰|胆|威|压|怒|决|破|强推|意志/u], weight: 16 },
|
||||
{ slotId: 'axis_e', patterns: [/缘|契|情|盟|商|医|助|信|交|誓/u], weight: 16 },
|
||||
{ slotId: 'axis_f', patterns: [/息|稳|续|守|调|回|养|持久|回复|韧/u], weight: 16 },
|
||||
];
|
||||
|
||||
export function buildTemplateWorldAttributeSchema(
|
||||
worldType: Exclude<WorldType, 'CUSTOM'>,
|
||||
) {
|
||||
const common = {
|
||||
schemaVersion: 1,
|
||||
generatedFrom:
|
||||
worldType === 'XIANXIA'
|
||||
? {
|
||||
worldType: 'XIANXIA' as const,
|
||||
worldName: '仙侠',
|
||||
settingSummary: '灵潮、宗门、禁制、秘境与道途交织。',
|
||||
tone: '空灵、危险、带着灾变与大道压迫。',
|
||||
conflictCore: '在裂变与因果之间稳住自我与道途。',
|
||||
}
|
||||
: {
|
||||
worldType: 'WUXIA' as const,
|
||||
worldName: '武侠',
|
||||
settingSummary: '江湖、门派、旧案与人情纠葛并存。',
|
||||
tone: '克制、紧张、讲究局势与心气。',
|
||||
conflictCore: '在人情、威压与旧案之间立住自身。',
|
||||
},
|
||||
};
|
||||
|
||||
if (worldType === 'XIANXIA') {
|
||||
return {
|
||||
id: 'schema:xianxia:v1',
|
||||
worldId: 'XIANXIA',
|
||||
schemaName: '灵界六轴',
|
||||
...common,
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '道骨',
|
||||
definition: '承载道压与高强度冲击的底子。',
|
||||
positiveSignals: ['承压', '根基稳', '扛得住'],
|
||||
negativeSignals: ['根基浅', '易溃', '承载不足'],
|
||||
combatUseText: '扛住灵压、正面承受高强度对撞。',
|
||||
socialUseText: '让人感到根基扎实,值得托付重事。',
|
||||
explorationUseText: '承受秘境、禁制与裂隙带来的压迫。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '灵行',
|
||||
definition: '位移、御空、转场、抢占天时地利的能力。',
|
||||
positiveSignals: ['位移', '御空', '机动'],
|
||||
negativeSignals: ['迟滞', '失位', '转场慢'],
|
||||
combatUseText: '抢位、御空、快速重整战场位置。',
|
||||
socialUseText: '反应轻快,擅长顺势接住局面的变化。',
|
||||
explorationUseText: '穿越高危地形、裂隙、云海与复杂禁区。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '识海',
|
||||
definition: '解析禁制、洞察因果、识破虚实的能力。',
|
||||
positiveSignals: ['洞察', '解构', '看破'],
|
||||
negativeSignals: ['迷失', '误判', '看不清'],
|
||||
combatUseText: '识破术理、找出因果节点与破绽。',
|
||||
socialUseText: '更容易辨认真话、虚言与隐藏动机。',
|
||||
explorationUseText: '解读阵纹、禁制、旧史与环境异象。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '劫纹',
|
||||
definition: '在高危变化中强行推进、改写局势的能力。',
|
||||
positiveSignals: ['强推', '决断', '逆转'],
|
||||
negativeSignals: ['畏缩', '迟疑', '不敢碰变局'],
|
||||
combatUseText: '在高压窗口里压上去,逼出变化与突破。',
|
||||
socialUseText: '在关键谈判中拍板,推动他人表态。',
|
||||
explorationUseText: '面对异变与风险时敢于推进关键节点。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '心契',
|
||||
definition: '与他者、器物、灵兽、誓约建立共鸣的能力。',
|
||||
positiveSignals: ['共鸣', '结契', '安抚'],
|
||||
negativeSignals: ['隔阂', '生硬', '难以共振'],
|
||||
combatUseText: '与器物、灵兽、同伴形成协同与共鸣。',
|
||||
socialUseText: '建立信任、誓约与更深层的关系连结。',
|
||||
explorationUseText: '借由共鸣打开封印、回应遗物或安抚异兽。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '循环灵息、稳住心神、让自身持续在线的能力。',
|
||||
positiveSignals: ['稳态', '回转', '续航'],
|
||||
negativeSignals: ['紊乱', '枯竭', '失衡'],
|
||||
combatUseText: '维持灵息循环、拖住长线压力与消耗。',
|
||||
socialUseText: '气息沉稳,不轻易乱阵脚或露出破绽。',
|
||||
explorationUseText: '在漫长探索与灵潮侵蚀中维持可行动状态。',
|
||||
},
|
||||
] satisfies WorldAttributeSlot[],
|
||||
} satisfies WorldAttributeSchema;
|
||||
}
|
||||
|
||||
return {
|
||||
id: 'schema:wuxia:v1',
|
||||
worldId: 'WUXIA',
|
||||
schemaVersion: 1,
|
||||
schemaName: '江湖六脉',
|
||||
generatedFrom: common.generatedFrom,
|
||||
slots: [
|
||||
{
|
||||
slotId: 'axis_a',
|
||||
name: '骨势',
|
||||
definition: '扛压、顶冲、硬吃风险也不退的势头。',
|
||||
positiveSignals: ['扛压', '硬桥硬马', '稳住正面'],
|
||||
negativeSignals: ['虚浮', '怯退', '一碰就散'],
|
||||
combatUseText: '顶住正面压力、换伤不退、撑住阵线。',
|
||||
socialUseText: '在强压场面里不露怯,给人可靠或强硬之感。',
|
||||
explorationUseText: '穿越险路、硬顶机关、承受高压环境。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_b',
|
||||
name: '身法',
|
||||
definition: '腾挪、抢位、换线、把握出手节奏的能力。',
|
||||
positiveSignals: ['快', '轻灵', '抢位'],
|
||||
negativeSignals: ['迟缓', '失位', '笨重'],
|
||||
combatUseText: '切线换位、闪转腾挪、争夺先手。',
|
||||
socialUseText: '应变快,擅长观察气口并顺势接话。',
|
||||
explorationUseText: '攀越、潜入、追踪与复杂地形穿行。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_c',
|
||||
name: '眼脉',
|
||||
definition: '看破破绽、拆招、识局、看穿人心的能力。',
|
||||
positiveSignals: ['识局', '洞察', '拆招'],
|
||||
negativeSignals: ['迟钝', '误判', '看不透'],
|
||||
combatUseText: '抓破绽、拆套路、找出最该切入的位置。',
|
||||
socialUseText: '判断弦外之音、试探真假、识别来意。',
|
||||
explorationUseText: '识破机关、辨认痕迹、看懂异状。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_d',
|
||||
name: '心焰',
|
||||
definition: '决断、压迫、胆气、在局面中立住自身意志的能力。',
|
||||
positiveSignals: ['胆气', '决断', '压迫'],
|
||||
negativeSignals: ['犹疑', '软弱', '易被动摇'],
|
||||
combatUseText: '逼迫对手、强行推进、在关键时刻拍板。',
|
||||
socialUseText: '立威、定调、在谈判里压住场子。',
|
||||
explorationUseText: '在未知风险前保持决断,不被局势拖死。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_e',
|
||||
name: '尘缘',
|
||||
definition: '与人事、情面、承诺、牵引关系打交道的能力。',
|
||||
positiveSignals: ['通人情', '会安抚', '懂交换'],
|
||||
negativeSignals: ['生硬', '失礼', '不近人情'],
|
||||
combatUseText: '借势协同、读懂同伴与对手的关系脉络。',
|
||||
socialUseText: '安抚、求助、结盟、维系承诺与信任。',
|
||||
explorationUseText: '从传闻、人脉和地方关系里打开线索。',
|
||||
},
|
||||
{
|
||||
slotId: 'axis_f',
|
||||
name: '玄息',
|
||||
definition: '调息、稳态、久战、把自身维持在可用状态的能力。',
|
||||
positiveSignals: ['稳', '续战', '调息'],
|
||||
negativeSignals: ['紊乱', '易崩', '续不上'],
|
||||
combatUseText: '续战、回气、稳住节奏与状态。',
|
||||
socialUseText: '遇事不乱,语气和姿态都更沉稳可信。',
|
||||
explorationUseText: '长线跋涉、在恶劣环境下维持专注与状态。',
|
||||
},
|
||||
] satisfies WorldAttributeSlot[],
|
||||
} satisfies WorldAttributeSchema;
|
||||
}
|
||||
|
||||
export function generateWorldAttributeSchema(input: {
|
||||
worldName: string;
|
||||
settingText: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
}) {
|
||||
const inferredWorldType = inferWorldTypeFromSetting(input.settingText);
|
||||
const template = buildTemplateWorldAttributeSchema(
|
||||
inferredWorldType === 'XIANXIA' ? 'XIANXIA' : 'WUXIA',
|
||||
);
|
||||
|
||||
return {
|
||||
...template,
|
||||
id: `schema:custom:${slugify(input.worldName)}`,
|
||||
worldId: `custom:${input.worldName}`,
|
||||
generatedFrom: {
|
||||
worldType: 'CUSTOM',
|
||||
worldName: input.worldName,
|
||||
settingSummary: input.summary,
|
||||
tone: input.tone,
|
||||
conflictCore: input.playerGoal,
|
||||
},
|
||||
} satisfies WorldAttributeSchema;
|
||||
}
|
||||
|
||||
function normalizeAttributeValues(
|
||||
values: AttributeVector,
|
||||
slotIds: readonly string[],
|
||||
targetTotal = 360,
|
||||
) {
|
||||
const positiveValues = slotIds.map((slotId) => Math.max(0, values[slotId] ?? 0));
|
||||
const rawTotal = positiveValues.reduce((sum, value) => sum + value, 0);
|
||||
const normalized =
|
||||
rawTotal > 0
|
||||
? positiveValues.map((value) => (value / rawTotal) * targetTotal)
|
||||
: slotIds.map(() => targetTotal / Math.max(slotIds.length, 1));
|
||||
const rounded = normalized.map((value) => Math.max(0, Math.min(100, Math.round(value))));
|
||||
return Object.fromEntries(
|
||||
slotIds.map((slotId, index) => [slotId, rounded[index] ?? 0]),
|
||||
) as AttributeVector;
|
||||
}
|
||||
|
||||
function ensureRoleAttributeProfile(
|
||||
profile: Partial<RoleAttributeProfile> | null | undefined,
|
||||
schema: WorldAttributeSchema,
|
||||
fallbackValues: AttributeVector,
|
||||
): RoleAttributeProfile {
|
||||
const slotIds = schema.slots.map((slot) => slot.slotId);
|
||||
const values = normalizeAttributeValues(
|
||||
{
|
||||
...fallbackValues,
|
||||
...(profile?.values ?? {}),
|
||||
},
|
||||
slotIds,
|
||||
);
|
||||
const sortedSlots = [...schema.slots]
|
||||
.map((slot) => ({
|
||||
slot,
|
||||
value: values[slot.slotId] ?? 0,
|
||||
}))
|
||||
.sort((left, right) => right.value - left.value);
|
||||
|
||||
return {
|
||||
schemaId: profile?.schemaId ?? schema.id,
|
||||
values,
|
||||
topTraits: sortedSlots.slice(0, 2).map((entry) => entry.slot.name),
|
||||
hiddenTraits: profile?.hiddenTraits ? [...profile.hiddenTraits] : undefined,
|
||||
evidence:
|
||||
profile?.evidence?.length
|
||||
? [...profile.evidence]
|
||||
: sortedSlots.slice(0, 3).map((entry) => ({
|
||||
slotId: entry.slot.slotId,
|
||||
reason: `${entry.slot.name}在当前画像中最突出。`,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function buildDefaultAxisVector(
|
||||
overrides: Partial<Record<(typeof WORLD_ATTRIBUTE_SLOT_IDS)[number], number>>,
|
||||
) {
|
||||
return WORLD_ATTRIBUTE_SLOT_IDS.reduce<AttributeVector>((result, slotId) => {
|
||||
result[slotId] = overrides[slotId] ?? 0;
|
||||
return result;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function buildRoleAttributeProfileFromTexts(params: {
|
||||
schema: WorldAttributeSchema;
|
||||
textBlocks: Array<string | null | undefined>;
|
||||
}) {
|
||||
const sourceText = params.textBlocks.filter(Boolean).join(' ');
|
||||
const seed = buildDefaultAxisVector({
|
||||
axis_a: 58,
|
||||
axis_b: 58,
|
||||
axis_c: 58,
|
||||
axis_d: 58,
|
||||
axis_e: 58,
|
||||
axis_f: 58,
|
||||
});
|
||||
|
||||
AXIS_KEYWORD_RULES.forEach((rule) => {
|
||||
const matches = rule.patterns.reduce(
|
||||
(count, pattern) => count + (pattern.test(sourceText) ? 1 : 0),
|
||||
0,
|
||||
);
|
||||
if (matches <= 0) {
|
||||
return;
|
||||
}
|
||||
seed[rule.slotId] = (seed[rule.slotId] ?? 0) + rule.weight * matches;
|
||||
});
|
||||
|
||||
return ensureRoleAttributeProfile(
|
||||
{
|
||||
schemaId: params.schema.id,
|
||||
},
|
||||
params.schema,
|
||||
seed,
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCustomWorldPlayableNpcAttributeProfile(
|
||||
npc: CustomWorldPlayableNpc,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromTexts({
|
||||
schema,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
export function buildCustomWorldStoryNpcAttributeProfile(
|
||||
npc: CustomWorldNpc,
|
||||
schema: WorldAttributeSchema,
|
||||
) {
|
||||
return buildRoleAttributeProfileFromTexts({
|
||||
schema,
|
||||
textBlocks: [
|
||||
npc.title,
|
||||
npc.role,
|
||||
npc.description,
|
||||
npc.backstory,
|
||||
npc.personality,
|
||||
npc.motivation,
|
||||
npc.combatStyle,
|
||||
...(npc.relationshipHooks ?? []),
|
||||
...(npc.tags ?? []),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,410 @@
|
||||
import type {
|
||||
CustomWorldGenerationFramework,
|
||||
CustomWorldProfile,
|
||||
} from '../runtimeTypes.js';
|
||||
import {
|
||||
buildCustomWorldPlayableNpcAttributeProfile,
|
||||
buildCustomWorldStoryNpcAttributeProfile,
|
||||
generateWorldAttributeSchema,
|
||||
} from './buildAttributeSchema.js';
|
||||
import {
|
||||
buildWorldName,
|
||||
inferWorldTypeFromSetting,
|
||||
normalizeWorldType,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldLockState,
|
||||
resolveCustomWorldRuntimeIntentBridge,
|
||||
} from './creatorIntentBridge.js';
|
||||
import {
|
||||
buildFallbackCustomWorldCampScene,
|
||||
normalizeCampOutline,
|
||||
normalizeCampScene,
|
||||
} from './normalizeCamp.js';
|
||||
import {
|
||||
buildCustomWorldRawProfileLandmarksFromFramework,
|
||||
normalizeLandmarkOutlineList,
|
||||
normalizeLandmarks,
|
||||
} from './normalizeLandmark.js';
|
||||
import {
|
||||
buildCustomWorldRawProfileRolesFromFramework,
|
||||
normalizeCustomWorldGenerationFrameworkRoles,
|
||||
normalizePlayableNpcList,
|
||||
normalizeStoryNpcList,
|
||||
} from './normalizeRole.js';
|
||||
import {
|
||||
buildDefaultCustomWorldCover,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
normalizeCustomWorldCover,
|
||||
normalizeItemList,
|
||||
normalizeTags,
|
||||
PLAYABLE_TEMPLATE_CHARACTER_IDS,
|
||||
slugify,
|
||||
toRecordArray,
|
||||
toText,
|
||||
} from './normalizeShared.js';
|
||||
import { normalizeSceneChapterBlueprints } from './normalizeSceneChapter.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 让 runtime profile 真正由“主编译入口 + 目录化 normalize/build 子模块”组成。
|
||||
*/
|
||||
|
||||
function buildBaseCustomWorldProfile(settingText: string): CustomWorldProfile {
|
||||
const templateWorldType = inferWorldTypeFromSetting(settingText);
|
||||
const name = buildWorldName(settingText, templateWorldType);
|
||||
const subtitle = '前路未明';
|
||||
const summary = settingText.trim()
|
||||
? `这个世界围绕“${settingText.trim().slice(0, 28)}”展开。`
|
||||
: '一个仍待展开的独立世界正在成形。';
|
||||
const tone = '未知、紧绷、仍在展开';
|
||||
const playerGoal = '查清眼前局势的关键矛盾,并守住仍值得相信的人与事';
|
||||
const camp = buildFallbackCustomWorldCampScene({
|
||||
name,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
settingText: settingText.trim(),
|
||||
});
|
||||
|
||||
return {
|
||||
id: `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
cover: buildDefaultCustomWorldCover([]),
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType: templateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [summary],
|
||||
attributeSchema: generateWorldAttributeSchema({
|
||||
worldName: name,
|
||||
settingText: settingText.trim(),
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
}),
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
items: [],
|
||||
camp,
|
||||
landmarks: [],
|
||||
themePack: null,
|
||||
storyGraph: null,
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: normalizeCustomWorldLockState(null),
|
||||
generationMode: 'full',
|
||||
generationStatus: 'complete',
|
||||
ownedSettingLayers: null,
|
||||
scenarioPackId: null,
|
||||
campaignPackId: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldGenerationFramework(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): CustomWorldGenerationFramework {
|
||||
const fallback = buildBaseCustomWorldProfile(settingText);
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return {
|
||||
settingText: fallback.settingText,
|
||||
name: fallback.name,
|
||||
subtitle: fallback.subtitle,
|
||||
summary: fallback.summary,
|
||||
tone: fallback.tone,
|
||||
playerGoal: fallback.playerGoal,
|
||||
templateWorldType: fallback.templateWorldType,
|
||||
compatibilityTemplateWorldType:
|
||||
fallback.compatibilityTemplateWorldType ?? fallback.templateWorldType,
|
||||
majorFactions: [],
|
||||
coreConflicts: [fallback.summary],
|
||||
camp: {
|
||||
name: fallback.camp?.name ?? '归舍',
|
||||
description: fallback.camp?.description ?? '',
|
||||
dangerLevel: fallback.camp?.dangerLevel ?? 'low',
|
||||
},
|
||||
playableNpcs: [],
|
||||
storyNpcs: [],
|
||||
landmarks: [],
|
||||
};
|
||||
}
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const roleState = normalizeCustomWorldGenerationFrameworkRoles({
|
||||
raw: item,
|
||||
fallback,
|
||||
settingText,
|
||||
});
|
||||
|
||||
return {
|
||||
settingText: settingText.trim(),
|
||||
name: roleState.name,
|
||||
subtitle: toText(item.subtitle) || fallback.subtitle,
|
||||
summary: toText(item.summary) || fallback.summary,
|
||||
tone: toText(item.tone) || fallback.tone,
|
||||
playerGoal: toText(item.playerGoal) || fallback.playerGoal,
|
||||
templateWorldType: roleState.templateWorldType,
|
||||
compatibilityTemplateWorldType: roleState.templateWorldType,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [fallback.summary]),
|
||||
camp: {
|
||||
name: normalizeCampOutline(item.camp, roleState.campFallbackProfile).name,
|
||||
description: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
|
||||
.description,
|
||||
dangerLevel: normalizeCampOutline(item.camp, roleState.campFallbackProfile)
|
||||
.dangerLevel,
|
||||
},
|
||||
playableNpcs: roleState.playableNpcs,
|
||||
storyNpcs: roleState.storyNpcs,
|
||||
landmarks: normalizeLandmarkOutlineList(item.landmarks),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldRawProfileFromFramework(
|
||||
framework: CustomWorldGenerationFramework,
|
||||
) {
|
||||
return {
|
||||
name: framework.name,
|
||||
subtitle: framework.subtitle,
|
||||
summary: framework.summary,
|
||||
tone: framework.tone,
|
||||
playerGoal: framework.playerGoal,
|
||||
templateWorldType: framework.templateWorldType,
|
||||
compatibilityTemplateWorldType: framework.compatibilityTemplateWorldType,
|
||||
majorFactions: framework.majorFactions,
|
||||
coreConflicts: framework.coreConflicts,
|
||||
camp: {
|
||||
name: framework.camp.name,
|
||||
description: framework.camp.description,
|
||||
dangerLevel: framework.camp.dangerLevel,
|
||||
},
|
||||
...buildCustomWorldRawProfileRolesFromFramework(framework),
|
||||
landmarks: buildCustomWorldRawProfileLandmarksFromFramework(framework),
|
||||
};
|
||||
}
|
||||
|
||||
function pickCyclic<T>(items: readonly T[], index: number, label: string): T {
|
||||
const item = items[index % items.length];
|
||||
if (item === undefined) {
|
||||
throw new Error(`Missing ${label}`);
|
||||
}
|
||||
return item;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): CustomWorldProfile {
|
||||
const fallback = buildBaseCustomWorldProfile(settingText);
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = raw as Record<string, unknown>;
|
||||
const worldSignalText = [
|
||||
settingText,
|
||||
toText(item.subtitle),
|
||||
toText(item.summary),
|
||||
toText(item.tone),
|
||||
toText(item.playerGoal),
|
||||
].join(' ');
|
||||
const templateWorldType = normalizeWorldType(
|
||||
item.templateWorldType,
|
||||
worldSignalText,
|
||||
);
|
||||
const name =
|
||||
toText(item.name) || buildWorldName(settingText, templateWorldType);
|
||||
const summary = toText(item.summary) || fallback.summary;
|
||||
const tone = toText(item.tone) || fallback.tone;
|
||||
const playerGoal = toText(item.playerGoal) || fallback.playerGoal;
|
||||
const generatedAttributeSchema = generateWorldAttributeSchema({
|
||||
worldName: name,
|
||||
settingText: settingText.trim(),
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
});
|
||||
const playableNpcs = normalizePlayableNpcList(item.playableNpcs);
|
||||
const storyNpcs = normalizeStoryNpcList(item.storyNpcs);
|
||||
const landmarkDrafts = toRecordArray(item.landmarks);
|
||||
const camp = normalizeCampScene(item.camp, {
|
||||
name,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
settingText: settingText.trim(),
|
||||
});
|
||||
const runtimeBridge = resolveCustomWorldRuntimeIntentBridge(item);
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) || `custom-world-${Date.now().toString(36)}-${slugify(name)}`,
|
||||
settingText: settingText.trim(),
|
||||
name,
|
||||
subtitle: toText(item.subtitle) || fallback.subtitle,
|
||||
summary,
|
||||
tone,
|
||||
playerGoal,
|
||||
cover: normalizeCustomWorldCover(item.cover, playableNpcs),
|
||||
templateWorldType,
|
||||
compatibilityTemplateWorldType: templateWorldType,
|
||||
majorFactions: normalizeTags(item.majorFactions, []),
|
||||
coreConflicts: normalizeTags(item.coreConflicts, [summary]),
|
||||
attributeSchema:
|
||||
item.attributeSchema && typeof item.attributeSchema === 'object'
|
||||
? generatedAttributeSchema
|
||||
: generatedAttributeSchema,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
items: normalizeItemList(item.items),
|
||||
camp,
|
||||
landmarks: normalizeLandmarks({
|
||||
landmarks: landmarkDrafts,
|
||||
storyNpcs,
|
||||
}),
|
||||
themePack:
|
||||
item.themePack && typeof item.themePack === 'object'
|
||||
? (item.themePack as CustomWorldProfile['themePack'])
|
||||
: null,
|
||||
storyGraph:
|
||||
item.storyGraph && typeof item.storyGraph === 'object'
|
||||
? (item.storyGraph as CustomWorldProfile['storyGraph'])
|
||||
: null,
|
||||
anchorContent:
|
||||
item.anchorContent && typeof item.anchorContent === 'object'
|
||||
? (item.anchorContent as Record<string, unknown>)
|
||||
: null,
|
||||
creatorIntent: runtimeBridge.creatorIntent,
|
||||
anchorPack: runtimeBridge.anchorPack,
|
||||
lockState: runtimeBridge.lockState,
|
||||
generationMode:
|
||||
item.generationMode === 'fast' || item.generationMode === 'full'
|
||||
? item.generationMode
|
||||
: fallback.generationMode,
|
||||
generationStatus:
|
||||
item.generationStatus === 'key_only' || item.generationStatus === 'complete'
|
||||
? item.generationStatus
|
||||
: fallback.generationStatus,
|
||||
ownedSettingLayers:
|
||||
item.ownedSettingLayers && typeof item.ownedSettingLayers === 'object'
|
||||
? (item.ownedSettingLayers as Record<string, unknown>)
|
||||
: null,
|
||||
knowledgeFacts:
|
||||
Array.isArray(item.knowledgeFacts)
|
||||
? (item.knowledgeFacts as Array<Record<string, unknown>>)
|
||||
: null,
|
||||
threadContracts:
|
||||
Array.isArray(item.threadContracts)
|
||||
? (item.threadContracts as Array<Record<string, unknown>>)
|
||||
: null,
|
||||
sceneChapterBlueprints: normalizeSceneChapterBlueprints(
|
||||
item.sceneChapterBlueprints,
|
||||
),
|
||||
scenarioPackId: toText(item.scenarioPackId) || null,
|
||||
campaignPackId: toText(item.campaignPackId) || null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCompiledCustomWorldProfile(
|
||||
raw: unknown,
|
||||
settingText: string,
|
||||
): CustomWorldProfile {
|
||||
const profile = normalizeCustomWorldProfile(raw, settingText);
|
||||
const playableNpcs = profile.playableNpcs.map((npc, index) => {
|
||||
const templateCharacterId =
|
||||
npc.templateCharacterId ??
|
||||
pickCyclic(
|
||||
PLAYABLE_TEMPLATE_CHARACTER_IDS,
|
||||
index,
|
||||
'playable template character id',
|
||||
);
|
||||
|
||||
return {
|
||||
...npc,
|
||||
templateCharacterId,
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldPlayableNpcAttributeProfile(
|
||||
{
|
||||
...npc,
|
||||
templateCharacterId,
|
||||
},
|
||||
profile.attributeSchema,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
const storyNpcs = profile.storyNpcs.map((npc) => ({
|
||||
...npc,
|
||||
attributeProfile:
|
||||
npc.attributeProfile ??
|
||||
buildCustomWorldStoryNpcAttributeProfile(npc, profile.attributeSchema),
|
||||
}));
|
||||
|
||||
return {
|
||||
...profile,
|
||||
playableNpcs,
|
||||
storyNpcs,
|
||||
scenarioPackId:
|
||||
profile.scenarioPackId ?? `scenario-pack:${slugify(profile.name)}`,
|
||||
campaignPackId:
|
||||
profile.campaignPackId ?? `campaign-pack:${slugify(profile.name)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function countUniqueNames(items: Array<{ name: string }>) {
|
||||
return new Set(items.map((item) => item.name.trim()).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
export function validateGeneratedCustomWorldProfile(
|
||||
profile: CustomWorldProfile,
|
||||
) {
|
||||
const playableCount = countUniqueNames(profile.playableNpcs);
|
||||
const landmarkCount = countUniqueNames(profile.landmarks);
|
||||
|
||||
if (playableCount < MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求模型至少产出 ${MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT} 名可扮演角色。`,
|
||||
);
|
||||
}
|
||||
|
||||
if (landmarkCount < MIN_CUSTOM_WORLD_LANDMARK_COUNT) {
|
||||
throw new Error(
|
||||
`自定义世界生成要求至少产出 ${MIN_CUSTOM_WORLD_LANDMARK_COUNT} 个场景,当前仅返回 ${landmarkCount} 个。`,
|
||||
);
|
||||
}
|
||||
|
||||
const validStoryNpcIds = new Set(profile.storyNpcs.map((npc) => npc.id));
|
||||
const validLandmarkIds = new Set(
|
||||
profile.landmarks.map((landmark) => landmark.id),
|
||||
);
|
||||
|
||||
profile.landmarks.forEach((landmark) => {
|
||||
const uniqueSceneNpcIds = [...new Set(landmark.sceneNpcIds)];
|
||||
if (uniqueSceneNpcIds.length < 3) {
|
||||
throw new Error(
|
||||
`场景「${landmark.name}」至少需要关联 3 个场景角色,当前仅有 ${uniqueSceneNpcIds.length} 个。`,
|
||||
);
|
||||
}
|
||||
if (uniqueSceneNpcIds.some((npcId) => !validStoryNpcIds.has(npcId))) {
|
||||
throw new Error(`场景「${landmark.name}」引用了不存在的场景角色。`);
|
||||
}
|
||||
if (landmark.connections.length === 0) {
|
||||
throw new Error(`场景「${landmark.name}」缺少可用的场景连接关系。`);
|
||||
}
|
||||
if (
|
||||
landmark.connections.some(
|
||||
(connection) =>
|
||||
connection.targetLandmarkId === landmark.id ||
|
||||
!validLandmarkIds.has(connection.targetLandmarkId),
|
||||
)
|
||||
) {
|
||||
throw new Error(`场景「${landmark.name}」存在无效的目标场景连接。`);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
buildCustomWorldAnchorPackFromIntent,
|
||||
deriveCustomWorldLockStateFromIntent,
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldLockState,
|
||||
} from '../creatorIntentRuntime.js';
|
||||
import type {
|
||||
CustomWorldCreatorIntent,
|
||||
CustomWorldProfile,
|
||||
WorldType,
|
||||
} from '../runtimeTypes.js';
|
||||
import { toText } from './normalizeShared.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 统一 runtime profile 对 creator intent、anchor pack 和 lock state 的桥接入口,
|
||||
* 避免主编译器继续直接拼装这些兼容字段。
|
||||
*/
|
||||
|
||||
export function inferWorldTypeFromSetting(settingText: string): WorldType {
|
||||
return /[仙灵修真飞升灵脉宗门法器天宫秘境道骨星舰]/u.test(settingText)
|
||||
? 'XIANXIA'
|
||||
: 'WUXIA';
|
||||
}
|
||||
|
||||
export function normalizeWorldType(value: unknown, sourceText: string): WorldType {
|
||||
const worldType = toText(value).toUpperCase();
|
||||
if (worldType === 'WUXIA' || worldType === 'XIANXIA') {
|
||||
return worldType;
|
||||
}
|
||||
return inferWorldTypeFromSetting(sourceText);
|
||||
}
|
||||
|
||||
export function buildSeedPhrase(settingText: string, fallback: string) {
|
||||
const compact = settingText.replace(/\s+/g, '').trim();
|
||||
return compact ? compact.slice(0, 10) : fallback;
|
||||
}
|
||||
|
||||
export function buildWorldName(settingText: string, worldType: WorldType) {
|
||||
const seed = buildSeedPhrase(settingText, '新旅');
|
||||
const suffix = worldType === 'XIANXIA' ? '境' : '域';
|
||||
return `${seed}${suffix}`;
|
||||
}
|
||||
|
||||
export {
|
||||
normalizeCustomWorldCreatorIntent,
|
||||
normalizeCustomWorldLockState,
|
||||
};
|
||||
|
||||
export function buildEmptyCustomWorldRuntimeBridge() {
|
||||
return {
|
||||
creatorIntent: null,
|
||||
anchorPack: null,
|
||||
lockState: normalizeCustomWorldLockState(null),
|
||||
} satisfies {
|
||||
creatorIntent: CustomWorldCreatorIntent | null;
|
||||
anchorPack: CustomWorldProfile['anchorPack'];
|
||||
lockState: CustomWorldProfile['lockState'];
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveCustomWorldRuntimeIntentBridge(
|
||||
raw: Record<string, unknown>,
|
||||
) {
|
||||
const creatorIntent = normalizeCustomWorldCreatorIntent(raw.creatorIntent);
|
||||
|
||||
return {
|
||||
creatorIntent,
|
||||
anchorPack:
|
||||
raw.anchorPack && typeof raw.anchorPack === 'object'
|
||||
? (raw.anchorPack as CustomWorldProfile['anchorPack'])
|
||||
: buildCustomWorldAnchorPackFromIntent(creatorIntent),
|
||||
lockState:
|
||||
raw.lockState && typeof raw.lockState === 'object'
|
||||
? normalizeCustomWorldLockState(raw.lockState)
|
||||
: deriveCustomWorldLockStateFromIntent(creatorIntent),
|
||||
} satisfies {
|
||||
creatorIntent: CustomWorldCreatorIntent | null;
|
||||
anchorPack: CustomWorldProfile['anchorPack'];
|
||||
lockState: CustomWorldProfile['lockState'];
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 工作包 G:
|
||||
* custom world runtime profile 的主入口统一收口到目录化模块。
|
||||
* 主编译逻辑和各类 normalize/build 子模块已经物理拆分,旧 compiler 文件只保留兼容转发。
|
||||
*/
|
||||
export * from './buildAttributeSchema.js';
|
||||
export * from './buildCompiledProfile.js';
|
||||
export * from './creatorIntentBridge.js';
|
||||
export * from './normalizeCamp.js';
|
||||
export * from './normalizeLandmark.js';
|
||||
export * from './normalizeRole.js';
|
||||
export * from './normalizeSceneChapter.js';
|
||||
export * from './normalizeShared.js';
|
||||
@@ -0,0 +1,178 @@
|
||||
import type {
|
||||
CustomWorldCampScene,
|
||||
CustomWorldGenerationCampOutline,
|
||||
} from '../runtimeTypes.js';
|
||||
import {
|
||||
clampText,
|
||||
toRecordArray,
|
||||
toStringArray,
|
||||
toText,
|
||||
} from './normalizeShared.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 营地 fallback、outline 归一和 runtime 场景归一单独收口,
|
||||
* 避免主编译器继续混合 UI 展示语义和营地领域默认值。
|
||||
*/
|
||||
|
||||
export type CustomWorldCampFallbackProfile = {
|
||||
name: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
settingText: string;
|
||||
};
|
||||
|
||||
function detectCustomWorldThemeMode(profile: {
|
||||
settingText: string;
|
||||
summary: string;
|
||||
tone: string;
|
||||
playerGoal: string;
|
||||
}) {
|
||||
const source = [
|
||||
profile.settingText,
|
||||
profile.summary,
|
||||
profile.tone,
|
||||
profile.playerGoal,
|
||||
].join(' ');
|
||||
|
||||
if (/[机关蒸汽齿轮工坊机巧城轨炮舰]/u.test(source)) return 'machina';
|
||||
if (/[海潮港湾船舟湖泊澜雾湾]/u.test(source)) return 'tide';
|
||||
if (/[裂缝裂界边境前线断层界桥灰域]/u.test(source)) return 'rift';
|
||||
if (/[修真仙灵宗门法器道脉秘境云阙]/u.test(source)) return 'arcane';
|
||||
if (/[江湖门派镖局朝廷刀剑侠客旧案]/u.test(source)) return 'martial';
|
||||
return 'mythic';
|
||||
}
|
||||
|
||||
function sanitizeCampSeed(name: string) {
|
||||
const normalized = name.trim().replace(/\s+/g, '');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const stripped = normalized.replace(
|
||||
/(世界|江湖|边城|仙洲|仙境|灵境|界|录|域|境)$/u,
|
||||
'',
|
||||
);
|
||||
const seed = stripped || normalized;
|
||||
|
||||
return seed.slice(0, Math.min(seed.length, 4));
|
||||
}
|
||||
|
||||
function buildFallbackCampName(profile: CustomWorldCampFallbackProfile) {
|
||||
const seed = sanitizeCampSeed(profile.name) || '归途';
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
|
||||
const suffixByMode = {
|
||||
mythic: '归舍',
|
||||
martial: '归舍',
|
||||
arcane: '栖居',
|
||||
machina: '整备居',
|
||||
tide: '潮居',
|
||||
rift: '界隙居所',
|
||||
} as const;
|
||||
|
||||
return `${seed}${suffixByMode[themeMode]}`;
|
||||
}
|
||||
|
||||
export function buildFallbackCustomWorldCampScene(
|
||||
profile: CustomWorldCampFallbackProfile,
|
||||
): CustomWorldCampScene {
|
||||
const fallbackName = buildFallbackCampName(profile);
|
||||
const summaryLead = clampText(profile.summary, 24) || '前路尚未明朗';
|
||||
const goalLead = clampText(profile.playerGoal, 24) || '继续整理线索';
|
||||
const themeMode = detectCustomWorldThemeMode(profile);
|
||||
|
||||
const descriptionByMode = {
|
||||
mythic: `${fallbackName}是你在${profile.name}暂时安顿的归处,能整备行装、交换判断,并围绕“${goalLead}”继续启程。`,
|
||||
martial: `${fallbackName}是你在${profile.name}暂时落脚的归处,能整备行装、收拢同伴,并朝“${goalLead}”继续动身。`,
|
||||
arcane: `${fallbackName}是你在${profile.name}暂时栖身的居所,适合调息、整理法器,并围绕“${summaryLead}”交换判断。`,
|
||||
machina: `${fallbackName}是你在${profile.name}的临时居所,能检修装备、补齐物资,并为下一段行动重新校准节奏。`,
|
||||
tide: `${fallbackName}是你在${profile.name}靠潮歇息的住处,能安顿队伍、整理补给,并顺着“${goalLead}”继续前探。`,
|
||||
rift: `${fallbackName}是你在${profile.name}勉强守住的一处居所,既能短暂停步,也能围绕“${summaryLead}”商量下一步去向。`,
|
||||
} as const;
|
||||
|
||||
return {
|
||||
id: 'custom-scene-camp',
|
||||
name: fallbackName,
|
||||
description: descriptionByMode[themeMode],
|
||||
dangerLevel: 'low',
|
||||
sceneNpcIds: [],
|
||||
connections: [],
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCampOutline(
|
||||
value: unknown,
|
||||
fallbackProfile: CustomWorldCampFallbackProfile,
|
||||
) {
|
||||
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
|
||||
const item =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) ||
|
||||
toText(connection.position) ||
|
||||
'forward',
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkName),
|
||||
} satisfies CustomWorldGenerationCampOutline & {
|
||||
id: string;
|
||||
visualDescription?: string;
|
||||
imageSrc?: string;
|
||||
sceneNpcIds: string[];
|
||||
connections: Array<{
|
||||
targetLandmarkName: string;
|
||||
relativePosition: string;
|
||||
summary: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCampScene(
|
||||
value: unknown,
|
||||
fallbackProfile: CustomWorldCampFallbackProfile,
|
||||
): CustomWorldCampScene {
|
||||
const fallback = buildFallbackCustomWorldCampScene(fallbackProfile);
|
||||
const item =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: toText(item.id) || fallback.id,
|
||||
name: toText(item.name) || fallback.name,
|
||||
description: toText(item.description) || fallback.description,
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || fallback.dangerLevel,
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) || toText(connection.position) || 'forward',
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
narrativeResidues: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import type {
|
||||
CustomWorldGenerationFramework,
|
||||
CustomWorldGenerationLandmarkOutline,
|
||||
CustomWorldNpc,
|
||||
} from '../runtimeTypes.js';
|
||||
import {
|
||||
clampText,
|
||||
createEntryId,
|
||||
MIN_CUSTOM_WORLD_LANDMARK_COUNT,
|
||||
toRecordArray,
|
||||
toStringArray,
|
||||
toText,
|
||||
} from './normalizeShared.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 世界地点 outline/runtime 归一独立收口,避免地点网络解析继续和角色、场景章节逻辑混在一个文件里。
|
||||
*/
|
||||
|
||||
export function normalizeLandmarkOutlineList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
name,
|
||||
description:
|
||||
toText(item.description) ||
|
||||
clampText(`${name}暗藏新的局势变化。`, 40),
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || 'medium',
|
||||
sceneNpcNames: [
|
||||
...toStringArray(item.sceneNpcNames),
|
||||
...toStringArray(item.npcs, 'name'),
|
||||
...toStringArray(item.sceneNpcs, 'name'),
|
||||
...toStringArray(item.npcNames),
|
||||
],
|
||||
connections: toRecordArray(item.connections)
|
||||
.map((connection) => ({
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) ||
|
||||
toText(connection.position) ||
|
||||
'forward',
|
||||
summary:
|
||||
toText(connection.summary) || toText(connection.description),
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkName),
|
||||
} satisfies CustomWorldGenerationLandmarkOutline;
|
||||
})
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, MIN_CUSTOM_WORLD_LANDMARK_COUNT);
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldGenerationLandmarkOutlineBatch(raw: unknown) {
|
||||
const item =
|
||||
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
return normalizeLandmarkOutlineList(item.landmarks);
|
||||
}
|
||||
|
||||
export function buildCustomWorldRawProfileLandmarksFromFramework(
|
||||
framework: CustomWorldGenerationFramework,
|
||||
) {
|
||||
return framework.landmarks.map((landmark) => ({
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
visualDescription: landmark.visualDescription,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
sceneNpcNames: [...landmark.sceneNpcNames],
|
||||
connections: landmark.connections.map((connection) => ({
|
||||
targetLandmarkName: connection.targetLandmarkName,
|
||||
relativePosition: connection.relativePosition,
|
||||
summary: connection.summary,
|
||||
})),
|
||||
}));
|
||||
}
|
||||
|
||||
export function normalizeLandmarks(params: {
|
||||
landmarks: Array<Record<string, unknown>>;
|
||||
storyNpcs: CustomWorldNpc[];
|
||||
}) {
|
||||
const storyNpcIdByName = new Map(
|
||||
params.storyNpcs.map((npc) => [npc.name.trim(), npc.id] as const),
|
||||
);
|
||||
const landmarkEntries = params.landmarks
|
||||
.map((item, index) => ({
|
||||
id: toText(item.id) || createEntryId('landmark', toText(item.name), index),
|
||||
name: toText(item.name),
|
||||
description: toText(item.description),
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
dangerLevel: toText(item.dangerLevel) || 'medium',
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
sceneNpcIds: toStringArray(item.sceneNpcIds),
|
||||
sceneNpcNames: [
|
||||
...toStringArray(item.sceneNpcNames),
|
||||
...toStringArray(item.npcs, 'name'),
|
||||
...toStringArray(item.sceneNpcs, 'name'),
|
||||
...toStringArray(item.npcNames),
|
||||
],
|
||||
connections: toRecordArray(item.connections).map((connection) => ({
|
||||
targetLandmarkId: toText(connection.targetLandmarkId),
|
||||
targetLandmarkName:
|
||||
toText(connection.targetLandmarkName) ||
|
||||
toText(connection.target) ||
|
||||
toText(connection.sceneName),
|
||||
relativePosition:
|
||||
toText(connection.relativePosition) || toText(connection.position),
|
||||
summary: toText(connection.summary) || toText(connection.description),
|
||||
})),
|
||||
}))
|
||||
.filter((entry) => entry.name);
|
||||
|
||||
const landmarkIdByName = new Map(
|
||||
landmarkEntries.map((landmark) => [landmark.name.trim(), landmark.id] as const),
|
||||
);
|
||||
|
||||
return landmarkEntries.map((landmark) => {
|
||||
const resolvedSceneNpcIds = [
|
||||
...new Set(
|
||||
[
|
||||
...landmark.sceneNpcIds,
|
||||
...landmark.sceneNpcNames
|
||||
.map((name) => storyNpcIdByName.get(name.trim()) ?? '')
|
||||
.filter(Boolean),
|
||||
].filter(Boolean),
|
||||
),
|
||||
];
|
||||
|
||||
return {
|
||||
id: landmark.id,
|
||||
name: landmark.name,
|
||||
description: landmark.description,
|
||||
visualDescription: landmark.visualDescription,
|
||||
dangerLevel: landmark.dangerLevel,
|
||||
imageSrc: landmark.imageSrc,
|
||||
sceneNpcIds: resolvedSceneNpcIds,
|
||||
connections: landmark.connections
|
||||
.map((connection) => ({
|
||||
targetLandmarkId:
|
||||
connection.targetLandmarkId ||
|
||||
landmarkIdByName.get(connection.targetLandmarkName.trim()) ||
|
||||
'',
|
||||
relativePosition: connection.relativePosition || 'forward',
|
||||
summary: connection.summary,
|
||||
}))
|
||||
.filter((connection) => connection.targetLandmarkId),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,541 @@
|
||||
import type {
|
||||
CharacterBackstoryChapter,
|
||||
CharacterBackstoryRevealConfig,
|
||||
CustomWorldGenerationFramework,
|
||||
CustomWorldGenerationRoleBatchType,
|
||||
CustomWorldGenerationRoleOutline,
|
||||
CustomWorldNpc,
|
||||
CustomWorldPlayableNpc,
|
||||
CustomWorldProfile,
|
||||
CustomWorldRoleInitialItem,
|
||||
CustomWorldRoleProfile,
|
||||
CustomWorldRoleSkill,
|
||||
} from '../runtimeTypes.js';
|
||||
import {
|
||||
buildWorldName,
|
||||
normalizeWorldType,
|
||||
} from './creatorIntentBridge.js';
|
||||
import {
|
||||
clampCustomWorldAffinity,
|
||||
clampText,
|
||||
createEntryId,
|
||||
MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
normalizeInitialAffinity,
|
||||
normalizeRarity,
|
||||
normalizeRoleItemCategory,
|
||||
normalizeTags,
|
||||
toRecordArray,
|
||||
toText,
|
||||
} from './normalizeShared.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 把角色相关的 outline/runtime 归一、背景揭示、初始技能与物品 fallback 统一收口,
|
||||
* 让主编译器只负责装配,不继续内嵌角色画像细节。
|
||||
*/
|
||||
|
||||
const AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS = [15, 30, 60, 90] as const;
|
||||
const DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY = 60;
|
||||
const DEFAULT_PLAYABLE_INITIAL_AFFINITY = 18;
|
||||
const DEFAULT_STORY_NPC_INITIAL_AFFINITY = 6;
|
||||
const DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT = 3;
|
||||
const DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT = 3;
|
||||
const CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES = [
|
||||
'表层来意',
|
||||
'旧事裂痕',
|
||||
'隐藏执念',
|
||||
'最终底牌',
|
||||
] as const;
|
||||
|
||||
type CustomWorldRoleFallbackSource = Pick<
|
||||
CustomWorldRoleProfile,
|
||||
| 'name'
|
||||
| 'title'
|
||||
| 'role'
|
||||
| 'description'
|
||||
| 'backstory'
|
||||
| 'personality'
|
||||
| 'motivation'
|
||||
| 'combatStyle'
|
||||
| 'relationshipHooks'
|
||||
| 'tags'
|
||||
>;
|
||||
|
||||
function splitNarrativeSentences(text: string) {
|
||||
const normalized = text.replace(/\s+/g, ' ').trim();
|
||||
if (!normalized) {
|
||||
return [];
|
||||
}
|
||||
const matches = normalized.match(/[^。!?!?]+[。!?!?]?/gu);
|
||||
return (matches ?? [normalized]).map((item) => item.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function buildFallbackBackstoryReveal(
|
||||
source: CustomWorldRoleFallbackSource,
|
||||
): CharacterBackstoryRevealConfig {
|
||||
const normalizedBackstory =
|
||||
source.backstory.trim() || `${source.name}对自己的过去始终有所保留。`;
|
||||
const backstorySentences = splitNarrativeSentences(normalizedBackstory);
|
||||
const backstoryLead = backstorySentences[0] ?? normalizedBackstory;
|
||||
const backstoryDetail =
|
||||
backstorySentences.slice(0, 2).join('') || normalizedBackstory;
|
||||
const publicSummary =
|
||||
source.description.trim() || clampText(normalizedBackstory, 42);
|
||||
const fallbackContents = [
|
||||
source.description.trim() || backstoryLead,
|
||||
backstoryDetail,
|
||||
source.motivation.trim()
|
||||
? `${source.name}真正挂念的,是:${source.motivation.trim()}`
|
||||
: `${source.name}的选择与“${clampText(backstoryLead, 24)}”直接相关。`,
|
||||
source.personality.trim()
|
||||
? `${source.name}不会轻易说出的底色,是:${source.personality.trim()}`
|
||||
: `${source.name}仍把最深的筹码藏在过去之中。`,
|
||||
];
|
||||
|
||||
return {
|
||||
publicSummary,
|
||||
privateChatUnlockAffinity: DEFAULT_PRIVATE_CHAT_UNLOCK_AFFINITY,
|
||||
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
|
||||
(affinityRequired, index) =>
|
||||
({
|
||||
id: createEntryId(
|
||||
'backstory-chapter',
|
||||
`${source.name}-${CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ?? index + 1}`,
|
||||
index,
|
||||
),
|
||||
title:
|
||||
CUSTOM_WORLD_BACKSTORY_CHAPTER_TITLES[index] ??
|
||||
`背景片段${index + 1}`,
|
||||
affinityRequired,
|
||||
teaser: clampText(
|
||||
fallbackContents[index] ?? normalizedBackstory,
|
||||
22,
|
||||
),
|
||||
content: clampText(
|
||||
fallbackContents[index] ?? normalizedBackstory,
|
||||
72,
|
||||
),
|
||||
contextSnippet: clampText(
|
||||
`${source.name}的背景正在向“${fallbackContents[index] ?? normalizedBackstory}”这条线索展开。`,
|
||||
48,
|
||||
),
|
||||
}) satisfies CharacterBackstoryChapter,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBackstoryReveal(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const fallback = buildFallbackBackstoryReveal(fallbackSource);
|
||||
if (!value || typeof value !== 'object') {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const rawChapters = toRecordArray(item.chapters);
|
||||
|
||||
return {
|
||||
publicSummary: toText(item.publicSummary) || fallback.publicSummary,
|
||||
privateChatUnlockAffinity:
|
||||
typeof item.privateChatUnlockAffinity === 'number' &&
|
||||
Number.isFinite(item.privateChatUnlockAffinity)
|
||||
? clampCustomWorldAffinity(item.privateChatUnlockAffinity)
|
||||
: fallback.privateChatUnlockAffinity,
|
||||
chapters: AFFINITY_BACKSTORY_CHAPTER_THRESHOLDS.map(
|
||||
(defaultAffinity, index) => {
|
||||
const fallbackChapter = fallback.chapters[index];
|
||||
const rawChapter = rawChapters[index];
|
||||
return {
|
||||
id:
|
||||
(rawChapter && toText(rawChapter.id)) ||
|
||||
fallbackChapter?.id ||
|
||||
`backstory-chapter-${index + 1}`,
|
||||
title:
|
||||
(rawChapter && toText(rawChapter.title)) ||
|
||||
fallbackChapter?.title ||
|
||||
`背景片段${index + 1}`,
|
||||
affinityRequired:
|
||||
fallbackChapter?.affinityRequired ?? defaultAffinity,
|
||||
teaser:
|
||||
(rawChapter && toText(rawChapter.teaser)) ||
|
||||
fallbackChapter?.teaser ||
|
||||
'',
|
||||
content:
|
||||
(rawChapter && toText(rawChapter.content)) ||
|
||||
fallbackChapter?.content ||
|
||||
'',
|
||||
contextSnippet:
|
||||
(rawChapter && toText(rawChapter.contextSnippet)) ||
|
||||
fallbackChapter?.contextSnippet ||
|
||||
'',
|
||||
} satisfies CharacterBackstoryChapter;
|
||||
},
|
||||
),
|
||||
} satisfies CharacterBackstoryRevealConfig;
|
||||
}
|
||||
|
||||
function buildFallbackRoleSkills(source: CustomWorldRoleFallbackSource) {
|
||||
const skillNameSeed = source.title || source.role || source.name || '角色';
|
||||
const skillSummarySeed =
|
||||
source.combatStyle || source.description || `${source.name}善于把握局势。`;
|
||||
const motivationSeed =
|
||||
source.motivation || source.personality || source.backstory;
|
||||
|
||||
return [
|
||||
{
|
||||
id: createEntryId('role-skill', `${skillNameSeed}-起手`, 0),
|
||||
name: `${skillNameSeed}起手`,
|
||||
summary: clampText(skillSummarySeed, 36),
|
||||
style: '起手压制',
|
||||
},
|
||||
{
|
||||
id: createEntryId('role-skill', `${skillNameSeed}-机动`, 1),
|
||||
name: `${skillNameSeed}变招`,
|
||||
summary: clampText(
|
||||
source.personality || `${source.name}习惯在试探中寻找破绽。`,
|
||||
36,
|
||||
),
|
||||
style: '机动周旋',
|
||||
},
|
||||
{
|
||||
id: createEntryId('role-skill', `${skillNameSeed}-底牌`, 2),
|
||||
name: `${skillNameSeed}底牌`,
|
||||
summary: clampText(
|
||||
motivationSeed || `${source.name}会在关键时刻亮出压箱手段。`,
|
||||
36,
|
||||
),
|
||||
style: '爆发终结',
|
||||
},
|
||||
] satisfies CustomWorldRoleSkill[];
|
||||
}
|
||||
|
||||
function normalizeRoleSkillList(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const normalized = toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
const summary = toText(item.summary) || toText(item.description);
|
||||
const style = toText(item.style) || toText(item.category) || '常用';
|
||||
|
||||
return {
|
||||
id: createEntryId('role-skill', name || style, index),
|
||||
name,
|
||||
summary,
|
||||
style,
|
||||
} satisfies CustomWorldRoleSkill;
|
||||
})
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_SKILL_COUNT);
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: buildFallbackRoleSkills(fallbackSource);
|
||||
}
|
||||
|
||||
function buildFallbackRoleInitialItems(source: CustomWorldRoleFallbackSource) {
|
||||
const itemNameSeed = source.title || source.role || source.name || '角色';
|
||||
return [
|
||||
{
|
||||
id: createEntryId('role-item', `${itemNameSeed}-1`, 0),
|
||||
name: `${itemNameSeed}常备武具`,
|
||||
category: '武器',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: clampText(
|
||||
source.combatStyle || `${source.name}随身携带的主要作战物件。`,
|
||||
36,
|
||||
),
|
||||
tags: normalizeTags(source.tags, ['战斗', '随身']),
|
||||
},
|
||||
{
|
||||
id: createEntryId('role-item', `${itemNameSeed}-2`, 1),
|
||||
name: `${itemNameSeed}补给包`,
|
||||
category: '消耗品',
|
||||
quantity: 2,
|
||||
rarity: 'uncommon',
|
||||
description: clampText(
|
||||
source.personality || `${source.name}为了长期行动准备的基础补给。`,
|
||||
36,
|
||||
),
|
||||
tags: normalizeTags(source.relationshipHooks, ['补给', '行动']),
|
||||
},
|
||||
{
|
||||
id: createEntryId('role-item', `${itemNameSeed}-3`, 2),
|
||||
name: `${itemNameSeed}私人物件`,
|
||||
category: '专属物品',
|
||||
quantity: 1,
|
||||
rarity: 'rare',
|
||||
description: clampText(
|
||||
source.backstory ||
|
||||
source.motivation ||
|
||||
`${source.name}不愿随意交出的信物。`,
|
||||
36,
|
||||
),
|
||||
tags: normalizeTags(
|
||||
[...source.tags, ...source.relationshipHooks],
|
||||
['信物', '线索'],
|
||||
),
|
||||
},
|
||||
] satisfies CustomWorldRoleInitialItem[];
|
||||
}
|
||||
|
||||
function normalizeRoleInitialItemList(
|
||||
value: unknown,
|
||||
fallbackSource: CustomWorldRoleFallbackSource,
|
||||
) {
|
||||
const normalized = toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
return {
|
||||
id: createEntryId('role-item', name, index),
|
||||
name,
|
||||
category: normalizeRoleItemCategory(item.category),
|
||||
quantity:
|
||||
typeof item.quantity === 'number' && Number.isFinite(item.quantity)
|
||||
? Math.max(1, Math.min(99, Math.round(item.quantity)))
|
||||
: 1,
|
||||
rarity: normalizeRarity(item.rarity, 'rare'),
|
||||
description: toText(item.description),
|
||||
tags: normalizeTags(item.tags),
|
||||
} satisfies CustomWorldRoleInitialItem;
|
||||
})
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, DEFAULT_CUSTOM_WORLD_ROLE_INITIAL_ITEM_COUNT);
|
||||
|
||||
return normalized.length > 0
|
||||
? normalized
|
||||
: buildFallbackRoleInitialItems(fallbackSource);
|
||||
}
|
||||
|
||||
function normalizeRoleOutlineList(
|
||||
value: unknown,
|
||||
options: {
|
||||
titleFallback: string;
|
||||
defaultAffinity: number;
|
||||
maxCount?: number;
|
||||
},
|
||||
) {
|
||||
const normalized = toRecordArray(value)
|
||||
.map((item) => {
|
||||
const name = toText(item.name);
|
||||
const title =
|
||||
toText(item.title) || toText(item.role) || options.titleFallback;
|
||||
const role = toText(item.role) || title;
|
||||
const relationshipHooks = normalizeTags(
|
||||
item.relationshipHooks,
|
||||
normalizeTags(item.tags),
|
||||
);
|
||||
|
||||
return {
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description:
|
||||
toText(item.description) ||
|
||||
clampText(`${name || title}在世界中以${role}身份活动。`, 36),
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
actionDescription: toText(item.actionDescription) || undefined,
|
||||
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
|
||||
initialAffinity: normalizeInitialAffinity(
|
||||
item.initialAffinity,
|
||||
options.defaultAffinity,
|
||||
),
|
||||
relationshipHooks,
|
||||
tags: normalizeTags(item.tags, relationshipHooks),
|
||||
} satisfies CustomWorldGenerationRoleOutline;
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
|
||||
return typeof options.maxCount === 'number'
|
||||
? normalized.slice(0, options.maxCount)
|
||||
: normalized;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldGenerationRoleOutlineBatch(
|
||||
raw: unknown,
|
||||
roleType: CustomWorldGenerationRoleBatchType,
|
||||
) {
|
||||
const item =
|
||||
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
||||
const key = roleType === 'playable' ? 'playableNpcs' : 'storyNpcs';
|
||||
|
||||
return normalizeRoleOutlineList(item[key], {
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity:
|
||||
roleType === 'playable'
|
||||
? DEFAULT_PLAYABLE_INITIAL_AFFINITY
|
||||
: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldGenerationFrameworkRoles(params: {
|
||||
raw: Record<string, unknown>;
|
||||
fallback: CustomWorldProfile;
|
||||
settingText: string;
|
||||
}) {
|
||||
const worldSignalText = [
|
||||
params.settingText,
|
||||
toText(params.raw.subtitle),
|
||||
toText(params.raw.summary),
|
||||
toText(params.raw.tone),
|
||||
toText(params.raw.playerGoal),
|
||||
].join(' ');
|
||||
const templateWorldType = normalizeWorldType(
|
||||
params.raw.templateWorldType,
|
||||
worldSignalText,
|
||||
);
|
||||
const name =
|
||||
toText(params.raw.name) || buildWorldName(params.settingText, templateWorldType);
|
||||
|
||||
return {
|
||||
name,
|
||||
templateWorldType,
|
||||
playableNpcs: normalizeRoleOutlineList(params.raw.playableNpcs, {
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
|
||||
maxCount: MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
}),
|
||||
storyNpcs: normalizeRoleOutlineList(params.raw.storyNpcs, {
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
maxCount: MIN_CUSTOM_WORLD_STORY_NPC_COUNT,
|
||||
}),
|
||||
campFallbackProfile: {
|
||||
name,
|
||||
summary: toText(params.raw.summary) || params.fallback.summary,
|
||||
tone: toText(params.raw.tone) || params.fallback.tone,
|
||||
playerGoal: toText(params.raw.playerGoal) || params.fallback.playerGoal,
|
||||
settingText: params.settingText.trim(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function buildCustomWorldRawProfileRolesFromFramework(
|
||||
framework: CustomWorldGenerationFramework,
|
||||
) {
|
||||
return {
|
||||
playableNpcs: framework.playableNpcs.map((npc) => ({
|
||||
name: npc.name,
|
||||
title: npc.title,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
visualDescription: npc.visualDescription,
|
||||
actionDescription: npc.actionDescription,
|
||||
sceneVisualDescription: npc.sceneVisualDescription,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
relationshipHooks: [...npc.relationshipHooks],
|
||||
tags: [...npc.tags],
|
||||
})),
|
||||
storyNpcs: framework.storyNpcs.map((npc) => ({
|
||||
name: npc.name,
|
||||
title: npc.title,
|
||||
role: npc.role,
|
||||
description: npc.description,
|
||||
visualDescription: npc.visualDescription,
|
||||
actionDescription: npc.actionDescription,
|
||||
sceneVisualDescription: npc.sceneVisualDescription,
|
||||
initialAffinity: npc.initialAffinity,
|
||||
relationshipHooks: [...npc.relationshipHooks],
|
||||
tags: [...npc.tags],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRoleProfile(
|
||||
item: Record<string, unknown>,
|
||||
index: number,
|
||||
options: {
|
||||
idPrefix: 'playable-npc' | 'story-npc';
|
||||
titleFallback: string;
|
||||
defaultAffinity: number;
|
||||
},
|
||||
) {
|
||||
const name = toText(item.name);
|
||||
const title =
|
||||
toText(item.title) || toText(item.role) || options.titleFallback;
|
||||
const role = toText(item.role) || title;
|
||||
const relationshipHooks = normalizeTags(
|
||||
item.relationshipHooks,
|
||||
normalizeTags(item.tags),
|
||||
);
|
||||
const normalizedRole = {
|
||||
id: toText(item.id) || createEntryId(options.idPrefix, name, index),
|
||||
name,
|
||||
title,
|
||||
role,
|
||||
description: toText(item.description),
|
||||
visualDescription: toText(item.visualDescription) || undefined,
|
||||
actionDescription: toText(item.actionDescription) || undefined,
|
||||
sceneVisualDescription: toText(item.sceneVisualDescription) || undefined,
|
||||
backstory: toText(item.backstory),
|
||||
personality: toText(item.personality),
|
||||
motivation: toText(item.motivation) || toText(item.description),
|
||||
combatStyle: toText(item.combatStyle),
|
||||
initialAffinity: normalizeInitialAffinity(
|
||||
item.initialAffinity,
|
||||
options.defaultAffinity,
|
||||
),
|
||||
relationshipHooks,
|
||||
tags: normalizeTags(item.tags, relationshipHooks),
|
||||
};
|
||||
|
||||
return {
|
||||
...normalizedRole,
|
||||
backstoryReveal: normalizeBackstoryReveal(
|
||||
item.backstoryReveal,
|
||||
normalizedRole,
|
||||
),
|
||||
skills: normalizeRoleSkillList(item.skills, normalizedRole),
|
||||
initialItems: normalizeRoleInitialItemList(item.initialItems, normalizedRole),
|
||||
imageSrc: toText(item.imageSrc) || undefined,
|
||||
generatedVisualAssetId: toText(item.generatedVisualAssetId) || undefined,
|
||||
generatedAnimationSetId:
|
||||
toText(item.generatedAnimationSetId) || undefined,
|
||||
animationMap:
|
||||
item.animationMap && typeof item.animationMap === 'object'
|
||||
? (item.animationMap as Record<string, unknown>)
|
||||
: undefined,
|
||||
narrativeProfile:
|
||||
item.narrativeProfile && typeof item.narrativeProfile === 'object'
|
||||
? (item.narrativeProfile as CustomWorldRoleProfile['narrativeProfile'])
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizePlayableNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => ({
|
||||
...normalizeRoleProfile(item, index, {
|
||||
idPrefix: 'playable-npc',
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_PLAYABLE_INITIAL_AFFINITY,
|
||||
}),
|
||||
templateCharacterId: toText(item.templateCharacterId) || undefined,
|
||||
}))
|
||||
.filter((entry) => entry.name)
|
||||
.slice(0, MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT);
|
||||
}
|
||||
|
||||
export function normalizeStoryNpcList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map(
|
||||
(item, index) =>
|
||||
({
|
||||
...normalizeRoleProfile(item, index, {
|
||||
idPrefix: 'story-npc',
|
||||
titleFallback: '未定称号',
|
||||
defaultAffinity: DEFAULT_STORY_NPC_INITIAL_AFFINITY,
|
||||
}),
|
||||
visual:
|
||||
item.visual && typeof item.visual === 'object'
|
||||
? (item.visual as Record<string, unknown>)
|
||||
: undefined,
|
||||
}) satisfies CustomWorldNpc,
|
||||
)
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { SceneActBlueprint, SceneChapterBlueprint } from '../runtimeTypes.js';
|
||||
import { createEntryId, toRecordArray, toStringArray, toText } from './normalizeShared.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 分幕与场景章节 blueprint 归一独立收口,让结果预览编译层只消费稳定的章节结构。
|
||||
*/
|
||||
|
||||
const SCENE_ACT_STAGES = new Set([
|
||||
'opening',
|
||||
'expansion',
|
||||
'turning_point',
|
||||
'climax',
|
||||
'aftermath',
|
||||
]);
|
||||
const SCENE_ACT_ADVANCE_RULES = new Set([
|
||||
'after_primary_contact',
|
||||
'after_active_step_complete',
|
||||
'after_chapter_resolution',
|
||||
]);
|
||||
|
||||
function normalizeSceneActStageCoverage(value: unknown) {
|
||||
const stageCoverage = Array.isArray(value)
|
||||
? value
|
||||
.filter((entry): entry is string => typeof entry === 'string')
|
||||
.map((entry) => entry.trim())
|
||||
.filter((entry): entry is SceneActBlueprint['stageCoverage'][number] =>
|
||||
SCENE_ACT_STAGES.has(entry as never),
|
||||
)
|
||||
: [];
|
||||
|
||||
return [...new Set(stageCoverage)];
|
||||
}
|
||||
|
||||
function normalizeSceneActBlueprint(
|
||||
value: unknown,
|
||||
index: number,
|
||||
sceneId: string,
|
||||
): SceneActBlueprint | null {
|
||||
const item =
|
||||
value && typeof value === 'object'
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
if (!item) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const encounterNpcIds = toStringArray(item.encounterNpcIds);
|
||||
const stageCoverage = normalizeSceneActStageCoverage(item.stageCoverage);
|
||||
const advanceRule = toText(item.advanceRule);
|
||||
const title = toText(item.title);
|
||||
const summary = toText(item.summary);
|
||||
|
||||
if (!title && !summary && encounterNpcIds.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(item.id) ||
|
||||
createEntryId(`saved-scene-act-${sceneId}`, title || sceneId, index),
|
||||
sceneId,
|
||||
title: title || `第 ${index + 1} 幕`,
|
||||
summary: summary || title || `围绕${sceneId}继续推进`,
|
||||
stageCoverage:
|
||||
stageCoverage.length > 0
|
||||
? stageCoverage
|
||||
: index === 0
|
||||
? ['opening']
|
||||
: ['climax', 'aftermath'],
|
||||
backgroundImageSrc: toText(item.backgroundImageSrc) || undefined,
|
||||
backgroundAssetId: toText(item.backgroundAssetId) || undefined,
|
||||
encounterNpcIds,
|
||||
primaryNpcId: toText(item.primaryNpcId) || encounterNpcIds[0] || '',
|
||||
linkedThreadIds: toStringArray(item.linkedThreadIds),
|
||||
advanceRule: SCENE_ACT_ADVANCE_RULES.has(advanceRule as never)
|
||||
? (advanceRule as SceneActBlueprint['advanceRule'])
|
||||
: 'after_active_step_complete',
|
||||
actGoal: toText(item.actGoal),
|
||||
transitionHook: toText(item.transitionHook),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeSceneChapterBlueprints(value: unknown) {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value
|
||||
.filter(
|
||||
(entry): entry is Record<string, unknown> =>
|
||||
Boolean(entry) && typeof entry === 'object' && !Array.isArray(entry),
|
||||
)
|
||||
.map((entry, index) => {
|
||||
const sceneId = toText(entry.sceneId);
|
||||
if (!sceneId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const acts = Array.isArray(entry.acts)
|
||||
? entry.acts
|
||||
.map((act, actIndex) =>
|
||||
normalizeSceneActBlueprint(act, actIndex, sceneId),
|
||||
)
|
||||
.filter((act): act is SceneActBlueprint => Boolean(act))
|
||||
: [];
|
||||
|
||||
return {
|
||||
id:
|
||||
toText(entry.id) ||
|
||||
createEntryId('saved-scene-chapter', sceneId, index),
|
||||
sceneId,
|
||||
title: toText(entry.title) || toText(entry.sceneName) || sceneId,
|
||||
summary: toText(entry.summary),
|
||||
linkedThreadIds: toStringArray(entry.linkedThreadIds),
|
||||
linkedLandmarkIds: toStringArray(entry.linkedLandmarkIds),
|
||||
acts,
|
||||
} satisfies SceneChapterBlueprint;
|
||||
})
|
||||
.filter((entry): entry is SceneChapterBlueprint => Boolean(entry));
|
||||
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
import type {
|
||||
CustomWorldCoverProfile,
|
||||
CustomWorldCoverSourceType,
|
||||
CustomWorldItem,
|
||||
CustomWorldPlayableNpc,
|
||||
} from '../runtimeTypes.js';
|
||||
|
||||
/**
|
||||
* 工作包 G:
|
||||
* 把 runtime profile 编译流程里的通用常量、基础文本归一和通用小型归一逻辑收口到共享模块,
|
||||
* 让角色、地点、场景章节和主编译入口都不再重复维护这些基础能力。
|
||||
*/
|
||||
|
||||
const MIN_CUSTOM_WORLD_AFFINITY = -40;
|
||||
const MAX_CUSTOM_WORLD_AFFINITY = 90;
|
||||
const CUSTOM_WORLD_RARITIES = [
|
||||
'common',
|
||||
'uncommon',
|
||||
'rare',
|
||||
'epic',
|
||||
'legendary',
|
||||
] as const;
|
||||
const CUSTOM_WORLD_ROLE_ITEM_CATEGORIES = [
|
||||
'武器',
|
||||
'护甲',
|
||||
'饰品',
|
||||
'消耗品',
|
||||
'材料',
|
||||
'稀有品',
|
||||
'专属物品',
|
||||
'专属物',
|
||||
] as const;
|
||||
|
||||
export const MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT = 5;
|
||||
export const MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT = 30;
|
||||
export const MIN_CUSTOM_WORLD_LANDMARK_COUNT = 10;
|
||||
export const MIN_CUSTOM_WORLD_STORY_NPC_COUNT = Math.max(
|
||||
0,
|
||||
MIN_CUSTOM_WORLD_TOTAL_NPC_COUNT - MIN_CUSTOM_WORLD_PLAYABLE_NPC_COUNT,
|
||||
);
|
||||
|
||||
export const PLAYABLE_TEMPLATE_CHARACTER_IDS = [
|
||||
'sword-princess',
|
||||
'archer-hero',
|
||||
'girl-hero',
|
||||
'punch-hero',
|
||||
'fighter-4',
|
||||
] as const;
|
||||
|
||||
export function toText(value: unknown) {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
export function toFiniteInteger(value: unknown) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? Math.round(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function toRecord(value: unknown) {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
export function toRecordArray(value: unknown) {
|
||||
return Array.isArray(value)
|
||||
? (value.filter((item) => item && typeof item === 'object') as Array<
|
||||
Record<string, unknown>
|
||||
>)
|
||||
: [];
|
||||
}
|
||||
|
||||
export function toStringArray(value: unknown, nestedKey?: string) {
|
||||
if (!Array.isArray(value)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return value
|
||||
.map((item) => {
|
||||
if (typeof item === 'string') {
|
||||
return item.trim();
|
||||
}
|
||||
if (nestedKey && item && typeof item === 'object') {
|
||||
return toText((item as Record<string, unknown>)[nestedKey]);
|
||||
}
|
||||
return '';
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function normalizeTags(value: unknown, fallbackTags: string[] = []) {
|
||||
const tags = Array.isArray(value)
|
||||
? value.map((item) => toText(item)).filter(Boolean)
|
||||
: [];
|
||||
return [
|
||||
...new Set((tags.length > 0 ? tags : fallbackTags).filter(Boolean)),
|
||||
].slice(0, 5);
|
||||
}
|
||||
|
||||
export function clampText(value: string, maxLength: number) {
|
||||
const normalized = value.trim().replace(/\s+/g, ' ');
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
if (normalized.length <= maxLength) {
|
||||
return normalized;
|
||||
}
|
||||
return `${normalized.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
|
||||
}
|
||||
|
||||
export function slugify(value: string) {
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return ascii ? ascii.slice(0, 24) : 'entry';
|
||||
}
|
||||
|
||||
export function createEntryId(prefix: string, label: string, index: number) {
|
||||
return `${prefix}-${slugify(label || `${prefix}-${index + 1}`)}-${index + 1}`;
|
||||
}
|
||||
|
||||
export function clampCustomWorldAffinity(value: number) {
|
||||
return Math.max(
|
||||
MIN_CUSTOM_WORLD_AFFINITY,
|
||||
Math.min(MAX_CUSTOM_WORLD_AFFINITY, Math.round(value)),
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeInitialAffinity(value: unknown, fallback: number) {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
? clampCustomWorldAffinity(value)
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function normalizeRarity(
|
||||
value: unknown,
|
||||
fallback: (typeof CUSTOM_WORLD_RARITIES)[number] = 'rare',
|
||||
) {
|
||||
const rarity = toText(value).toLowerCase();
|
||||
return CUSTOM_WORLD_RARITIES.includes(
|
||||
rarity as (typeof CUSTOM_WORLD_RARITIES)[number],
|
||||
)
|
||||
? (rarity as (typeof CUSTOM_WORLD_RARITIES)[number])
|
||||
: fallback;
|
||||
}
|
||||
|
||||
export function normalizeRoleItemCategory(value: unknown, fallback = '材料') {
|
||||
const category = toText(value);
|
||||
if (
|
||||
(CUSTOM_WORLD_ROLE_ITEM_CATEGORIES as readonly string[]).includes(category)
|
||||
) {
|
||||
return category === '专属物' ? '专属物品' : category;
|
||||
}
|
||||
if (/武器|刀|剑|弓|枪|炮|锤/u.test(category)) return '武器';
|
||||
if (/甲|护|盾|衣|袍/u.test(category)) return '护甲';
|
||||
if (/饰|符|佩|坠|珠|印/u.test(category)) return '饰品';
|
||||
if (/药|食|补给|瓶|卷轴/u.test(category)) return '消耗品';
|
||||
if (/材|矿|木|石|鳞|骨/u.test(category)) return '材料';
|
||||
if (/秘|钥|图|册|录|卷/u.test(category)) return '稀有品';
|
||||
if (/信物|遗物|核心|母印|真符/u.test(category)) return '专属物品';
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldCoverCharacterRoleIds(
|
||||
value: unknown,
|
||||
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
|
||||
) {
|
||||
const availableIds = new Set(
|
||||
playableNpcs.map((entry) => entry.id.trim()).filter(Boolean),
|
||||
);
|
||||
const selectedIds = Array.isArray(value)
|
||||
? [
|
||||
...new Set(
|
||||
value
|
||||
.map((entry) => toText(entry))
|
||||
.filter((entry) => entry && availableIds.has(entry)),
|
||||
),
|
||||
].slice(0, 3)
|
||||
: [];
|
||||
|
||||
if (selectedIds.length > 0) {
|
||||
return selectedIds;
|
||||
}
|
||||
|
||||
return playableNpcs
|
||||
.map((entry) => entry.id.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 3);
|
||||
}
|
||||
|
||||
export function buildDefaultCustomWorldCover(
|
||||
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
|
||||
): CustomWorldCoverProfile {
|
||||
return {
|
||||
sourceType: 'default' as const,
|
||||
imageSrc: null,
|
||||
characterRoleIds: normalizeCustomWorldCoverCharacterRoleIds(
|
||||
undefined,
|
||||
playableNpcs,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCustomWorldCover(
|
||||
value: unknown,
|
||||
playableNpcs: Array<Pick<CustomWorldPlayableNpc, 'id'>>,
|
||||
): CustomWorldCoverProfile {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return buildDefaultCustomWorldCover(playableNpcs);
|
||||
}
|
||||
|
||||
const item = value as Record<string, unknown>;
|
||||
const sourceType: CustomWorldCoverSourceType =
|
||||
item.sourceType === 'uploaded' || item.sourceType === 'generated'
|
||||
? item.sourceType
|
||||
: 'default';
|
||||
const imageSrc = toText(item.imageSrc) || null;
|
||||
|
||||
if (sourceType !== 'default' && imageSrc) {
|
||||
return {
|
||||
sourceType,
|
||||
imageSrc,
|
||||
characterRoleIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return buildDefaultCustomWorldCover(playableNpcs);
|
||||
}
|
||||
|
||||
export function normalizeItemList(value: unknown) {
|
||||
return toRecordArray(value)
|
||||
.map((item, index) => {
|
||||
const name = toText(item.name);
|
||||
const category = toText(item.category);
|
||||
return {
|
||||
id: toText(item.id) || createEntryId('item', name, index),
|
||||
name,
|
||||
category,
|
||||
rarity: normalizeRarity(item.rarity, 'rare'),
|
||||
description: toText(item.description),
|
||||
tags: normalizeTags(item.tags),
|
||||
} satisfies CustomWorldItem;
|
||||
})
|
||||
.filter((entry) => entry.name && entry.category);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* 兼容期 façade:
|
||||
* 旧的 runtimeProfileCompiler 文件名暂时保留,避免工作包 G 完整拆分后影响仍未迁移的局部导入。
|
||||
* 新实现已经拆到目录模块中,后续新增逻辑禁止继续回写到这个文件。
|
||||
*/
|
||||
export * from './buildAttributeSchema.js';
|
||||
export * from './buildCompiledProfile.js';
|
||||
export * from './creatorIntentBridge.js';
|
||||
export * from './normalizeCamp.js';
|
||||
export * from './normalizeLandmark.js';
|
||||
export * from './normalizeRole.js';
|
||||
export * from './normalizeSceneChapter.js';
|
||||
export * from './normalizeShared.js';
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
getPlayerBuildDamageBreakdown,
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
} from './inventoryMutationService.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
|
||||
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
|
||||
|
||||
const SUPPORTED_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'equipment_equip',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
addInventoryItems,
|
||||
@@ -23,8 +23,8 @@ import {
|
||||
} from '../../bridges/legacyNpcTask6Bridge.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
|
||||
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
|
||||
|
||||
const SUPPORTED_NPC_INVENTORY_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'npc_gift',
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { RuntimeStoryPatch } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import { conflict } from '../../errors.js';
|
||||
import { resolveHostileBattleProfile } from '../progression/hostileProgressionService.js';
|
||||
import {
|
||||
applyStoryChoiceToStanceProfile,
|
||||
} from './npcTask6Primitives.js';
|
||||
import { markNpcFirstMeaningfulContactResolved } from '../runtime/runtimeNpcStatePrimitives.js';
|
||||
import {
|
||||
MAX_TASK5_COMPANIONS,
|
||||
getEncounterNpcState,
|
||||
@@ -8,7 +12,7 @@ import {
|
||||
type RuntimeEncounter,
|
||||
type RuntimeNpcState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSessionPrimitives.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
|
||||
@@ -57,6 +61,158 @@ function isRecord(value: unknown): value is JsonRecord {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function readString(value: unknown) {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
}
|
||||
|
||||
function buildRecruitedCompanion(
|
||||
session: RuntimeSession,
|
||||
encounter: RuntimeEncounter,
|
||||
npcState: RuntimeNpcState,
|
||||
) {
|
||||
const rawCompanionSource = isRecord(session.rawGameState.currentEncounter)
|
||||
? session.rawGameState.currentEncounter
|
||||
: {};
|
||||
const maxHp = Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
typeof rawCompanionSource.maxHp === 'number' &&
|
||||
Number.isFinite(rawCompanionSource.maxHp)
|
||||
? rawCompanionSource.maxHp
|
||||
: 180,
|
||||
),
|
||||
);
|
||||
const maxMana = Math.max(
|
||||
1,
|
||||
Math.round(
|
||||
typeof rawCompanionSource.maxMana === 'number' &&
|
||||
Number.isFinite(rawCompanionSource.maxMana)
|
||||
? rawCompanionSource.maxMana
|
||||
: 999,
|
||||
),
|
||||
);
|
||||
const skillCooldowns = Object.fromEntries(
|
||||
Object.entries(
|
||||
isRecord(rawCompanionSource.skillCooldowns)
|
||||
? rawCompanionSource.skillCooldowns
|
||||
: {},
|
||||
).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
typeof turns === 'number' && Number.isFinite(turns)
|
||||
? Math.max(0, Math.round(turns))
|
||||
: 0,
|
||||
]),
|
||||
);
|
||||
|
||||
return {
|
||||
npcId: encounter.id,
|
||||
characterId: encounter.characterId ?? '',
|
||||
joinedAtAffinity: npcState.affinity,
|
||||
hp: maxHp,
|
||||
maxHp,
|
||||
mana: maxMana,
|
||||
maxMana,
|
||||
skillCooldowns,
|
||||
animationState: readString(rawCompanionSource.animationState) || 'idle',
|
||||
actionMode: readString(rawCompanionSource.actionMode) || 'idle',
|
||||
offsetX:
|
||||
typeof rawCompanionSource.offsetX === 'number' &&
|
||||
Number.isFinite(rawCompanionSource.offsetX)
|
||||
? rawCompanionSource.offsetX
|
||||
: 0,
|
||||
offsetY:
|
||||
typeof rawCompanionSource.offsetY === 'number' &&
|
||||
Number.isFinite(rawCompanionSource.offsetY)
|
||||
? rawCompanionSource.offsetY
|
||||
: 0,
|
||||
transitionMs:
|
||||
typeof rawCompanionSource.transitionMs === 'number' &&
|
||||
Number.isFinite(rawCompanionSource.transitionMs)
|
||||
? Math.max(0, Math.round(rawCompanionSource.transitionMs))
|
||||
: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function upsertCompanion(
|
||||
list: RuntimeSession['companions'],
|
||||
companion: RuntimeSession['companions'][number],
|
||||
) {
|
||||
const next = [...list];
|
||||
const existingIndex = next.findIndex((item) => item.npcId === companion.npcId);
|
||||
if (existingIndex >= 0) {
|
||||
next[existingIndex] = companion;
|
||||
return next;
|
||||
}
|
||||
|
||||
next.push(companion);
|
||||
return next;
|
||||
}
|
||||
|
||||
function removeCompanion(
|
||||
list: RuntimeSession['companions'],
|
||||
npcId: string,
|
||||
) {
|
||||
return list.filter((item) => item.npcId !== npcId);
|
||||
}
|
||||
|
||||
function normalizeRoster(
|
||||
roster: RuntimeSession['roster'],
|
||||
activeCompanions: RuntimeSession['companions'],
|
||||
) {
|
||||
const activeIds = new Set(activeCompanions.map((companion) => companion.npcId));
|
||||
return roster.filter((companion) => !activeIds.has(companion.npcId));
|
||||
}
|
||||
|
||||
function recruitCompanionToParty(params: {
|
||||
session: RuntimeSession;
|
||||
companion: RuntimeSession['companions'][number];
|
||||
releaseNpcId?: string | null;
|
||||
}) {
|
||||
const nextRosterWithoutRecruit = removeCompanion(
|
||||
params.session.roster,
|
||||
params.companion.npcId,
|
||||
);
|
||||
|
||||
if (
|
||||
!params.releaseNpcId &&
|
||||
params.session.companions.length < MAX_TASK5_COMPANIONS
|
||||
) {
|
||||
return {
|
||||
companions: [...params.session.companions, params.companion],
|
||||
roster: nextRosterWithoutRecruit,
|
||||
releasedCompanion: null,
|
||||
};
|
||||
}
|
||||
|
||||
if (!params.releaseNpcId) {
|
||||
throw conflict('队伍已满时必须明确指定一名离队同伴');
|
||||
}
|
||||
|
||||
const replaceIndex = params.session.companions.findIndex(
|
||||
(item) => item.npcId === params.releaseNpcId,
|
||||
);
|
||||
if (replaceIndex < 0) {
|
||||
throw conflict('指定的离队同伴不存在,无法完成换队招募');
|
||||
}
|
||||
|
||||
const releasedCompanion = params.session.companions[replaceIndex];
|
||||
if (!releasedCompanion) {
|
||||
throw conflict('指定的离队同伴不存在,无法完成换队招募');
|
||||
}
|
||||
|
||||
const nextCompanions = [...params.session.companions];
|
||||
nextCompanions[replaceIndex] = params.companion;
|
||||
|
||||
return {
|
||||
companions: nextCompanions,
|
||||
roster: normalizeRoster(
|
||||
upsertCompanion(nextRosterWithoutRecruit, releasedCompanion),
|
||||
nextCompanions,
|
||||
),
|
||||
releasedCompanion,
|
||||
};
|
||||
}
|
||||
|
||||
function buildBattleTarget(
|
||||
encounter: RuntimeEncounter,
|
||||
rawGameState: JsonRecord,
|
||||
@@ -92,6 +248,7 @@ function buildBattleTarget(
|
||||
export function resolveNpcInteraction(
|
||||
session: RuntimeSession,
|
||||
functionId: string,
|
||||
payload?: JsonRecord,
|
||||
): NpcInteractionResolution {
|
||||
const encounter = requireNpcEncounter(session);
|
||||
const npcState = requireNpcState(session, encounter);
|
||||
@@ -179,20 +336,29 @@ export function resolveNpcInteraction(
|
||||
if (npcState.affinity < 60) {
|
||||
throw conflict('当前关系还没达到招募阈值,暂时不能邀请入队');
|
||||
}
|
||||
if (session.companions.length >= MAX_TASK5_COMPANIONS) {
|
||||
throw conflict('队伍已满,任务5首轮后端接口暂不处理换队逻辑');
|
||||
}
|
||||
|
||||
setEncounterNpcState(session, {
|
||||
...npcState,
|
||||
const releaseNpcId = readString(payload?.releaseNpcId) || null;
|
||||
const recruitedCompanion = buildRecruitedCompanion(
|
||||
session,
|
||||
encounter,
|
||||
npcState,
|
||||
);
|
||||
const recruitmentResult = recruitCompanionToParty({
|
||||
session,
|
||||
companion: recruitedCompanion,
|
||||
releaseNpcId,
|
||||
});
|
||||
const nextNpcState = {
|
||||
...markNpcFirstMeaningfulContactResolved(npcState),
|
||||
recruited: true,
|
||||
firstMeaningfulContactResolved: true,
|
||||
});
|
||||
session.companions.push({
|
||||
npcId: encounter.id,
|
||||
characterId: encounter.characterId ?? '',
|
||||
joinedAtAffinity: npcState.affinity,
|
||||
});
|
||||
stanceProfile: applyStoryChoiceToStanceProfile(
|
||||
npcState.stanceProfile,
|
||||
'npc_recruit',
|
||||
{ recruited: true },
|
||||
),
|
||||
};
|
||||
setEncounterNpcState(session, nextNpcState);
|
||||
session.companions = recruitmentResult.companions;
|
||||
session.roster = recruitmentResult.roster;
|
||||
session.currentEncounter = null;
|
||||
session.npcInteractionActive = false;
|
||||
session.currentNpcBattleMode = null;
|
||||
@@ -202,7 +368,9 @@ export function resolveNpcInteraction(
|
||||
|
||||
return {
|
||||
actionText: `邀请${encounter.npcName}加入队伍`,
|
||||
resultText: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
|
||||
resultText: recruitmentResult.releasedCompanion
|
||||
? `${encounter.npcName}接受了你的邀请,你先让一名当前同行暂时离队,把位置腾给了新的同行者。`
|
||||
: `${encounter.npcName}接受了你的邀请,正式进入了同行队伍。`,
|
||||
patches: [
|
||||
{
|
||||
type: 'status_changed',
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { RuntimeBattlePresentation } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import {
|
||||
applyQuestSignal,
|
||||
normalizeQuestEntries,
|
||||
} from './questProgressionService.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
|
||||
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
|
||||
|
||||
type JsonRecord = Record<string, unknown>;
|
||||
type RuntimeGameState = {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type {
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import {
|
||||
buildExperienceGrantResultText,
|
||||
grantPlayerExperience,
|
||||
@@ -26,8 +26,8 @@ import {
|
||||
} from './questTask6Bridge.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
|
||||
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
|
||||
|
||||
const SUPPORTED_QUEST_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'npc_chat_quest_offer_abandon',
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
QUEST_OBJECTIVE_KINDS,
|
||||
QUEST_REWARD_THEMES,
|
||||
QUEST_URGENCY_LEVELS,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import {
|
||||
buildQuestIntentPrompt,
|
||||
QUEST_INTENT_SYSTEM_PROMPT,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import {
|
||||
buildAvailableOptions,
|
||||
buildLegacyCurrentStory,
|
||||
buildRuntimeViewModel,
|
||||
} from './RpgRuntimeSessionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime option / view model 编译入口。
|
||||
* 工作包 G 后所有可见 option 与 view model 都从新域目录输出。
|
||||
*/
|
||||
export { buildAvailableOptions, buildRuntimeViewModel };
|
||||
export const buildRpgRuntimeAvailableOptions = buildAvailableOptions;
|
||||
export const buildRpgRuntimeViewModel = buildRuntimeViewModel;
|
||||
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;
|
||||
@@ -3,9 +3,13 @@ import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildAvailableOptions,
|
||||
} from './RpgRuntimeOptionCompiler.js';
|
||||
import {
|
||||
buildLegacyCurrentStory,
|
||||
} from './RpgRuntimeStoryPresentationCompiler.js';
|
||||
import {
|
||||
loadRuntimeSession,
|
||||
} from './runtimeSession.ts';
|
||||
} from './RpgRuntimeSessionLoader.js';
|
||||
|
||||
function createNpcSnapshot() {
|
||||
return {
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* RPG runtime session 编译主实现。
|
||||
* 工作包 G 把旧 `runtimeSession.ts` 的真实逻辑迁到这里,旧文件后续只保留兼容职责。
|
||||
*/
|
||||
import type {
|
||||
RuntimeStoryChoicePayload,
|
||||
RuntimeStoryEncounterViewModel,
|
||||
@@ -5,9 +9,9 @@ import type {
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryViewModel,
|
||||
Task5RuntimeOptionScope,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import { TASK6_RUNTIME_FUNCTION_IDS } from '../../../../packages/shared/src/contracts/rpgRuntimeStoryAction.js';
|
||||
import type { RpgRuntimeSavedSnapshot } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
|
||||
import {
|
||||
normalizeRuntimeEntityLevelProfile,
|
||||
type RuntimeEntityLevelProfile,
|
||||
@@ -75,6 +79,16 @@ export type RuntimeCompanion = {
|
||||
npcId: string;
|
||||
characterId: string;
|
||||
joinedAtAffinity: number;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
mana: number;
|
||||
maxMana: number;
|
||||
skillCooldowns: Record<string, number>;
|
||||
animationState?: string;
|
||||
actionMode?: string;
|
||||
offsetX?: number;
|
||||
offsetY?: number;
|
||||
transitionMs?: number;
|
||||
};
|
||||
|
||||
type RuntimePlayerAttributes = {
|
||||
@@ -146,6 +160,7 @@ export type RuntimeSession = {
|
||||
playerMaxMana: number;
|
||||
npcStates: Record<string, RuntimeNpcState>;
|
||||
companions: RuntimeCompanion[];
|
||||
roster: RuntimeCompanion[];
|
||||
currentNpcBattleMode: 'fight' | 'spar' | null;
|
||||
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
|
||||
};
|
||||
@@ -511,6 +526,53 @@ function normalizeCompanion(value: unknown): RuntimeCompanion | null {
|
||||
npcId,
|
||||
characterId: readString(rawCompanion.characterId),
|
||||
joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)),
|
||||
hp: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
readNumber(
|
||||
rawCompanion.hp,
|
||||
readNumber(rawCompanion.maxHp, 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
maxHp: Math.max(1, Math.round(readNumber(rawCompanion.maxHp, 1))),
|
||||
mana: Math.max(
|
||||
0,
|
||||
Math.round(
|
||||
readNumber(
|
||||
rawCompanion.mana,
|
||||
readNumber(rawCompanion.maxMana, 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
maxMana: Math.max(1, Math.round(readNumber(rawCompanion.maxMana, 1))),
|
||||
skillCooldowns: Object.fromEntries(
|
||||
Object.entries(
|
||||
isObject(rawCompanion.skillCooldowns)
|
||||
? rawCompanion.skillCooldowns
|
||||
: {},
|
||||
).map(([skillId, turns]) => [
|
||||
skillId,
|
||||
Math.max(0, Math.round(readNumber(turns, 0))),
|
||||
]),
|
||||
),
|
||||
animationState: readString(rawCompanion.animationState) || undefined,
|
||||
actionMode: readString(rawCompanion.actionMode) || undefined,
|
||||
offsetX:
|
||||
typeof rawCompanion.offsetX === 'number' &&
|
||||
Number.isFinite(rawCompanion.offsetX)
|
||||
? rawCompanion.offsetX
|
||||
: undefined,
|
||||
offsetY:
|
||||
typeof rawCompanion.offsetY === 'number' &&
|
||||
Number.isFinite(rawCompanion.offsetY)
|
||||
? rawCompanion.offsetY
|
||||
: undefined,
|
||||
transitionMs:
|
||||
typeof rawCompanion.transitionMs === 'number' &&
|
||||
Number.isFinite(rawCompanion.transitionMs)
|
||||
? Math.max(0, Math.round(rawCompanion.transitionMs))
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -531,6 +593,15 @@ function normalizeCompanions(value: unknown) {
|
||||
.filter((entry): entry is RuntimeCompanion => Boolean(entry));
|
||||
}
|
||||
|
||||
function normalizeRoster(
|
||||
roster: RuntimeCompanion[],
|
||||
companions: RuntimeCompanion[],
|
||||
) {
|
||||
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
|
||||
|
||||
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
|
||||
}
|
||||
|
||||
function normalizeHostileNpcs(value: unknown) {
|
||||
return readArray(value)
|
||||
.map((entry) => normalizeHostileNpc(entry))
|
||||
@@ -944,7 +1015,7 @@ export function getEncounterKey(encounter: RuntimeEncounter) {
|
||||
}
|
||||
|
||||
export function loadRuntimeSession(
|
||||
snapshot: SavedSnapshot,
|
||||
snapshot: RpgRuntimeSavedSnapshot,
|
||||
requestedSessionId: string,
|
||||
): RuntimeSession {
|
||||
const rawGameState = isObject(snapshot.gameState)
|
||||
@@ -982,6 +1053,10 @@ export function loadRuntimeSession(
|
||||
),
|
||||
npcStates: normalizeNpcStates(rawGameState.npcStates),
|
||||
companions: normalizeCompanions(rawGameState.companions),
|
||||
roster: normalizeRoster(
|
||||
normalizeCompanions(rawGameState.roster),
|
||||
normalizeCompanions(rawGameState.companions),
|
||||
),
|
||||
currentNpcBattleMode:
|
||||
rawGameState.currentNpcBattleMode === 'fight' ||
|
||||
rawGameState.currentNpcBattleMode === 'spar'
|
||||
@@ -1185,16 +1260,7 @@ export function buildAvailableOptions(session: RuntimeSession) {
|
||||
|
||||
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
|
||||
options.push(
|
||||
buildOptionView(
|
||||
session,
|
||||
'npc_recruit',
|
||||
session.companions.length >= MAX_TASK5_COMPANIONS
|
||||
? {
|
||||
disabled: true,
|
||||
reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。',
|
||||
}
|
||||
: {},
|
||||
),
|
||||
buildOptionView(session, 'npc_recruit'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1328,6 +1394,7 @@ export function syncRawGameState(session: RuntimeSession) {
|
||||
session.rawGameState.playerMaxMana = session.playerMaxMana;
|
||||
session.rawGameState.npcStates = cloneJson(session.npcStates);
|
||||
session.rawGameState.companions = cloneJson(session.companions);
|
||||
session.rawGameState.roster = cloneJson(session.roster);
|
||||
session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode;
|
||||
session.rawGameState.currentNpcBattleOutcome =
|
||||
session.currentNpcBattleOutcome;
|
||||
@@ -1367,6 +1434,7 @@ export function replaceRuntimeSessionRawGameState(
|
||||
session.playerMaxMana = refreshed.playerMaxMana;
|
||||
session.npcStates = refreshed.npcStates;
|
||||
session.companions = refreshed.companions;
|
||||
session.roster = refreshed.roster;
|
||||
session.currentNpcBattleMode = refreshed.currentNpcBattleMode;
|
||||
session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
loadRuntimeSession,
|
||||
type RuntimeSession,
|
||||
} from './RpgRuntimeSessionDomain.js';
|
||||
|
||||
export type { RuntimeSession };
|
||||
|
||||
/**
|
||||
* RPG runtime session loader 的主入口。
|
||||
* 工作包 G 把旧 `runtimeSession.ts` 的真实实现迁入新域后,这里负责承接稳定命名。
|
||||
*/
|
||||
export { loadRuntimeSession };
|
||||
export const loadRpgRuntimeSession = loadRuntimeSession;
|
||||
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* RPG runtime session 原子能力导出。
|
||||
* 这里集中输出运行时动作链直接依赖的 session 原语,避免再次回到旧热点文件取用。
|
||||
*/
|
||||
export {
|
||||
appendStoryHistory,
|
||||
getEncounterKey,
|
||||
getEncounterNpcState,
|
||||
getPlayerCharacter,
|
||||
getPlayerSkillCooldowns,
|
||||
isCombatFunctionId,
|
||||
isNpcFunctionId,
|
||||
isStoryFunctionId,
|
||||
isTask5FunctionId,
|
||||
isTask6RuntimeFunctionId,
|
||||
MAX_TASK5_COMPANIONS,
|
||||
setEncounterNpcState,
|
||||
syncRawGameState,
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
} from './RpgRuntimeSessionDomain.js';
|
||||
|
||||
export type {
|
||||
RuntimeCompanion,
|
||||
RuntimeEncounter,
|
||||
RuntimeHostileNpc,
|
||||
RuntimeNpcState,
|
||||
RuntimeSession,
|
||||
RuntimeStoryHistoryEntry,
|
||||
} from './RpgRuntimeSessionDomain.js';
|
||||
@@ -0,0 +1,13 @@
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
syncRawGameState,
|
||||
} from './RpgRuntimeSessionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime snapshot 同步入口。
|
||||
* 工作包 G 后 rawGameState 的回写与替换都统一从新域目录输出。
|
||||
*/
|
||||
export { replaceRuntimeSessionRawGameState, syncRawGameState };
|
||||
export const syncRpgRuntimeSnapshot = syncRawGameState;
|
||||
export const replaceRpgRuntimeSessionRawGameState =
|
||||
replaceRuntimeSessionRawGameState;
|
||||
@@ -1,3 +1,7 @@
|
||||
/**
|
||||
* RPG runtime story 主链迁移后的真实动作/状态实现。
|
||||
* 工作包 G 完成后,运行时动作解析直接落在 RPG runtime story 新域。
|
||||
*/
|
||||
import type {
|
||||
RuntimeBattlePresentation,
|
||||
RuntimeStoryActionRequest,
|
||||
@@ -5,9 +9,9 @@ import type {
|
||||
RuntimeStoryOptionView,
|
||||
RuntimeStoryPatch,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import type { RuntimeRepositoryPort } from '../../repositories/runtimeRepository.js';
|
||||
import type { RpgRuntimeSnapshotRepositoryPort } from '../../repositories/rpg-runtime/RpgRuntimeSnapshotRepository.js';
|
||||
import type { UpstreamLlmClient } from '../../services/llmClient.js';
|
||||
import {
|
||||
buildStrictNpcChatDialoguePrompt,
|
||||
@@ -39,21 +43,27 @@ import {
|
||||
resolveTreasureStoryAction,
|
||||
} from '../runtime-item/treasureStoryActionService.js';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
buildAvailableOptions,
|
||||
buildLegacyCurrentStory,
|
||||
buildRuntimeViewModel,
|
||||
} from './RpgRuntimeOptionCompiler.js';
|
||||
import {
|
||||
appendStoryHistory,
|
||||
getEncounterNpcState,
|
||||
isCombatFunctionId,
|
||||
isNpcFunctionId,
|
||||
isStoryFunctionId,
|
||||
isTask5FunctionId,
|
||||
loadRuntimeSession,
|
||||
type RuntimeSession,
|
||||
setEncounterNpcState,
|
||||
syncRawGameState,
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
} from './runtimeSession.js';
|
||||
} from './RpgRuntimeSessionPrimitives.js';
|
||||
import {
|
||||
buildLegacyCurrentStory,
|
||||
} from './RpgRuntimeStoryPresentationCompiler.js';
|
||||
import {
|
||||
loadRuntimeSession,
|
||||
type RuntimeSession,
|
||||
} from './RpgRuntimeSessionLoader.js';
|
||||
|
||||
type StoryResolution = {
|
||||
actionText: string;
|
||||
@@ -630,18 +640,23 @@ function normalizeIncomingSnapshot(snapshot: unknown) {
|
||||
}
|
||||
|
||||
async function resolveSnapshotForRequest(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
|
||||
userId: string;
|
||||
snapshot?: unknown;
|
||||
}) {
|
||||
const incomingSnapshot = normalizeIncomingSnapshot(params.snapshot);
|
||||
if (incomingSnapshot) {
|
||||
return hydrateSavedSnapshot(
|
||||
await params.runtimeRepository.putSnapshot(params.userId, incomingSnapshot),
|
||||
await params.snapshotRepository.putSnapshot(
|
||||
params.userId,
|
||||
incomingSnapshot,
|
||||
),
|
||||
)!;
|
||||
}
|
||||
|
||||
const persistedSnapshot = await params.runtimeRepository.getSnapshot(params.userId);
|
||||
const persistedSnapshot = await params.snapshotRepository.getSnapshot(
|
||||
params.userId,
|
||||
);
|
||||
if (!persistedSnapshot) {
|
||||
throw conflict('运行时快照不存在,请先初始化并保存一次游戏');
|
||||
}
|
||||
@@ -900,13 +915,13 @@ function resolveStoryFlowAction(
|
||||
}
|
||||
|
||||
export async function resolveRuntimeStoryAction(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
|
||||
llmClient?: UpstreamLlmClient;
|
||||
userId: string;
|
||||
request: RuntimeStoryActionRequest;
|
||||
}) {
|
||||
const hydratedSnapshot = await resolveSnapshotForRequest({
|
||||
runtimeRepository: params.runtimeRepository,
|
||||
snapshotRepository: params.snapshotRepository,
|
||||
userId: params.userId,
|
||||
snapshot: params.request.snapshot,
|
||||
});
|
||||
@@ -969,7 +984,13 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
: undefined,
|
||||
});
|
||||
} else if (isNpcFunctionId(functionId)) {
|
||||
resolution = resolveNpcInteraction(session, functionId);
|
||||
resolution = resolveNpcInteraction(
|
||||
session,
|
||||
functionId,
|
||||
isObject(params.request.action.payload)
|
||||
? params.request.action.payload
|
||||
: undefined,
|
||||
);
|
||||
} else if (isSupportedInventoryStoryFunctionId(functionId)) {
|
||||
resolution = resolveInventoryStoryAction(session, params.request);
|
||||
} else if (isSupportedNpcInventoryStoryFunctionId(functionId)) {
|
||||
@@ -1074,7 +1095,7 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
appendStoryHistory(session, actionText, historyResultText);
|
||||
syncRawGameState(session);
|
||||
|
||||
const persistedSnapshot = await params.runtimeRepository.putSnapshot(
|
||||
const persistedSnapshot = await params.snapshotRepository.putSnapshot(
|
||||
params.userId,
|
||||
normalizeSavedSnapshotPayload({
|
||||
savedAt: new Date().toISOString(),
|
||||
@@ -1109,14 +1130,14 @@ export async function resolveRuntimeStoryAction(params: {
|
||||
}
|
||||
|
||||
export async function getRuntimeStoryState(params: {
|
||||
runtimeRepository: RuntimeRepositoryPort;
|
||||
snapshotRepository: RpgRuntimeSnapshotRepositoryPort;
|
||||
userId: string;
|
||||
sessionId: string;
|
||||
clientVersion?: number;
|
||||
snapshot?: RuntimeStoryStateRequest['snapshot'];
|
||||
}) {
|
||||
const hydratedSnapshot = await resolveSnapshotForRequest({
|
||||
runtimeRepository: params.runtimeRepository,
|
||||
snapshotRepository: params.snapshotRepository,
|
||||
userId: params.userId,
|
||||
snapshot: params.snapshot,
|
||||
});
|
||||
@@ -0,0 +1,10 @@
|
||||
import {
|
||||
resolveRuntimeStoryAction,
|
||||
} from './RpgRuntimeStoryActionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime story 动作服务入口。
|
||||
* 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。
|
||||
*/
|
||||
export { resolveRuntimeStoryAction };
|
||||
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime story 展示兼容编译器。
|
||||
* 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。
|
||||
*/
|
||||
export { buildLegacyCurrentStory };
|
||||
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js';
|
||||
|
||||
/**
|
||||
* RPG runtime story 状态读取入口。
|
||||
* 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。
|
||||
*/
|
||||
export { getRuntimeStoryState };
|
||||
export const getRpgRuntimeStoryState = getRuntimeStoryState;
|
||||
35
server-node/src/modules/rpg-runtime-story/index.ts
Normal file
35
server-node/src/modules/rpg-runtime-story/index.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
export {
|
||||
buildRpgRuntimeAvailableOptions,
|
||||
buildRpgRuntimeLegacyCurrentStory,
|
||||
buildRpgRuntimeViewModel,
|
||||
} from './RpgRuntimeOptionCompiler.js';
|
||||
export {
|
||||
appendStoryHistory,
|
||||
getEncounterKey,
|
||||
getEncounterNpcState,
|
||||
getPlayerCharacter,
|
||||
getPlayerSkillCooldowns,
|
||||
isCombatFunctionId,
|
||||
isNpcFunctionId,
|
||||
isStoryFunctionId,
|
||||
isTask5FunctionId,
|
||||
isTask6RuntimeFunctionId,
|
||||
MAX_TASK5_COMPANIONS,
|
||||
setEncounterNpcState,
|
||||
TASK6_DEFERRED_FUNCTION_IDS,
|
||||
type RuntimeEncounter,
|
||||
type RuntimeNpcState,
|
||||
type RuntimeSession as RuntimeSessionPrimitives,
|
||||
} from './RpgRuntimeSessionPrimitives.js';
|
||||
export {
|
||||
loadRpgRuntimeSession,
|
||||
type RuntimeSession,
|
||||
} from './RpgRuntimeSessionLoader.js';
|
||||
export {
|
||||
replaceRpgRuntimeSessionRawGameState,
|
||||
syncRpgRuntimeSnapshot,
|
||||
} from './RpgRuntimeSnapshotSync.js';
|
||||
export {
|
||||
resolveRpgRuntimeStoryAction,
|
||||
} from './RpgRuntimeStoryActionService.js';
|
||||
export { getRpgRuntimeStoryState } from './RpgRuntimeStoryStateService.js';
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
|
||||
RUNTIME_ITEM_TONE_VALUES,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeQuestAssist.js';
|
||||
import {
|
||||
buildRuntimeItemIntentPromptText,
|
||||
RUNTIME_ITEM_INTENT_SYSTEM_PROMPT,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryPatch,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
} from '../../../../packages/shared/src/contracts/rpgRuntimeStoryState.js';
|
||||
import { conflict, invalidRequest } from '../../errors.js';
|
||||
import {
|
||||
addInventoryItems,
|
||||
@@ -14,8 +14,8 @@ import {
|
||||
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
|
||||
import {
|
||||
replaceRuntimeSessionRawGameState,
|
||||
type RuntimeSession,
|
||||
} from '../story/runtimeSession.js';
|
||||
} from '../rpg-runtime-story/RpgRuntimeSnapshotSync.js';
|
||||
import type { RuntimeSession } from '../rpg-runtime-story/RpgRuntimeSessionLoader.js';
|
||||
|
||||
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
|
||||
'treasure_inspect',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,104 +0,0 @@
|
||||
import { Router } from 'express';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type {
|
||||
RuntimeStoryActionRequest,
|
||||
RuntimeStoryStateRequest,
|
||||
} from '../../../../packages/shared/src/contracts/story.js';
|
||||
import type { AppContext } from '../../context.js';
|
||||
import { badRequest } from '../../errors.js';
|
||||
import { asyncHandler, sendApiResponse } from '../../http.js';
|
||||
import { requireJwtAuth } from '../../middleware/auth.js';
|
||||
import { routeMeta } from '../../middleware/routeMeta.js';
|
||||
import {
|
||||
getRuntimeStoryState,
|
||||
resolveRuntimeStoryAction,
|
||||
} from './storyActionService.js';
|
||||
|
||||
const actionPayloadSchema = z.record(z.string(), z.unknown());
|
||||
|
||||
const runtimeStoryActionSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
clientVersion: z.number().int().min(0).optional(),
|
||||
snapshot: z.unknown().optional(),
|
||||
action: z.object({
|
||||
type: z.literal('story_choice'),
|
||||
functionId: z.string().trim().min(1),
|
||||
targetId: z.string().trim().optional(),
|
||||
payload: actionPayloadSchema.optional().default({}),
|
||||
}),
|
||||
});
|
||||
|
||||
const runtimeStoryStateResolveSchema = z.object({
|
||||
sessionId: z.string().trim().min(1),
|
||||
clientVersion: z.number().int().min(0).optional(),
|
||||
snapshot: z.unknown().optional(),
|
||||
});
|
||||
|
||||
export function createStoryActionRoutes(context: AppContext) {
|
||||
const router = Router();
|
||||
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
||||
|
||||
router.use(requireAuth);
|
||||
|
||||
router.post(
|
||||
'/actions/resolve',
|
||||
routeMeta({ operation: 'runtime.story.actions.resolve' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeStoryActionSchema.parse(
|
||||
request.body,
|
||||
) as RuntimeStoryActionRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await resolveRuntimeStoryAction({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
llmClient: context.llmClient,
|
||||
userId: request.userId!,
|
||||
request: payload,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get(
|
||||
'/state/:sessionId',
|
||||
routeMeta({ operation: 'runtime.story.state.get' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const sessionId = request.params.sessionId?.trim() || '';
|
||||
if (!sessionId) {
|
||||
throw badRequest('sessionId is required');
|
||||
}
|
||||
|
||||
sendApiResponse(
|
||||
response,
|
||||
await getRuntimeStoryState({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
userId: request.userId!,
|
||||
sessionId,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
router.post(
|
||||
'/state/resolve',
|
||||
routeMeta({ operation: 'runtime.story.state.resolve' }),
|
||||
asyncHandler(async (request, response) => {
|
||||
const payload = runtimeStoryStateResolveSchema.parse(
|
||||
request.body,
|
||||
) as RuntimeStoryStateRequest;
|
||||
sendApiResponse(
|
||||
response,
|
||||
await getRuntimeStoryState({
|
||||
runtimeRepository: context.runtimeRepository,
|
||||
userId: request.userId!,
|
||||
sessionId: payload.sessionId,
|
||||
clientVersion: payload.clientVersion,
|
||||
snapshot: payload.snapshot,
|
||||
}),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
return router;
|
||||
}
|
||||
Reference in New Issue
Block a user