This commit is contained in:
2026-04-21 18:27:46 +08:00
parent 04bff9617d
commit 4372ab5be1
358 changed files with 30788 additions and 14737 deletions

View File

@@ -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';

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 ?? []),
],
});
}

View File

@@ -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}」存在无效的目标场景连接。`);
}
});
}

View File

@@ -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'];
};
}

View File

@@ -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';

View File

@@ -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,
};
}

View File

@@ -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),
};
});
}

View File

@@ -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);
}

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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

View File

@@ -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',

View File

@@ -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',

View File

@@ -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',

View File

@@ -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 = {

View File

@@ -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',

View File

@@ -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,

View File

@@ -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;

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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,
});

View File

@@ -0,0 +1,10 @@
import {
resolveRuntimeStoryAction,
} from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 动作服务入口。
* 工作包 G 后 runtime action 主链直接落到新域实现,不再继续桥接旧热点文件。
*/
export { resolveRuntimeStoryAction };
export const resolveRpgRuntimeStoryAction = resolveRuntimeStoryAction;

View File

@@ -0,0 +1,8 @@
import { buildLegacyCurrentStory } from './RpgRuntimeSessionDomain.js';
/**
* RPG runtime story 展示兼容编译器。
* 当前仍复用 legacy currentStory 投影规则,但真实落点已经切到新域目录。
*/
export { buildLegacyCurrentStory };
export const buildRpgRuntimeLegacyCurrentStory = buildLegacyCurrentStory;

View File

@@ -0,0 +1,8 @@
import { getRuntimeStoryState } from './RpgRuntimeStoryActionDomain.js';
/**
* RPG runtime story 状态读取入口。
* 工作包 G 先把状态读取从旧热点文件迁到新域命名,再保持动作与状态的物理落点分离。
*/
export { getRuntimeStoryState };
export const getRpgRuntimeStoryState = getRuntimeStoryState;

View 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';

View File

@@ -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,

View File

@@ -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

View File

@@ -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;
}