This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -0,0 +1,2 @@
export * from './runtimeItemResolutionService.js';
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';

View File

@@ -0,0 +1,784 @@
import {
RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES,
RUNTIME_ITEM_TONE_VALUES,
} from '../../../../packages/shared/src/contracts/story.js';
export type RuntimeItemFunctionalBias =
(typeof RUNTIME_ITEM_FUNCTIONAL_BIAS_VALUES)[number];
export type RuntimeItemTone = (typeof RUNTIME_ITEM_TONE_VALUES)[number];
export type RuntimeRelationAnchor =
| { type: 'npc'; npcName: string }
| { type: 'scene'; sceneName: string }
| { type: 'monster'; monsterName: string }
| { type: 'quest'; questName: string }
| { type: 'faction'; factionName: string }
| { type: 'landmark'; landmarkName: string };
export type RuntimeItemPlan = {
slot: string;
itemKind: 'equipment' | 'consumable' | 'material' | 'relic' | 'quest';
permanence: 'permanent' | 'timed' | 'resource';
relationAnchor: RuntimeRelationAnchor;
targetBuildDirection: string[];
};
export type RuntimeItemAiPromptInput = {
worldSummary: string;
sceneSummary: string;
encounterSummary: string;
relatedNpcSummary: string;
recentStorySummary: string;
activeThreadSummary: string;
generationChannel: string;
playerBuildDirection: string[];
playerBuildGaps: string[];
desiredItemKind: RuntimeItemPlan['itemKind'];
permanence: RuntimeItemPlan['permanence'];
};
export type RuntimeItemAiIntent = {
shortNameSeed: string;
sourcePhrase: string;
reasonToAppear: string;
relationHooks: string[];
desiredBuildTags: string[];
desiredFunctionalBias: RuntimeItemFunctionalBias[];
tone: RuntimeItemTone;
visibleClue: string;
witnessMark: string;
unfinishedBusiness: string;
hiddenHook: string;
reactionHooks: string[];
namingPattern: string;
};
export type RuntimeItemStoryFingerprint = {
relatedScarIds: string[];
relatedThreadIds: string[];
visibleClue: string;
witnessMark: string;
unresolvedQuestion: string;
};
export type RuntimeItemInventory = {
id: string;
category: string;
name: string;
description: string;
quantity: number;
rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
tags: string[];
equipmentSlotId?: string;
buildProfile?: {
role: string;
tags: string[];
synergy: string[];
forgeRank: number;
};
statProfile?: {
maxHpBonus?: number;
outgoingDamageBonus?: number;
incomingDamageMultiplier?: number;
};
useProfile?: {
hpRestore: number;
manaRestore: number;
cooldownReduction: number;
buildBuffs: Array<{
id: string;
sourceType: 'item';
sourceId: string;
name: string;
tags: string[];
durationTurns: number;
}>;
};
runtimeMetadata?: {
origin: 'ai_compiled' | 'procedural';
generationChannel: string;
seedKey: string;
sourceReason: string;
storyFingerprint: RuntimeItemStoryFingerprint;
};
};
export type DirectedRuntimeReward = {
primaryItem: RuntimeItemInventory | null;
supportItems: RuntimeItemInventory[];
hp?: number;
mana?: number;
currency?: number;
storyHint?: string;
};
export type RuntimeItemGenerationContext = {
worldType: string | null | undefined;
customWorldProfile?: {
name?: string;
summary?: string;
} | null;
sceneId: string | null;
sceneName: string | null;
sceneDescription: string | null;
treasureHints: string[];
encounter: {
id?: string;
kind?: string;
npcName: string;
npcDescription?: string;
npcAvatar?: string;
context?: string;
} | null;
encounterNpcId: string | null;
encounterNpcName: string | null;
encounterContextText: string | null;
relatedNpcState: {
affinity?: number;
} | null;
relatedNpcNarrativeProfile: {
publicMask?: string;
visibleLine?: string;
immediatePressure?: string;
debtOrBurden?: string;
contradiction?: string;
taboo?: string;
reactionHooks?: string[];
relatedThreadIds?: string[];
} | null;
relatedScene: {
id: string;
name: string;
description?: string;
treasureHints?: string[];
} | null;
recentStorySummary: string;
recentActions: string[];
activeThreadIds: string[];
playerCharacterId: string;
playerBuildTags: string[];
playerBuildGaps: string[];
playerEquipmentTags: string[];
generationChannel: string;
};
type LooseContextInput = {
worldType: string | null | undefined;
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
scene?: RuntimeItemGenerationContext['relatedScene'];
encounter?: RuntimeItemGenerationContext['encounter'];
relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState'];
storyHistory?: Array<{ text: string }>;
playerCharacterId?: string;
playerBuildTags?: string[];
playerEquipmentTags?: string[];
generationChannel: string;
};
function dedupeStrings(values: Array<string | null | undefined>) {
return [...new Set(values.map((value) => value?.trim() ?? '').filter(Boolean))];
}
function sanitizeFragment(value: string | null | undefined, maxLength = 4) {
return (value ?? '')
.replace(/[^\u4e00-\u9fa5a-z0-9]/giu, '')
.slice(0, maxLength);
}
function resolveAnchorLabel(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return anchor.npcName;
case 'scene':
return anchor.sceneName;
case 'monster':
return anchor.monsterName;
case 'quest':
return anchor.questName;
case 'faction':
return anchor.factionName;
default:
return anchor.landmarkName;
}
}
function buildRecentStoryLines(storyHistory: Array<{ text: string }> = []) {
return storyHistory
.slice(-4)
.map((moment) => moment.text.trim())
.filter(Boolean)
.slice(-3);
}
function buildRecentStorySummary(lines: string[]) {
return lines.length > 0 ? lines.join(' / ') : '最近没有形成稳定的事件线索。';
}
function derivePlayerBuildGaps(playerBuildTags: string[]) {
const gapChecks = [
{ id: 'survival_gap', tags: ['守御', '护体', '回复', '续战'] },
{ id: 'mana_gap', tags: ['法力', '冷却', '护持', '过载'] },
{ id: 'finisher_gap', tags: ['爆发', '重击', '追击', '压制'] },
];
const tagSet = new Set(playerBuildTags);
return gapChecks
.filter((definition) => definition.tags.every((tag) => !tagSet.has(tag)))
.map((definition) => definition.id)
.slice(0, 3);
}
function buildRuntimeItemStoryFingerprint(params: {
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
const anchorKey = sanitizeFragment(resolveAnchorLabel(params.plan.relationAnchor), 6) || '旧痕';
return {
relatedScarIds: [`scar:${params.context.generationChannel}:${anchorKey}`],
relatedThreadIds: params.context.activeThreadIds.slice(0, 2),
visibleClue: params.intent.visibleClue,
witnessMark: params.intent.witnessMark,
unresolvedQuestion: params.intent.hiddenHook || params.intent.unfinishedBusiness,
} satisfies RuntimeItemStoryFingerprint;
}
function buildNarrativeName(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
index: number,
) {
const seed = intent.shortNameSeed || '旧痕';
switch (plan.itemKind) {
case 'equipment':
return `${seed}${index === 0 ? '战符' : '护具'}`;
case 'consumable':
return `${seed}${intent.desiredFunctionalBias.includes('mana') ? '回息散' : '疗伤散'}`;
case 'material':
return `${seed}残材`;
case 'quest':
return `${seed}凭证`;
default:
return `${seed}遗物`;
}
}
function buildNarrativeDescription(params: {
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
}) {
const buildText = params.context.playerBuildTags.join('、') || '当前构筑';
const anchorText = resolveAnchorLabel(params.plan.relationAnchor);
return `${anchorText}把这件物件推到了你面前。它会围绕你的构筑 ${buildText} 发挥作用,原因是:${params.intent.reasonToAppear}`;
}
function createRelationAnchor(
context: RuntimeItemGenerationContext,
index = 0,
): RuntimeRelationAnchor {
if (context.encounterNpcName) {
return {
type: 'npc',
npcName: context.encounterNpcName,
};
}
if (context.sceneName) {
return {
type: 'scene',
sceneName: context.sceneName,
};
}
return {
type: 'landmark',
landmarkName: `遗址${index + 1}`,
};
}
function buildPlanFromOptions(params: {
context: RuntimeItemGenerationContext;
index: number;
fixedKinds?: RuntimeItemPlan['itemKind'][];
fixedPermanence?: RuntimeItemPlan['permanence'][];
}) {
return {
slot: `slot_${params.index + 1}`,
itemKind: params.fixedKinds?.[params.index] ?? 'relic',
permanence: params.fixedPermanence?.[params.index] ?? 'permanent',
relationAnchor: createRelationAnchor(params.context, params.index),
targetBuildDirection: params.context.playerBuildTags.slice(0, 3),
} satisfies RuntimeItemPlan;
}
function buildItemRarity(plan: RuntimeItemPlan) {
if (plan.itemKind === 'equipment' || plan.itemKind === 'relic') {
return 'rare' as const;
}
if (plan.itemKind === 'quest') {
return 'epic' as const;
}
return 'uncommon' as const;
}
function buildItemTags(
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
context: RuntimeItemGenerationContext,
) {
return dedupeStrings([
plan.itemKind,
...intent.desiredBuildTags,
...context.playerBuildTags.slice(0, 2),
...intent.desiredFunctionalBias,
]);
}
function buildItemProfiles(
itemId: string,
plan: RuntimeItemPlan,
intent: RuntimeItemAiIntent,
context: RuntimeItemGenerationContext,
) {
if (plan.itemKind === 'equipment') {
return {
equipmentSlotId: 'weapon',
buildProfile: {
role: context.playerBuildTags[0] ?? '均衡',
tags: buildItemTags(plan, intent, context).slice(0, 3),
synergy: buildItemTags(plan, intent, context).slice(0, 3),
forgeRank: 0,
},
statProfile: {
maxHpBonus: intent.desiredFunctionalBias.includes('guard') ? 16 : 8,
outgoingDamageBonus: intent.desiredFunctionalBias.includes('damage')
? 0.12
: 0.05,
incomingDamageMultiplier: intent.desiredFunctionalBias.includes('guard')
? 0.9
: 0.96,
},
};
}
if (plan.itemKind === 'consumable') {
return {
useProfile: {
hpRestore: intent.desiredFunctionalBias.includes('heal') ? 12 : 0,
manaRestore: intent.desiredFunctionalBias.includes('mana') ? 10 : 0,
cooldownReduction: intent.desiredFunctionalBias.includes('cooldown') ? 1 : 0,
buildBuffs: [
{
id: `${itemId}:buff`,
sourceType: 'item' as const,
sourceId: itemId,
name: `${intent.shortNameSeed || '旧痕'}增益`,
tags: buildItemTags(plan, intent, context).slice(0, 2),
durationTurns: 2,
},
],
},
};
}
return {};
}
function buildRuntimeInventoryItem(params: {
context: RuntimeItemGenerationContext;
plan: RuntimeItemPlan;
intent: RuntimeItemAiIntent;
seedKey: string;
index: number;
}) {
const itemId = `${params.seedKey}:${params.index + 1}`;
const storyFingerprint = buildRuntimeItemStoryFingerprint(params);
const name = buildNarrativeName(params.plan, params.intent, params.index);
return {
id: itemId,
category:
params.plan.itemKind === 'equipment'
? '装备'
: params.plan.itemKind === 'consumable'
? '消耗品'
: params.plan.itemKind === 'material'
? '材料'
: params.plan.itemKind === 'quest'
? '凭证'
: '遗物',
name,
description: buildNarrativeDescription(params),
quantity: 1,
rarity: buildItemRarity(params.plan),
tags: buildItemTags(params.plan, params.intent, params.context),
runtimeMetadata: {
origin: 'ai_compiled' as const,
generationChannel: params.context.generationChannel,
seedKey: itemId,
sourceReason: params.intent.reasonToAppear,
storyFingerprint,
},
...buildItemProfiles(itemId, params.plan, params.intent, params.context),
} satisfies RuntimeItemInventory;
}
export function buildRuntimeItemAiPromptInput(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
) {
return {
worldSummary:
context.customWorldProfile?.summary ?? context.worldType ?? '未知世界',
sceneSummary: [context.sceneName, context.sceneDescription].filter(Boolean).join(' / '),
encounterSummary: [context.encounterNpcName, context.encounterContextText]
.filter(Boolean)
.join(' / '),
relatedNpcSummary: context.relatedNpcNarrativeProfile
? `${context.encounterNpcName ?? '相关人物'}:公开面 ${
context.relatedNpcNarrativeProfile.publicMask ?? '暂无'
};当前压力 ${context.relatedNpcNarrativeProfile.immediatePressure ?? '暂无'}`
: context.relatedNpcState
? `${context.encounterNpcName ?? '相关人物'} 当前好感 ${context.relatedNpcState.affinity ?? 0}`
: '暂无明确人物关系',
recentStorySummary: context.recentStorySummary,
activeThreadSummary: context.activeThreadIds.join('、'),
generationChannel: context.generationChannel,
playerBuildDirection: context.playerBuildTags,
playerBuildGaps: context.playerBuildGaps,
desiredItemKind: plan.itemKind,
permanence: plan.permanence,
} satisfies RuntimeItemAiPromptInput;
}
export function buildRuntimeItemAiIntent(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
) {
const anchorLabel = resolveAnchorLabel(plan.relationAnchor);
const sourceSeed =
sanitizeFragment(context.sceneName, 4) ||
sanitizeFragment(context.customWorldProfile?.name, 4) ||
sanitizeFragment(anchorLabel, 4) ||
'旧誓';
const functionalBias: RuntimeItemFunctionalBias[] = [];
if (plan.permanence === 'timed') {
functionalBias.push(
context.playerBuildGaps.includes('survival_gap') ? 'heal' : 'cooldown',
);
}
if (context.playerBuildGaps.includes('mana_gap')) functionalBias.push('mana');
if (context.playerBuildGaps.includes('survival_gap')) functionalBias.push('guard');
if (
functionalBias.length <= 0 ||
context.playerBuildGaps.includes('finisher_gap') ||
plan.itemKind === 'equipment'
) {
functionalBias.push('damage');
}
return {
shortNameSeed: sourceSeed,
sourcePhrase: anchorLabel,
reasonToAppear:
context.generationChannel === 'monster_drop'
? `${anchorLabel}倒下后,${context.sceneName ?? '这片战场'}里最值得带走的残留被翻了出来。`
: `${anchorLabel}与最近局势把它推到了你面前。`,
relationHooks: [context.encounterContextText ?? context.sceneName ?? anchorLabel, ...context.recentActions]
.filter(Boolean)
.slice(0, 2) as string[],
desiredBuildTags: dedupeStrings([
...plan.targetBuildDirection,
...context.playerBuildTags.slice(0, 2),
]).slice(0, 3),
desiredFunctionalBias: [...new Set(functionalBias)].slice(0, 2),
tone:
context.generationChannel === 'monster_drop'
? 'grim'
: context.generationChannel === 'quest_reward'
? 'ritual'
: context.playerBuildGaps.includes('survival_gap')
? 'survival'
: 'martial',
visibleClue:
context.relatedNpcNarrativeProfile?.visibleLine ??
`${anchorLabel}身上留下的旧痕`,
witnessMark:
context.relatedNpcNarrativeProfile?.debtOrBurden ??
`${anchorLabel}尚未散尽的使用痕`,
unfinishedBusiness:
context.relatedNpcNarrativeProfile?.contradiction ??
`${anchorLabel}背后还有没说完的问题`,
hiddenHook:
context.relatedNpcNarrativeProfile?.taboo ??
`${anchorLabel}为什么会在此刻重新出现`,
reactionHooks: [
...(context.relatedNpcNarrativeProfile?.reactionHooks ?? []),
...(context.activeThreadIds ?? []),
].slice(0, 4),
namingPattern:
plan.itemKind === 'quest'
? 'quest_evidence'
: plan.itemKind === 'material'
? 'scene_relic'
: plan.relationAnchor.type === 'monster'
? 'monster_trophy'
: plan.relationAnchor.type === 'npc'
? 'npc_relic'
: 'faction_issue',
} satisfies RuntimeItemAiIntent;
}
function describeRelationAnchor(anchor: RuntimeRelationAnchor) {
switch (anchor.type) {
case 'npc':
return `NPC:${anchor.npcName}`;
case 'scene':
return `场景:${anchor.sceneName}`;
case 'monster':
return `怪物:${anchor.monsterName}`;
case 'quest':
return `任务:${anchor.questName}`;
case 'faction':
return `势力:${anchor.factionName}`;
default:
return `地标:${anchor.landmarkName}`;
}
}
function describePlan(
context: RuntimeItemGenerationContext,
plan: RuntimeItemPlan,
index: number,
) {
const promptInput = buildRuntimeItemAiPromptInput(context, plan);
return [
`物品 ${index + 1}`,
`- slot: ${plan.slot}`,
`- 物品类型: ${promptInput.desiredItemKind}`,
`- 持续性: ${promptInput.permanence}`,
`- 关系锚点: ${describeRelationAnchor(plan.relationAnchor)}`,
`- 世界摘要: ${promptInput.worldSummary}`,
`- 场景摘要: ${promptInput.sceneSummary || '无'}`,
`- 遭遇摘要: ${promptInput.encounterSummary || '无'}`,
`- 相关人物: ${promptInput.relatedNpcSummary}`,
`- 当前激活线程: ${promptInput.activeThreadSummary || '暂无'}`,
`- 近期剧情: ${promptInput.recentStorySummary}`,
`- 玩家当前 build: ${promptInput.playerBuildDirection.join('、') || '均衡'}`,
`- 玩家待补缺口: ${promptInput.playerBuildGaps.join('、') || '无明显缺口'}`,
`- 本次目标方向: ${plan.targetBuildDirection.join('、') || '均衡'}`,
].join('\n');
}
export const RUNTIME_ITEM_INTENT_SYSTEM_PROMPT = `你是 AI 原生叙事 RPG 的运行时物品导演。
你只返回 JSON不要输出 Markdown、解释或代码块。
输出结构:
{
"intents": [
{
"shortNameSeed": "中文短种子",
"sourcePhrase": "中文来源短语",
"reasonToAppear": "中文出现理由",
"relationHooks": ["中文关系钩子"],
"desiredBuildTags": ["中文 build 标签"],
"desiredFunctionalBias": ["heal|mana|cooldown|guard|damage"],
"tone": "grim|mysterious|martial|ritual|survival",
"visibleClue": "玩家第一眼能抓到的痕迹",
"witnessMark": "它见证过什么的使用痕",
"unfinishedBusiness": "背后仍未结清的问题",
"hiddenHook": "更深一层但别直接讲穿的钩子",
"reactionHooks": ["以后谁会对它起反应"],
"namingPattern": "命名范式建议"
}
]
}
规则:
- intents 数量必须与输入物品数量完全一致,顺序也必须一致。
- 所有自然语言字段都必须使用中文。
- 物品意图必须贴合当前场景、关系锚点、近期剧情和玩家当前 build。
- desiredBuildTags 要优先围绕玩家当前 build 与待补缺口,不要写空数组。
- desiredFunctionalBias 只能从给定枚举里选 1 到 2 个。
- reasonToAppear 必须解释为什么这件东西会在当前局势里出现,而不是泛泛描述。`;
export function buildRuntimeItemIntentPrompt(params: {
context: RuntimeItemGenerationContext;
plans: RuntimeItemPlan[];
}) {
return [
`生成渠道:${params.context.generationChannel}`,
`以下每个物品都需要给出一条可编译的运行时物品意图。`,
...params.plans.map((plan, index) => describePlan(params.context, plan, index)),
'请严格返回 JSON。',
].join('\n\n');
}
function buildBaseRuntimeContext(params: {
worldType: string | null | undefined;
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
scene?: RuntimeItemGenerationContext['relatedScene'];
encounter?: RuntimeItemGenerationContext['encounter'];
relatedNpcState?: RuntimeItemGenerationContext['relatedNpcState'];
storyHistory?: Array<{ text: string }>;
playerCharacterId?: string;
playerBuildTags?: string[];
playerEquipmentTags?: string[];
generationChannel: string;
}) {
const recentStoryLines = buildRecentStoryLines(params.storyHistory);
const activeThreadIds = dedupeStrings(
params.encounter?.kind === 'npc' && params.encounter?.id
? [`thread:${params.encounter.id}`]
: params.scene?.id
? [`thread:${params.scene.id}`]
: [],
).slice(0, 3);
return {
worldType: params.worldType,
customWorldProfile: params.customWorldProfile ?? null,
sceneId: params.scene?.id ?? null,
sceneName: params.scene?.name ?? null,
sceneDescription: params.scene?.description ?? null,
treasureHints: [...(params.scene?.treasureHints ?? [])],
encounter: params.encounter ?? null,
encounterNpcId:
params.encounter?.id ?? params.encounter?.npcName ?? null,
encounterNpcName: params.encounter?.npcName ?? null,
encounterContextText: params.encounter?.context ?? null,
relatedNpcState: params.relatedNpcState ?? null,
relatedNpcNarrativeProfile: null,
relatedScene: params.scene ?? null,
recentStorySummary: buildRecentStorySummary(recentStoryLines),
recentActions: recentStoryLines,
activeThreadIds,
playerCharacterId: params.playerCharacterId ?? 'runtime-loose-player',
playerBuildTags: params.playerBuildTags ?? [],
playerBuildGaps: derivePlayerBuildGaps(params.playerBuildTags ?? []),
playerEquipmentTags: params.playerEquipmentTags ?? [],
generationChannel: params.generationChannel,
} satisfies RuntimeItemGenerationContext;
}
export function buildLooseRuntimeItemGenerationContext(params: LooseContextInput) {
return buildBaseRuntimeContext(params);
}
export function buildQuestRuntimeItemGenerationContext(params: {
context: {
worldType?: string | null;
customWorldProfile?: RuntimeItemGenerationContext['customWorldProfile'];
currentSceneId?: string | null;
currentSceneName?: string | null;
currentSceneDescription?: string | null;
issuerAffinity?: number | null;
recentStoryMoments?: Array<{ text: string }>;
playerCharacter?: { id: string } | null;
};
generationChannel?: string;
issuerNpcId: string;
issuerNpcName: string;
roleText: string;
scene?: RuntimeItemGenerationContext['relatedScene'];
}) {
const { context, issuerNpcId, issuerNpcName, roleText } = params;
return buildBaseRuntimeContext({
worldType: context.worldType ?? null,
customWorldProfile: context.customWorldProfile ?? null,
scene:
params.scene ??
(context.currentSceneName
? {
id: context.currentSceneId ?? '',
name: context.currentSceneName,
description: context.currentSceneDescription ?? '',
treasureHints: [],
}
: null),
encounter: {
id: issuerNpcId,
kind: 'npc',
npcName: issuerNpcName,
npcDescription: roleText,
npcAvatar: '',
context: roleText,
},
relatedNpcState:
context.issuerAffinity == null
? null
: {
affinity: context.issuerAffinity,
},
storyHistory: context.recentStoryMoments ?? [],
playerCharacterId: context.playerCharacter?.id ?? 'quest-player',
generationChannel: params.generationChannel ?? 'quest_reward',
});
}
export function buildDirectedRuntimeReward(
context: RuntimeItemGenerationContext,
options: {
seedKey: string;
itemCount?: number;
fixedKinds?: RuntimeItemPlan['itemKind'][];
fixedPermanence?: RuntimeItemPlan['permanence'][];
baseHp?: number;
baseMana?: number;
baseCurrency?: number;
storyHint?: string;
},
) {
const itemCount = Math.max(1, options.itemCount ?? 2);
const items = Array.from({ length: itemCount }, (_, index) => {
const plan = buildPlanFromOptions({
context,
index,
fixedKinds: options.fixedKinds,
fixedPermanence: options.fixedPermanence,
});
const intent = buildRuntimeItemAiIntent(context, plan);
return buildRuntimeInventoryItem({
context,
plan,
intent,
seedKey: options.seedKey,
index,
});
});
return {
primaryItem: items[0] ?? null,
supportItems: items.slice(1),
hp: options.baseHp ?? 0,
mana: options.baseMana ?? 0,
currency: options.baseCurrency ?? 0,
storyHint:
options.storyHint ??
(items[0]
? `${items[0].name} 先露出的是“${
items[0].runtimeMetadata?.storyFingerprint.visibleClue ?? '旧痕'
}”。`
: '你得到了一件与当前局势相关的物品。'),
} satisfies DirectedRuntimeReward;
}
export function flattenDirectedRuntimeRewardItems(reward: DirectedRuntimeReward) {
return [
...(reward.primaryItem ? [reward.primaryItem] : []),
...reward.supportItems,
];
}
export function buildRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: Parameters<typeof buildDirectedRuntimeReward>[1],
) {
return flattenDirectedRuntimeRewardItems(
buildDirectedRuntimeReward(context, options),
);
}

View File

@@ -0,0 +1,96 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
buildLooseRuntimeItemGenerationContext,
buildQuestRuntimeItemGenerationContext,
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
import {
resolveDirectedReward,
resolveRuntimeInventoryStock,
} from './runtimeItemResolutionService.js';
const TEST_WUXIA_WORLD = 'WUXIA' as Parameters<
typeof buildLooseRuntimeItemGenerationContext
>[0]['worldType'];
const TEST_XIANXIA_WORLD = 'XIANXIA' as NonNullable<
Parameters<typeof buildQuestRuntimeItemGenerationContext>[0]['context']['worldType']
>;
test('resolveDirectedReward returns flattened runtime reward items on the server side', () => {
const context = buildLooseRuntimeItemGenerationContext({
worldType: TEST_WUXIA_WORLD,
scene: {
id: 'scene-ruins',
name: '断碑古道',
description: '碎碑与旧誓散落在路旁。',
treasureHints: ['残匣', '旧祭火'],
},
encounter: {
id: 'treasure-altar',
kind: 'treasure',
npcName: '断誓秘匣',
npcDescription: '匣盖上留着未熄的旧印。',
npcAvatar: '',
context: '古道祭坛',
},
playerCharacterId: 'hero',
playerBuildTags: ['快剑', '追击'],
generationChannel: 'treasure',
});
const result = resolveDirectedReward(context, {
seedKey: 'task6:treasure',
fixedKinds: ['relic', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
assert.equal(result.items.length, 2);
assert.equal(
result.reward.primaryItem?.runtimeMetadata?.generationChannel,
'treasure',
);
assert.equal(result.items[0]?.id, result.reward.primaryItem?.id);
assert.ok(result.reward.primaryItem?.description?.includes('构筑'));
});
test('resolveRuntimeInventoryStock builds quest-flavored stock without browser fallback', () => {
const context = buildQuestRuntimeItemGenerationContext({
context: {
worldType: TEST_XIANXIA_WORLD,
currentSceneId: 'scene-cloud',
currentSceneName: '云阙旧渡',
currentSceneDescription: '旧渡口残留着灵潮和巡守痕迹。',
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
issuerNpcContext: '巡守',
issuerAffinity: 24,
recentStoryMoments: [],
playerCharacter: null,
},
issuerNpcId: 'npc-issuer',
issuerNpcName: '巡守使',
roleText: '巡守',
scene: {
id: 'scene-cloud',
name: '云阙旧渡',
description: '旧渡口残留着灵潮和巡守痕迹。',
treasureHints: ['旧印'],
},
});
const items = resolveRuntimeInventoryStock(context, {
seedKey: 'task6:quest',
fixedKinds: ['equipment', 'consumable'],
fixedPermanence: ['permanent', 'timed'],
itemCount: 2,
});
assert.equal(items.length, 2);
assert.equal(
items.every((item) => item.runtimeMetadata?.generationChannel === 'quest_reward'),
true,
);
assert.equal(items.some((item) => Boolean(item.buildProfile || item.useProfile)), true);
});

View File

@@ -0,0 +1,39 @@
import {
buildDirectedRuntimeReward,
buildRuntimeInventoryStock,
flattenDirectedRuntimeRewardItems,
} from '../../bridges/legacyRuntimeItemResolutionBridge.js';
export type RuntimeItemGenerationContext = Parameters<
typeof buildDirectedRuntimeReward
>[0];
export type RuntimeRewardOptions = Parameters<
typeof buildDirectedRuntimeReward
>[1];
export type DirectedRuntimeReward = ReturnType<typeof buildDirectedRuntimeReward>;
export type ResolvedRuntimeRewardItem = ReturnType<
typeof buildRuntimeInventoryStock
>[number];
export type RuntimeRewardResolution = {
reward: DirectedRuntimeReward;
items: ResolvedRuntimeRewardItem[];
};
export function resolveDirectedReward(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): RuntimeRewardResolution {
const reward = buildDirectedRuntimeReward(context, options);
return {
reward,
items: flattenDirectedRuntimeRewardItems(reward),
};
}
export function resolveRuntimeInventoryStock(
context: RuntimeItemGenerationContext,
options: RuntimeRewardOptions,
): ResolvedRuntimeRewardItem[] {
return buildRuntimeInventoryStock(context, options);
}

View File

@@ -0,0 +1,80 @@
import {
buildDirectedRuntimeReward,
buildLooseRuntimeItemGenerationContext,
flattenDirectedRuntimeRewardItems,
} from './runtimeItemModule.js';
type TreasureInteractionAction = 'inspect' | 'leave' | 'secure';
type RuntimeStateLike = {
worldType: string | null | undefined;
currentScenePreset?: {
id: string;
name: string;
description?: string;
treasureHints?: string[];
} | null;
currentEncounter?: {
id?: string;
kind?: string;
npcName: string;
npcDescription?: string;
npcAvatar?: string;
context?: string;
} | null;
playerCharacter?: {
id: string;
} | null;
};
type RuntimeEncounterLike = NonNullable<RuntimeStateLike['currentEncounter']>;
export type TreasureReward = {
items: ReturnType<typeof flattenDirectedRuntimeRewardItems>;
hp: number;
mana: number;
currency: number;
storyHint?: string;
};
export function resolveTreasureReward(
state: RuntimeStateLike,
encounter: RuntimeEncounterLike,
action: TreasureInteractionAction,
) {
const context = buildLooseRuntimeItemGenerationContext({
worldType: state.worldType,
scene: state.currentScenePreset ?? null,
encounter,
playerCharacterId: state.playerCharacter?.id ?? 'treasure-player',
generationChannel: 'treasure',
});
const directed = buildDirectedRuntimeReward(context, {
seedKey: `treasure:${encounter.id ?? encounter.npcName}:${action}`,
variant: action,
itemCount: 2,
fixedKinds:
action === 'inspect' ? ['relic', 'consumable'] : ['relic', 'material'],
fixedPermanence:
action === 'inspect' ? ['permanent', 'timed'] : ['permanent', 'resource'],
baseHp: action === 'inspect' ? 10 : 0,
baseMana: action === 'inspect' ? 12 : 0,
baseCurrency:
action === 'inspect'
? state.worldType === 'XIANXIA'
? 34
: 48
: state.worldType === 'XIANXIA'
? 22
: 30,
storyHint: `${encounter.npcName}里藏着与你当前构筑和现场线索贴合的战利品。`,
} as Parameters<typeof buildDirectedRuntimeReward>[1]);
return {
items: flattenDirectedRuntimeRewardItems(directed),
hp: directed.hp ?? 0,
mana: directed.mana ?? 0,
currency: directed.currency ?? 0,
storyHint: directed.storyHint,
} satisfies TreasureReward;
}

View File

@@ -0,0 +1,140 @@
import type {
RuntimeStoryActionRequest,
RuntimeStoryPatch,
} from '../../../../packages/shared/src/contracts/story.js';
import { conflict, invalidRequest } from '../../errors.js';
import {
addInventoryItems,
appendStoryEngineCarrierMemory,
} from '../../bridges/legacyNpcTask6Bridge.js';
import {
buildTreasureResultText,
resolveTreasureReward,
} from '../../bridges/legacyTreasureRuntimeBridge.js';
import { buildBuildToast } from '../inventory/inventoryStoryActionService.js';
import {
replaceRuntimeSessionRawGameState,
type RuntimeSession,
} from '../story/runtimeSession.js';
const SUPPORTED_TREASURE_STORY_FUNCTION_IDS = new Set<string>([
'treasure_inspect',
'treasure_leave',
'treasure_secure',
]);
type TreasureStoryResolution = {
actionText: string;
resultText: string;
patches: RuntimeStoryPatch[];
toast?: string | null;
};
type JsonRecord = Record<string, unknown>;
type RuntimeGameState = Parameters<typeof resolveTreasureReward>[0];
type RuntimeEncounter = Parameters<typeof resolveTreasureReward>[1];
function resolveTreasureAction(functionId: string) {
switch (functionId) {
case 'treasure_secure':
return 'secure';
case 'treasure_inspect':
return 'inspect';
case 'treasure_leave':
return 'leave';
default:
throw invalidRequest(`暂不支持的 Treasure 动作:${functionId}`);
}
}
function getTreasureEncounter(
session: RuntimeSession,
state: RuntimeGameState,
): RuntimeEncounter | null {
const rawEncounter = state.currentEncounter;
if (!rawEncounter || rawEncounter.kind !== 'treasure') {
return null;
}
return {
npcAvatar: '',
hostile: false,
...rawEncounter,
id: rawEncounter.id ?? session.currentEncounter?.id ?? rawEncounter.npcName,
} satisfies RuntimeEncounter;
}
export function isSupportedTreasureStoryFunctionId(functionId: string) {
return SUPPORTED_TREASURE_STORY_FUNCTION_IDS.has(functionId);
}
export function resolveTreasureStoryAction(
session: RuntimeSession,
request: RuntimeStoryActionRequest,
): TreasureStoryResolution {
const state = session.rawGameState as unknown as RuntimeGameState;
const encounter = getTreasureEncounter(session, state);
if (!encounter) {
throw conflict('当前没有可结算的宝藏遭遇。');
}
const action = resolveTreasureAction(request.action.functionId);
const reward =
action === 'leave' ? null : resolveTreasureReward(state, encounter, action);
let nextState = {
...state,
currentEncounter: null,
npcInteractionActive: false,
sceneHostileNpcs: [],
playerX: 0,
playerFacing: 'right' as const,
animationState: state.animationState,
scrollWorld: false,
inBattle: false,
playerHp: reward
? Math.min(state.playerMaxHp, state.playerHp + reward.hp)
: state.playerHp,
playerMana: reward
? Math.min(state.playerMaxMana, state.playerMana + reward.mana)
: state.playerMana,
playerCurrency: reward
? state.playerCurrency + reward.currency
: state.playerCurrency,
playerInventory: reward
? addInventoryItems(state.playerInventory, reward.items)
: state.playerInventory,
currentBattleNpcId: null,
currentNpcBattleMode: null,
currentNpcBattleOutcome: null,
sparReturnEncounter: null,
sparPlayerHpBefore: null,
sparPlayerMaxHpBefore: null,
sparStoryHistoryBefore: null,
} satisfies RuntimeGameState;
if (reward) {
nextState = appendStoryEngineCarrierMemory(nextState, reward.items);
}
replaceRuntimeSessionRawGameState(
session,
nextState as unknown as JsonRecord,
);
return {
actionText:
action === 'leave'
? '先记下位置'
: action === 'inspect'
? '仔细检查'
: '直接收取',
resultText: buildTreasureResultText(
encounter,
action,
reward ?? undefined,
state.worldType,
),
patches: [],
toast: reward ? buildBuildToast(nextState) : null,
};
}