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

View File

@@ -0,0 +1,854 @@
import type {
RuntimeStoryEncounterViewModel,
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';
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;
};
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_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<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_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',
},
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));
}
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> = {},
): 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 [
'battle_probe_pressure',
'battle_guard_break',
'battle_feint_step',
'battle_finisher_window',
'battle_all_in_crush',
'battle_recover_breath',
'battle_escape_breakout',
].map((functionId) => buildOptionView(functionId));
}
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,
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;
}