1336 lines
38 KiB
TypeScript
1336 lines
38 KiB
TypeScript
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;
|
||
}
|