1
This commit is contained in:
2
server-node/src/modules/runtime-item/index.ts
Normal file
2
server-node/src/modules/runtime-item/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './runtimeItemResolutionService.js';
|
||||
export { generateRuntimeItemIntents } from '../../services/runtimeItemService.js';
|
||||
784
server-node/src/modules/runtime-item/runtimeItemModule.ts
Normal file
784
server-node/src/modules/runtime-item/runtimeItemModule.ts
Normal 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),
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user