import type { RuntimeStoryEncounterViewModel, RuntimeStoryChoicePayload, 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'; import { resolvePlayerOutgoingDamageResult } from '../runtime/runtimeBuildModule.js'; import { isInventoryItemUsable, resolveInventoryItemUseEffect, } from '../runtime/runtimeInventoryEffectsModule.js'; type JsonRecord = Record; type StoryHistoryRole = 'action' | 'result'; type FunctionDefinition = { actionText: string; detailText: string; scope: Task5RuntimeOptionScope; }; export type RuntimeStoryHistoryEntry = { text: string; historyRole: StoryHistoryRole; }; export type RuntimeNpcState = { affinity: number; chattedCount: number; helpUsed: boolean; giftsGiven: number; inventory: unknown[]; recruited: boolean; firstMeaningfulContactResolved: boolean; relationState: JsonRecord | null; stanceProfile: JsonRecord | null; tradeStockSignature?: string | null; revealedFacts?: string[]; knownAttributeRumors?: string[]; seenBackstoryChapterIds?: string[]; }; export type RuntimeEncounter = { id: string; kind: 'npc' | 'treasure'; npcName: string; npcDescription: string; context: string; hostile: boolean; characterId: string | null; monsterPresetId: string | null; }; export type RuntimeHostileNpc = { id: string; name: string; hp: number; maxHp: number; description: string; }; export type RuntimeCompanion = { npcId: string; characterId: string; joinedAtAffinity: number; }; type RuntimePlayerAttributes = { strength: number; agility: number; intelligence: number; spirit: number; }; type RuntimePlayerSkill = { id: string; name: string; damage: number; manaCost: number; cooldownTurns: number; buildBuffs?: Array<{ id: string; sourceType: 'skill' | 'item' | 'forge'; sourceId: string; name: string; tags: string[]; durationTurns: number; maxStacks?: number; }>; }; type RuntimePlayerCharacter = { attributes: RuntimePlayerAttributes; skills: RuntimePlayerSkill[]; }; type RuntimeBattleItemUseProfile = { hpRestore?: number; manaRestore?: number; cooldownReduction?: number; buildBuffs?: Array<{ id: string; sourceType: 'item'; sourceId: string; name: string; tags: string[]; durationTurns: number; }>; }; type RuntimeBattleInventoryItem = { id: string; name: string; quantity: number; rarity: 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary'; tags: string[]; useProfile?: RuntimeBattleItemUseProfile; }; export type RuntimeSession = { sessionId: string; runtimeVersion: number; snapshotBottomTab: string; rawGameState: JsonRecord; worldType: string | null; storyHistory: RuntimeStoryHistoryEntry[]; currentEncounter: RuntimeEncounter | null; npcInteractionActive: boolean; sceneHostileNpcs: RuntimeHostileNpc[]; inBattle: boolean; playerHp: number; playerMaxHp: number; playerMana: number; playerMaxMana: number; npcStates: Record; companions: RuntimeCompanion[]; currentNpcBattleMode: 'fight' | 'spar' | null; currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null; }; export const MAX_TASK5_COMPANIONS = 2; const STORY_FUNCTION_IDS = new Set([ 'story_continue_adventure', 'story_opening_camp_dialogue', 'camp_travel_home_scene', 'idle_call_out', 'idle_explore_forward', 'idle_observe_signs', 'idle_rest_focus', 'idle_travel_next_scene', ]); const COMBAT_FUNCTION_IDS = new Set([ 'battle_attack_basic', 'battle_use_skill', 'battle_all_in_crush', 'battle_escape_breakout', 'battle_feint_step', 'battle_finisher_window', 'battle_guard_break', 'battle_probe_pressure', 'battle_recover_breath', ]); const NPC_FUNCTION_IDS = new Set([ 'npc_chat', 'npc_fight', 'npc_help', 'npc_leave', 'npc_preview_talk', 'npc_recruit', 'npc_spar', ]); const TASK6_RUNTIME_FUNCTION_ID_SET = new Set( TASK6_RUNTIME_FUNCTION_IDS, ); export const TASK6_DEFERRED_FUNCTION_IDS = new Set([ ]); const FUNCTION_DEFINITIONS: Record = { story_continue_adventure: { actionText: '继续推进冒险', detailText: '让后端基于当前快照继续推进当前故事状态。', scope: 'story', }, story_opening_camp_dialogue: { actionText: '交换开场判断', detailText: '把当前营地里的第一次正式对话切进服务端交互态。', scope: 'story', }, camp_travel_home_scene: { actionText: '返回营地', detailText: '结束当前遭遇,把流程带回安全的营地状态。', scope: 'story', }, idle_call_out: { actionText: '主动出声试探', detailText: '对前路喊话,逼迫附近的动静更快浮出水面。', scope: 'story', }, idle_explore_forward: { actionText: '继续向前探索', detailText: '继续沿当前路径深入,把新遭遇交给后端推进。', scope: 'story', }, idle_observe_signs: { actionText: '观察周围迹象', detailText: '先读环境,再决定下一轮要不要靠近或出手。', scope: 'story', }, idle_rest_focus: { actionText: '原地调息', detailText: '恢复少量生命与灵力,稳住下一轮节奏。', scope: 'story', }, idle_travel_next_scene: { actionText: '前往相邻场景', detailText: '收束当前遭遇并切往下一段场景流程。', scope: 'story', }, battle_attack_basic: { actionText: '普通攻击', detailText: '本回合执行一次不耗蓝的基础攻击。', scope: 'combat', }, battle_use_skill: { actionText: '释放技能', detailText: '直接执行一个具体技能,不再包装成抽象战术动作。', scope: 'combat', }, battle_all_in_crush: { actionText: '正面强压', detailText: '高消耗高压制,适合趁敌人还没稳住时硬顶上去。', scope: 'combat', }, battle_escape_breakout: { actionText: '强行脱离战斗', detailText: '打断当前战斗,把状态切回探索或脱身结果。', scope: 'combat', }, battle_feint_step: { actionText: '虚晃切步', detailText: '用更轻的代价制造伤害,同时压低敌方反击力度。', scope: 'combat', }, battle_finisher_window: { actionText: '抓破绽终结', detailText: '对残血目标有额外收益,适合收尾。', scope: 'combat', }, battle_guard_break: { actionText: '破架重击', detailText: '偏稳定的伤害动作,能打断对方的站稳节奏。', scope: 'combat', }, battle_probe_pressure: { actionText: '稳步试探', detailText: '低风险压迫,兼顾伤害和节奏控制。', scope: 'combat', }, battle_recover_breath: { actionText: '恢复', detailText: '直接恢复资源,并推进本回合冷却。', scope: 'combat', }, inventory_use: { actionText: '使用物品', detailText: '战斗中优先执行一个可立即结算的消耗品。', scope: 'combat', }, npc_chat: { actionText: '继续交谈', detailText: '围绕当前话题延续对话,推进好感与关系判断。', scope: 'npc', }, npc_fight: { actionText: '与对方战斗', detailText: '把当前 NPC 交互直接切进正式战斗结算。', scope: 'npc', }, npc_help: { actionText: '请求援手', detailText: '向当前 NPC 请求一次性支援,恢复部分状态。', scope: 'npc', }, npc_leave: { actionText: '离开当前角色', detailText: '结束当前 NPC 交互,重新回到探索态。', scope: 'npc', }, npc_preview_talk: { actionText: '转向眼前角色', detailText: '从遭遇预览切进正式 NPC 互动菜单。', scope: 'npc', }, npc_recruit: { actionText: '邀请加入队伍', detailText: '关系达标后可以直接把当前 NPC 收进同行队伍。', scope: 'npc', }, npc_spar: { actionText: '点到为止切磋', detailText: '用 spar 模式进入轻量战斗,结果会回流到关系状态。', scope: 'npc', }, npc_trade: { actionText: '交易', detailText: '查看库存并执行买入或卖出。', scope: 'npc', }, npc_gift: { actionText: '赠送礼物', detailText: '把背包里的物品正式交给当前角色。', scope: 'npc', }, npc_quest_accept: { actionText: '接下委托', detailText: '把当前角色的委托正式收进任务日志。', scope: 'npc', }, npc_quest_turn_in: { actionText: '交付委托', detailText: '向当前角色结算已经完成的委托奖励。', scope: 'npc', }, treasure_secure: { actionText: '直接收取', detailText: '不再拖延,直接把眼前最关键的收获带走。', scope: 'story', }, treasure_inspect: { actionText: '仔细检查', detailText: '多花些时间拆开机关、痕迹和伪装。', scope: 'story', }, treasure_leave: { actionText: '先记下位置', detailText: '暂时不碰它,只把异常位置和痕迹记住。', scope: 'story', }, }; function cloneJson(value: T): T { return JSON.parse(JSON.stringify(value)) as T; } function isObject(value: unknown): value is JsonRecord { return typeof value === 'object' && value !== null && !Array.isArray(value); } function readString(value: unknown, fallback = '') { return typeof value === 'string' && value.trim() ? value.trim() : fallback; } function readNumber(value: unknown, fallback = 0) { return typeof value === 'number' && Number.isFinite(value) ? value : fallback; } function readBoolean(value: unknown, fallback = false) { return typeof value === 'boolean' ? value : fallback; } function readArray(value: unknown) { return Array.isArray(value) ? value : []; } function normalizeStoryHistory(value: unknown) { return readArray(value) .map((entry) => { const rawEntry = isObject(entry) ? entry : {}; const historyRole = rawEntry.historyRole === 'action' ? 'action' : 'result'; return { text: readString(rawEntry.text), historyRole, } satisfies RuntimeStoryHistoryEntry; }) .filter((entry) => entry.text); } function normalizeNpcState(value: unknown): RuntimeNpcState { const rawState = isObject(value) ? value : {}; return { affinity: Math.round(readNumber(rawState.affinity, 0)), chattedCount: Math.max(0, Math.round(readNumber(rawState.chattedCount, 0))), helpUsed: readBoolean(rawState.helpUsed), giftsGiven: Math.max(0, Math.round(readNumber(rawState.giftsGiven, 0))), inventory: cloneJson(readArray(rawState.inventory)), recruited: readBoolean(rawState.recruited), firstMeaningfulContactResolved: readBoolean( rawState.firstMeaningfulContactResolved, ), tradeStockSignature: readString(rawState.tradeStockSignature) || null, relationState: isObject(rawState.relationState) ? cloneJson(rawState.relationState) : null, stanceProfile: isObject(rawState.stanceProfile) ? cloneJson(rawState.stanceProfile) : null, revealedFacts: readArray(rawState.revealedFacts).filter( (item): item is string => typeof item === 'string' && item.trim().length > 0, ), knownAttributeRumors: readArray(rawState.knownAttributeRumors).filter( (item): item is string => typeof item === 'string' && item.trim().length > 0, ), seenBackstoryChapterIds: readArray(rawState.seenBackstoryChapterIds).filter( (item): item is string => typeof item === 'string' && item.trim().length > 0, ), }; } function normalizeEncounter(value: unknown): RuntimeEncounter | null { const rawEncounter = isObject(value) ? value : null; if (!rawEncounter) { return null; } const kind = rawEncounter.kind === 'treasure' ? 'treasure' : 'npc'; const npcName = readString(rawEncounter.npcName); if (!npcName) { return null; } return { id: readString(rawEncounter.id, npcName), kind, npcName, npcDescription: readString(rawEncounter.npcDescription), context: readString(rawEncounter.context), hostile: readBoolean(rawEncounter.hostile) || Boolean(readString(rawEncounter.monsterPresetId)), characterId: readString(rawEncounter.characterId) || null, monsterPresetId: readString(rawEncounter.monsterPresetId) || null, }; } function normalizeHostileNpc(value: unknown): RuntimeHostileNpc | null { const rawNpc = isObject(value) ? value : null; if (!rawNpc) { return null; } const id = readString(rawNpc.id); const name = readString(rawNpc.name, id); if (!id || !name) { return null; } const maxHp = Math.max(1, Math.round(readNumber(rawNpc.maxHp, 1))); const hp = Math.max(0, Math.min(maxHp, Math.round(readNumber(rawNpc.hp, maxHp)))); return { id, name, hp, maxHp, description: readString(rawNpc.description), }; } function normalizeCompanion(value: unknown): RuntimeCompanion | null { const rawCompanion = isObject(value) ? value : null; if (!rawCompanion) { return null; } const npcId = readString(rawCompanion.npcId); if (!npcId) { return null; } return { npcId, characterId: readString(rawCompanion.characterId), joinedAtAffinity: Math.round(readNumber(rawCompanion.joinedAtAffinity, 0)), }; } function normalizeNpcStates(value: unknown) { const rawStates = isObject(value) ? value : {}; return Object.fromEntries( Object.entries(rawStates).map(([key, state]) => [key, normalizeNpcState(state)]), ) as Record; } function normalizeCompanions(value: unknown) { return readArray(value) .map((entry) => normalizeCompanion(entry)) .filter((entry): entry is RuntimeCompanion => Boolean(entry)); } function normalizeHostileNpcs(value: unknown) { return readArray(value) .map((entry) => normalizeHostileNpc(entry)) .filter((entry): entry is RuntimeHostileNpc => Boolean(entry)); } function normalizePlayerSkill(value: unknown): RuntimePlayerSkill | null { const rawSkill = isObject(value) ? value : null; if (!rawSkill) { return null; } const id = readString(rawSkill.id); const name = readString(rawSkill.name, id); if (!id || !name) { return null; } return { id, name, damage: Math.max(1, Math.round(readNumber(rawSkill.damage, 1))), manaCost: Math.max(0, Math.round(readNumber(rawSkill.manaCost, 0))), cooldownTurns: Math.max( 0, Math.round(readNumber(rawSkill.cooldownTurns, 0)), ), buildBuffs: readArray(rawSkill.buildBuffs) .map((entry) => { const rawBuff = isObject(entry) ? entry : null; if (!rawBuff) { return null; } const buffId = readString(rawBuff.id); const sourceId = readString(rawBuff.sourceId); const name = readString(rawBuff.name, buffId); if (!buffId || !sourceId || !name) { return null; } const sourceType = readString(rawBuff.sourceType, 'skill'); return { id: buffId, sourceType: sourceType === 'item' || sourceType === 'forge' ? sourceType : 'skill', sourceId, name, tags: readArray(rawBuff.tags).filter( (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, ), durationTurns: Math.max( 1, Math.round(readNumber(rawBuff.durationTurns, 1)), ), maxStacks: typeof rawBuff.maxStacks === 'number' && Number.isFinite(rawBuff.maxStacks) ? Math.max(1, Math.round(rawBuff.maxStacks)) : undefined, } satisfies NonNullable[number]; }) .filter( ( entry, ): entry is NonNullable[number] => Boolean(entry), ), }; } function normalizePlayerCharacter( value: unknown, ): RuntimePlayerCharacter | null { const rawCharacter = isObject(value) ? value : null; const rawAttributes = isObject(rawCharacter?.attributes) ? rawCharacter.attributes : null; if (!rawCharacter || !rawAttributes) { return null; } return { attributes: { strength: Math.max(0, Math.round(readNumber(rawAttributes.strength, 0))), agility: Math.max(0, Math.round(readNumber(rawAttributes.agility, 0))), intelligence: Math.max( 0, Math.round(readNumber(rawAttributes.intelligence, 0)), ), spirit: Math.max(0, Math.round(readNumber(rawAttributes.spirit, 0))), }, skills: readArray(rawCharacter.skills) .map((entry) => normalizePlayerSkill(entry)) .filter((entry): entry is RuntimePlayerSkill => Boolean(entry)), }; } function normalizeBattleInventoryItem( value: unknown, ): RuntimeBattleInventoryItem | null { const rawItem = isObject(value) ? value : null; if (!rawItem) { return null; } const id = readString(rawItem.id); const name = readString(rawItem.name, id); if (!id || !name) { return null; } const rarity = readString(rawItem.rarity, 'common'); const normalizedRarity = rarity === 'legendary' || rarity === 'epic' || rarity === 'rare' || rarity === 'uncommon' ? rarity : 'common'; const useProfile = isObject(rawItem.useProfile) ? (cloneJson(rawItem.useProfile) as RuntimeBattleItemUseProfile) : undefined; return { id, name, quantity: Math.max(0, Math.round(readNumber(rawItem.quantity, 0))), rarity: normalizedRarity, tags: readArray(rawItem.tags).filter( (tag): tag is string => typeof tag === 'string' && tag.trim().length > 0, ), useProfile, }; } export function getPlayerCharacter(session: RuntimeSession) { return normalizePlayerCharacter(session.rawGameState.playerCharacter); } export function getPlayerSkillCooldowns(session: RuntimeSession) { const rawCooldowns = isObject(session.rawGameState.playerSkillCooldowns) ? session.rawGameState.playerSkillCooldowns : {}; return Object.fromEntries( Object.entries(rawCooldowns).map(([skillId, turns]) => [ skillId, Math.max(0, Math.round(readNumber(turns, 0))), ]), ) as Record; } function getBattleInventoryItems(session: RuntimeSession) { return readArray(session.rawGameState.playerInventory) .map((entry) => normalizeBattleInventoryItem(entry)) .filter((entry): entry is RuntimeBattleInventoryItem => Boolean(entry)); } function buildBasicAttackBaseDamage(character: RuntimePlayerCharacter) { return Math.max( 8, Math.round( character.attributes.strength * 0.85 + character.attributes.agility * 0.45, ), ); } function buildBattleDisabledOption(params: { functionId: string; actionText?: string; detailText?: string; reason: string; payload?: RuntimeStoryChoicePayload; }) { return buildOptionView(params.functionId, { actionText: params.actionText, detailText: params.detailText, payload: params.payload, disabled: true, reason: params.reason, }); } function buildBattleItemSummary( effect: NonNullable>, ) { const parts = [ effect.hpRestore > 0 ? `回血 ${effect.hpRestore}` : null, effect.manaRestore > 0 ? `回蓝 ${effect.manaRestore}` : null, effect.cooldownReduction > 0 ? `冷却 -${effect.cooldownReduction}` : null, effect.buildBuffs.length > 0 ? `增益 ${effect.buildBuffs.map((buff) => buff.name).join('、')}` : null, ].filter(Boolean); return parts.join(' / ') || '立即结算一次物品效果'; } function pickPreferredBattleItem(session: RuntimeSession) { const character = getPlayerCharacter(session); if (!character) { return null; } const cooldowns = getPlayerSkillCooldowns(session); const hasCoolingSkill = Object.values(cooldowns).some((turns) => turns > 0); const playerHpRatio = session.playerHp / Math.max(session.playerMaxHp, 1); const playerManaRatio = session.playerMana / Math.max(session.playerMaxMana, 1); return getBattleInventoryItems(session) .filter((item) => item.quantity > 0 && isInventoryItemUsable(item)) .map((item) => { const effect = resolveInventoryItemUseEffect(item, character); if (!effect) { return null; } const score = effect.hpRestore * (playerHpRatio <= 0.45 ? 2.6 : 1.2) + effect.manaRestore * (playerManaRatio <= 0.45 ? 2.2 : 0.9) + effect.cooldownReduction * (hasCoolingSkill ? 18 : 6) + effect.buildBuffs.length * 8; return { item, effect, score, }; }) .filter( ( candidate, ): candidate is { item: RuntimeBattleInventoryItem; effect: NonNullable>; score: number; } => Boolean(candidate), ) .sort( (left, right) => right.score - left.score || right.effect.hpRestore - left.effect.hpRestore || right.effect.manaRestore - left.effect.manaRestore || left.item.name.localeCompare(right.item.name, 'zh-CN'), )[0] ?? null; } function buildBattleSkillOptions(session: RuntimeSession) { const character = getPlayerCharacter(session); if (!character) { return []; } const cooldowns = getPlayerSkillCooldowns(session); return character.skills.map((skill) => { const remainingCooldown = cooldowns[skill.id] ?? 0; const damage = resolvePlayerOutgoingDamageResult( session.rawGameState as Parameters[0], character, skill.damage, 1, `runtime-skill-preview:${skill.id}`, ).damage; const detailText = [ `耗蓝 ${skill.manaCost}`, `伤害 ${damage}`, `冷却 ${skill.cooldownTurns}`, ].join(' / '); if (remainingCooldown > 0) { return buildBattleDisabledOption({ functionId: 'battle_use_skill', actionText: skill.name, detailText, payload: { skillId: skill.id }, reason: `冷却中,还需 ${remainingCooldown} 回合`, }); } if (skill.manaCost > session.playerMana) { return buildBattleDisabledOption({ functionId: 'battle_use_skill', actionText: skill.name, detailText, payload: { skillId: skill.id }, reason: '灵力不足', }); } return buildOptionView('battle_use_skill', { actionText: skill.name, detailText, payload: { skillId: skill.id }, }); }); } function buildBattleActionOptions(session: RuntimeSession) { const character = getPlayerCharacter(session); const itemCandidate = pickPreferredBattleItem(session); const basicAttackDamage = character ? resolvePlayerOutgoingDamageResult( session.rawGameState as Parameters[0], character, buildBasicAttackBaseDamage(character), 1, 'runtime-basic-attack-preview', ).damage : 0; return [ buildOptionView('battle_attack_basic', { detailText: basicAttackDamage > 0 ? `不耗蓝 / 伤害 ${basicAttackDamage}` : '不耗蓝的基础攻击', }), buildOptionView('battle_recover_breath', { actionText: '恢复', detailText: '回血 12 / 回蓝 9 / 冷却 -1', }), itemCandidate ? buildOptionView('inventory_use', { actionText: `使用物品:${itemCandidate.item.name}`, detailText: buildBattleItemSummary(itemCandidate.effect), payload: { itemId: itemCandidate.item.id }, }) : buildBattleDisabledOption({ functionId: 'inventory_use', actionText: '使用物品', detailText: '当前没有可直接结算的战斗消耗品', reason: '暂无可用物品', }), ...buildBattleSkillOptions(session), buildOptionView('battle_escape_breakout'), ] satisfies RuntimeStoryOptionView[]; } export function getEncounterKey(encounter: RuntimeEncounter) { return encounter.id || encounter.npcName; } export function loadRuntimeSession( snapshot: SavedSnapshot, requestedSessionId: string, ): RuntimeSession { const rawGameState = isObject(snapshot.gameState) ? cloneJson(snapshot.gameState) : {}; const currentEncounter = normalizeEncounter(rawGameState.currentEncounter); const sceneHostileNpcs = normalizeHostileNpcs(rawGameState.sceneHostileNpcs); const inBattle = readBoolean(rawGameState.inBattle) && sceneHostileNpcs.some((npc) => npc.hp > 0); return { sessionId: readString(rawGameState.runtimeSessionId, requestedSessionId), runtimeVersion: Math.max( 0, Math.round(readNumber(rawGameState.runtimeActionVersion, 0)), ), snapshotBottomTab: readString(snapshot.bottomTab, 'adventure'), rawGameState, worldType: readString(rawGameState.worldType) || null, storyHistory: normalizeStoryHistory(rawGameState.storyHistory), currentEncounter, npcInteractionActive: readBoolean(rawGameState.npcInteractionActive), sceneHostileNpcs, inBattle, playerHp: Math.max(0, Math.round(readNumber(rawGameState.playerHp, 0))), playerMaxHp: Math.max(1, Math.round(readNumber(rawGameState.playerMaxHp, 1))), playerMana: Math.max(0, Math.round(readNumber(rawGameState.playerMana, 0))), playerMaxMana: Math.max( 1, Math.round(readNumber(rawGameState.playerMaxMana, 1)), ), npcStates: normalizeNpcStates(rawGameState.npcStates), companions: normalizeCompanions(rawGameState.companions), currentNpcBattleMode: rawGameState.currentNpcBattleMode === 'fight' || rawGameState.currentNpcBattleMode === 'spar' ? rawGameState.currentNpcBattleMode : null, currentNpcBattleOutcome: rawGameState.currentNpcBattleOutcome === 'fight_victory' || rawGameState.currentNpcBattleOutcome === 'spar_complete' ? rawGameState.currentNpcBattleOutcome : null, }; } export function isStoryFunctionId(functionId: string) { return STORY_FUNCTION_IDS.has(functionId); } export function isCombatFunctionId(functionId: string) { return COMBAT_FUNCTION_IDS.has(functionId); } export function isNpcFunctionId(functionId: string) { return NPC_FUNCTION_IDS.has(functionId); } export function isTask5FunctionId(functionId: string) { return ( isStoryFunctionId(functionId) || isCombatFunctionId(functionId) || isNpcFunctionId(functionId) ); } export function isTask6RuntimeFunctionId(functionId: string) { return TASK6_RUNTIME_FUNCTION_ID_SET.has(functionId); } export function getEncounterNpcState(session: RuntimeSession) { if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { return null; } const key = getEncounterKey(session.currentEncounter); return ( session.npcStates[key] ?? { affinity: 0, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, firstMeaningfulContactResolved: false, relationState: null, stanceProfile: null, } ); } export function setEncounterNpcState( session: RuntimeSession, npcState: RuntimeNpcState, ) { if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { return; } session.npcStates[getEncounterKey(session.currentEncounter)] = npcState; } function buildOptionView( functionId: string, overrides: Partial = {}, ): RuntimeStoryOptionView { const definition = FUNCTION_DEFINITIONS[functionId]; if (!definition) { return { functionId, actionText: functionId, detailText: '', scope: 'story', ...overrides, }; } return { functionId, actionText: definition.actionText, detailText: definition.detailText, scope: definition.scope, ...overrides, }; } type RuntimeQuestPreview = { id: string; issuerNpcId: string; status: string; }; function readQuestPreviews(session: RuntimeSession): RuntimeQuestPreview[] { return readArray(session.rawGameState.quests) .map((quest) => { const rawQuest = isObject(quest) ? quest : {}; const id = readString(rawQuest.id); const issuerNpcId = readString(rawQuest.issuerNpcId); const status = readString(rawQuest.status); if (!id || !issuerNpcId || !status) { return null; } return { id, issuerNpcId, status, } satisfies RuntimeQuestPreview; }) .filter((quest): quest is RuntimeQuestPreview => Boolean(quest)); } function getActiveEncounterQuest(session: RuntimeSession) { if (!session.currentEncounter || session.currentEncounter.kind !== 'npc') { return null; } return ( readQuestPreviews(session).find( (quest) => quest.issuerNpcId === session.currentEncounter?.id && quest.status !== 'turned_in', ) ?? null ); } function hasGiftablePlayerInventory(session: RuntimeSession) { return readArray(session.rawGameState.playerInventory).some((item) => { const rawItem = isObject(item) ? item : {}; return readNumber(rawItem.quantity, 0) > 0; }); } export function buildAvailableOptions(session: RuntimeSession) { if (session.inBattle) { return buildBattleActionOptions(session); } if (session.currentEncounter?.kind === 'npc') { const npcState = getEncounterNpcState(session); if (session.currentEncounter.hostile) { return [ buildOptionView('npc_fight'), buildOptionView('npc_leave'), ]; } if (!session.npcInteractionActive) { return [ buildOptionView('npc_preview_talk'), buildOptionView('npc_fight'), buildOptionView('npc_leave'), ]; } const activeQuest = getActiveEncounterQuest(session); const options = [ buildOptionView('npc_chat'), buildOptionView('npc_help', npcState?.helpUsed ? { disabled: true, reason: '当前 NPC 的一次性援手已经用完了。', } : {}), buildOptionView('npc_spar'), buildOptionView('npc_fight'), ]; if ((npcState?.inventory?.length ?? 0) > 0) { options.push(buildOptionView('npc_trade')); } if (hasGiftablePlayerInventory(session)) { options.push(buildOptionView('npc_gift')); } if ( activeQuest && (activeQuest.status === 'completed' || activeQuest.status === 'ready_to_turn_in') ) { options.push(buildOptionView('npc_quest_turn_in')); } else if (!activeQuest) { options.push(buildOptionView('npc_quest_accept')); } if (npcState && !npcState.recruited && npcState.affinity >= 60) { options.push( buildOptionView( 'npc_recruit', session.companions.length >= MAX_TASK5_COMPANIONS ? { disabled: true, reason: '队伍已满,任务5首轮后端接口暂不处理换队逻辑。', } : {}, ), ); } options.push(buildOptionView('npc_leave')); return options; } if (session.currentEncounter?.kind === 'treasure') { return [ buildOptionView('treasure_secure'), buildOptionView('treasure_inspect'), buildOptionView('treasure_leave'), ]; } return [ 'idle_observe_signs', 'idle_call_out', 'idle_rest_focus', 'idle_explore_forward', 'idle_travel_next_scene', 'story_continue_adventure', ].map((functionId) => buildOptionView(functionId)); } function buildEncounterViewModel( session: RuntimeSession, ): RuntimeStoryEncounterViewModel | null { if (!session.currentEncounter) { return null; } const npcState = getEncounterNpcState(session); return { id: session.currentEncounter.id, kind: session.currentEncounter.kind, npcName: session.currentEncounter.npcName, hostile: session.currentEncounter.hostile, affinity: npcState?.affinity, recruited: npcState?.recruited, interactionActive: session.npcInteractionActive, battleMode: session.currentNpcBattleMode, }; } export function buildRuntimeViewModel( session: RuntimeSession, options = buildAvailableOptions(session), ): RuntimeStoryViewModel { return { player: { hp: session.playerHp, maxHp: session.playerMaxHp, mana: session.playerMana, maxMana: session.playerMaxMana, }, encounter: buildEncounterViewModel(session), companions: session.companions.map((companion) => ({ npcId: companion.npcId, characterId: companion.characterId || undefined, joinedAtAffinity: companion.joinedAtAffinity, })), availableOptions: options, status: { inBattle: session.inBattle, npcInteractionActive: session.npcInteractionActive, currentNpcBattleMode: session.currentNpcBattleMode, currentNpcBattleOutcome: session.currentNpcBattleOutcome, }, }; } export function appendStoryHistory( session: RuntimeSession, actionText: string, resultText: string, ) { session.storyHistory.push( { text: actionText, historyRole: 'action', }, { text: resultText, historyRole: 'result', }, ); } export function buildLegacyCurrentStory( storyText: string, options: RuntimeStoryOptionView[], ) { return { text: storyText, options: options.map((option) => ({ functionId: option.functionId, actionText: option.actionText, text: option.actionText, detailText: option.detailText, priority: option.scope === 'npc' ? 3 : option.scope === 'combat' ? 2 : 1, runtimePayload: option.payload, disabled: option.disabled, disabledReason: option.reason, visuals: { playerAnimation: 'idle', playerMoveMeters: 0, playerOffsetY: 0, playerFacing: 'right', scrollWorld: false, monsterChanges: [], }, })), }; } export function syncRawGameState(session: RuntimeSession) { session.rawGameState.runtimeSessionId = session.sessionId; session.rawGameState.runtimeActionVersion = session.runtimeVersion; session.rawGameState.storyHistory = cloneJson(session.storyHistory); session.rawGameState.currentEncounter = session.currentEncounter ? cloneJson(session.currentEncounter) : null; session.rawGameState.npcInteractionActive = session.npcInteractionActive; session.rawGameState.sceneHostileNpcs = cloneJson(session.sceneHostileNpcs); session.rawGameState.inBattle = session.inBattle; session.rawGameState.playerHp = session.playerHp; session.rawGameState.playerMaxHp = session.playerMaxHp; session.rawGameState.playerMana = session.playerMana; session.rawGameState.playerMaxMana = session.playerMaxMana; session.rawGameState.npcStates = cloneJson(session.npcStates); session.rawGameState.companions = cloneJson(session.companions); session.rawGameState.currentNpcBattleMode = session.currentNpcBattleMode; session.rawGameState.currentNpcBattleOutcome = session.currentNpcBattleOutcome; session.rawGameState.currentBattleNpcId = session.currentEncounter?.id ?? null; session.rawGameState.activeCombatEffects = []; session.rawGameState.playerActionMode = 'idle'; session.rawGameState.scrollWorld = false; session.rawGameState.animationState = 'idle'; } export function replaceRuntimeSessionRawGameState( session: RuntimeSession, nextGameState: JsonRecord, ) { session.rawGameState = cloneJson(nextGameState); const refreshed = loadRuntimeSession( { version: 2, savedAt: '', bottomTab: session.snapshotBottomTab, gameState: session.rawGameState, currentStory: null, }, session.sessionId, ); session.worldType = refreshed.worldType; session.storyHistory = refreshed.storyHistory; session.currentEncounter = refreshed.currentEncounter; session.npcInteractionActive = refreshed.npcInteractionActive; session.sceneHostileNpcs = refreshed.sceneHostileNpcs; session.inBattle = refreshed.inBattle; session.playerHp = refreshed.playerHp; session.playerMaxHp = refreshed.playerMaxHp; session.playerMana = refreshed.playerMana; session.playerMaxMana = refreshed.playerMaxMana; session.npcStates = refreshed.npcStates; session.companions = refreshed.companions; session.currentNpcBattleMode = refreshed.currentNpcBattleMode; session.currentNpcBattleOutcome = refreshed.currentNpcBattleOutcome; }