Files
Genarrative/server-node/src/modules/story/runtimeSession.ts
高物 8a7bd90458
Some checks failed
CI / verify (push) Has been cancelled
1
2026-04-20 11:30:19 +08:00

1336 lines
38 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type {
RuntimeStoryChoicePayload,
RuntimeStoryEncounterViewModel,
RuntimeStoryOptionInteraction,
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<string, unknown>;
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<string, RuntimeNpcState>;
companions: RuntimeCompanion[];
currentNpcBattleMode: 'fight' | 'spar' | null;
currentNpcBattleOutcome: 'fight_victory' | 'spar_complete' | null;
};
export const MAX_TASK5_COMPANIONS = 2;
const STORY_FUNCTION_IDS = new Set<string>([
'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<string>([
'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',
'inventory_use',
]);
const NPC_FUNCTION_IDS = new Set<string>([
'npc_chat',
'npc_fight',
'npc_help',
'npc_leave',
'npc_preview_talk',
'npc_recruit',
'npc_spar',
]);
const TASK6_RUNTIME_FUNCTION_ID_SET = new Set<string>(
TASK6_RUNTIME_FUNCTION_IDS,
);
export const TASK6_DEFERRED_FUNCTION_IDS = new Set<string>([]);
const FUNCTION_DEFINITIONS: Record<string, FunctionDefinition> = {
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<T>(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<string, RuntimeNpcState>;
}
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<RuntimePlayerSkill['buildBuffs']>[number];
})
.filter(
(
entry,
): entry is NonNullable<RuntimePlayerSkill['buildBuffs']>[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<string, number>;
}
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: {
session: RuntimeSession;
functionId: string;
actionText?: string;
detailText?: string;
reason: string;
payload?: RuntimeStoryChoicePayload;
}) {
return buildOptionView(params.session, params.functionId, {
actionText: params.actionText,
detailText: params.detailText,
payload: params.payload,
disabled: true,
reason: params.reason,
});
}
function buildOptionInteraction(
session: RuntimeSession,
functionId: string,
): RuntimeStoryOptionInteraction | undefined {
const encounter = session.currentEncounter;
if (encounter?.kind === 'npc') {
const npcId = getEncounterKey(encounter);
const npcActionMap: Record<string, RuntimeStoryOptionInteraction> = {
npc_chat: { kind: 'npc', npcId, action: 'chat' },
npc_fight: { kind: 'npc', npcId, action: 'fight' },
npc_help: { kind: 'npc', npcId, action: 'help' },
npc_leave: { kind: 'npc', npcId, action: 'leave' },
npc_preview_talk: { kind: 'npc', npcId, action: 'chat' },
npc_recruit: { kind: 'npc', npcId, action: 'recruit' },
npc_spar: { kind: 'npc', npcId, action: 'spar' },
npc_trade: { kind: 'npc', npcId, action: 'trade' },
npc_gift: { kind: 'npc', npcId, action: 'gift' },
npc_quest_accept: { kind: 'npc', npcId, action: 'quest_accept' },
npc_quest_turn_in: { kind: 'npc', npcId, action: 'quest_turn_in' },
};
return npcActionMap[functionId];
}
if (encounter?.kind === 'treasure') {
const treasureActionMap: Record<string, RuntimeStoryOptionInteraction> = {
treasure_secure: { kind: 'treasure', action: 'secure' },
treasure_inspect: { kind: 'treasure', action: 'inspect' },
treasure_leave: { kind: 'treasure', action: 'leave' },
};
return treasureActionMap[functionId];
}
return undefined;
}
function buildBattleItemSummary(
effect: NonNullable<ReturnType<typeof resolveInventoryItemUseEffect>>,
) {
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<ReturnType<typeof resolveInventoryItemUseEffect>>;
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<
typeof resolvePlayerOutgoingDamageResult
>[0],
character,
skill.damage,
1,
`runtime-skill-preview:${skill.id}`,
).damage;
const detailText = [
`耗蓝 ${skill.manaCost}`,
`伤害 ${damage}`,
`冷却 ${skill.cooldownTurns}`,
].join(' / ');
if (remainingCooldown > 0) {
return buildBattleDisabledOption({
session,
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: `冷却中,还需 ${remainingCooldown} 回合`,
});
}
if (skill.manaCost > session.playerMana) {
return buildBattleDisabledOption({
session,
functionId: 'battle_use_skill',
actionText: skill.name,
detailText,
payload: { skillId: skill.id },
reason: '灵力不足',
});
}
return buildOptionView(session, '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<
typeof resolvePlayerOutgoingDamageResult
>[0],
character,
buildBasicAttackBaseDamage(character),
1,
'runtime-basic-attack-preview',
).damage
: 0;
return [
buildOptionView(session, 'battle_attack_basic', {
detailText:
basicAttackDamage > 0
? `不耗蓝 / 伤害 ${basicAttackDamage}`
: '不耗蓝的基础攻击',
}),
buildOptionView(session, 'battle_recover_breath', {
actionText: '恢复',
detailText: '回血 12 / 回蓝 9 / 冷却 -1',
}),
itemCandidate
? buildOptionView(session, 'inventory_use', {
actionText: `使用物品:${itemCandidate.item.name}`,
detailText: buildBattleItemSummary(itemCandidate.effect),
payload: { itemId: itemCandidate.item.id },
})
: buildBattleDisabledOption({
session,
functionId: 'inventory_use',
actionText: '使用物品',
detailText: '当前没有可直接结算的战斗消耗品',
reason: '暂无可用物品',
}),
...buildBattleSkillOptions(session),
buildOptionView(session, '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(
session: RuntimeSession,
functionId: string,
overrides: Partial<RuntimeStoryOptionView> = {},
): RuntimeStoryOptionView {
const definition = FUNCTION_DEFINITIONS[functionId];
if (!definition) {
return {
functionId,
actionText: functionId,
detailText: '',
scope: 'story',
interaction: buildOptionInteraction(session, functionId),
...overrides,
};
}
return {
functionId,
actionText: definition.actionText,
detailText: definition.detailText,
scope: definition.scope,
interaction: buildOptionInteraction(session, functionId),
...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(session, 'npc_fight'),
buildOptionView(session, 'npc_leave'),
];
}
if (!session.npcInteractionActive) {
return [
buildOptionView(session, 'npc_preview_talk'),
buildOptionView(session, 'npc_fight'),
buildOptionView(session, 'npc_leave'),
];
}
const activeQuest = getActiveEncounterQuest(session);
const options = [
buildOptionView(session, 'npc_chat'),
buildOptionView(
session,
'npc_help',
npcState?.helpUsed
? {
disabled: true,
reason: '当前 NPC 的一次性援手已经用完了。',
}
: {},
),
buildOptionView(session, 'npc_spar'),
buildOptionView(session, 'npc_fight'),
];
if ((npcState?.inventory?.length ?? 0) > 0) {
options.push(buildOptionView(session, 'npc_trade'));
}
if (hasGiftablePlayerInventory(session)) {
options.push(buildOptionView(session, 'npc_gift'));
}
if (
activeQuest &&
(activeQuest.status === 'completed' ||
activeQuest.status === 'ready_to_turn_in')
) {
options.push(buildOptionView(session, 'npc_quest_turn_in'));
} else if (!activeQuest) {
options.push(buildOptionView(session, 'npc_quest_accept'));
}
if (npcState && !npcState.recruited && npcState.affinity >= 60) {
options.push(
buildOptionView(
session,
'npc_recruit',
session.companions.length >= MAX_TASK5_COMPANIONS
? {
disabled: true,
reason: '队伍已满任务5首轮后端接口暂不处理换队逻辑。',
}
: {},
),
);
}
options.push(buildOptionView(session, 'npc_leave'));
return options;
}
if (session.currentEncounter?.kind === 'treasure') {
return [
buildOptionView(session, 'treasure_secure'),
buildOptionView(session, 'treasure_inspect'),
buildOptionView(session, '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(session, 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,
interaction: option.interaction,
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;
}