644 lines
19 KiB
TypeScript
644 lines
19 KiB
TypeScript
import { jsonClone } from '../../http.js';
|
|
import type { SavedSnapshot } from '../../repositories/runtimeRepository.js';
|
|
import { normalizeQuestEntries } from '../quest/questProgressionService.js';
|
|
import {
|
|
createEmptyEquipmentLoadout,
|
|
getEquipmentBonuses,
|
|
getEquipmentSlotLabel,
|
|
} from './runtimeEquipmentModule.js';
|
|
import { normalizeNpcPersistentState } from './runtimeNpcStatePrimitives.js';
|
|
|
|
type JsonRecord = Record<string, unknown>;
|
|
type SnapshotShape = {
|
|
savedAt: string;
|
|
bottomTab: unknown;
|
|
gameState: unknown;
|
|
currentStory: unknown;
|
|
};
|
|
|
|
const STORY_ENGINE_MIGRATION_VERSION = 'story-engine-v5';
|
|
const STORY_ENGINE_REQUIRED_TRANSFORMS = [
|
|
'ensure_story_engine_memory',
|
|
'ensure_campaign_state',
|
|
'ensure_player_style_profile',
|
|
];
|
|
const UNIVERSAL_MAX_MANA = 999;
|
|
const EQUIPMENT_SLOTS = ['weapon', 'armor', 'relic'] as const;
|
|
|
|
type RuntimeEquipmentSlotId = (typeof EQUIPMENT_SLOTS)[number];
|
|
type LegacyCharacterEquipmentItem = {
|
|
slot: string;
|
|
item: string;
|
|
rarity?: string;
|
|
};
|
|
|
|
function isRecord(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 clampNonNegativeInteger(value: unknown) {
|
|
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
|
return 0;
|
|
}
|
|
|
|
return Math.max(0, Math.floor(value));
|
|
}
|
|
|
|
function normalizeBottomTab(value: unknown) {
|
|
return value === 'character' || value === 'inventory'
|
|
? value
|
|
: 'adventure';
|
|
}
|
|
|
|
function buildSaveMigrationManifest() {
|
|
return {
|
|
version: 'story-engine-v5',
|
|
requiredTransforms: [
|
|
'ensure_story_engine_memory',
|
|
'ensure_campaign_state',
|
|
'ensure_player_style_profile',
|
|
],
|
|
backwardCompatible: true,
|
|
};
|
|
}
|
|
|
|
function createEmptyStoryEngineMemoryState() {
|
|
return {
|
|
discoveredFactIds: [],
|
|
inferredFactIds: [],
|
|
activeThreadIds: [],
|
|
resolvedScarIds: [],
|
|
recentCarrierIds: [],
|
|
openedSceneChapterIds: [],
|
|
recentSignalIds: [],
|
|
recentCompanionReactions: [],
|
|
currentChapter: null,
|
|
currentJourneyBeatId: null,
|
|
currentJourneyBeat: null,
|
|
companionArcStates: [],
|
|
worldMutations: [],
|
|
chronicle: [],
|
|
factionTensionStates: [],
|
|
currentCampEvent: null,
|
|
currentSetpieceDirective: null,
|
|
continueGameDigest: null,
|
|
campaignState: null,
|
|
actState: null,
|
|
consequenceLedger: [],
|
|
companionResolutions: [],
|
|
endingState: null,
|
|
authorialConstraintPack: null,
|
|
branchBudgetStatus: null,
|
|
narrativeQaReport: null,
|
|
narrativeCodex: [],
|
|
playerStyleProfile: null,
|
|
simulationRunResults: [],
|
|
releaseGateReport: null,
|
|
saveMigrationManifest: {
|
|
version: STORY_ENGINE_MIGRATION_VERSION,
|
|
requiredTransforms: STORY_ENGINE_REQUIRED_TRANSFORMS,
|
|
backwardCompatible: true,
|
|
},
|
|
};
|
|
}
|
|
|
|
function normalizeRuntimeStats(
|
|
stats: unknown,
|
|
options: {
|
|
isActiveRun?: boolean;
|
|
now?: number;
|
|
} = {},
|
|
) {
|
|
const now = options.now ?? Date.now();
|
|
const rawStats = isRecord(stats) ? stats : {};
|
|
|
|
return {
|
|
playTimeMs:
|
|
typeof rawStats.playTimeMs === 'number' &&
|
|
Number.isFinite(rawStats.playTimeMs)
|
|
? Math.max(0, rawStats.playTimeMs)
|
|
: 0,
|
|
lastPlayTickAt: options.isActiveRun ? new Date(now).toISOString() : null,
|
|
hostileNpcsDefeated: clampNonNegativeInteger(
|
|
rawStats.hostileNpcsDefeated,
|
|
),
|
|
questsAccepted: clampNonNegativeInteger(rawStats.questsAccepted),
|
|
itemsUsed: clampNonNegativeInteger(rawStats.itemsUsed),
|
|
scenesTraveled: clampNonNegativeInteger(rawStats.scenesTraveled),
|
|
};
|
|
}
|
|
|
|
function normalizeCharacterChats(value: unknown) {
|
|
return Object.fromEntries(
|
|
Object.entries(isRecord(value) ? value : {}).map(([characterId, record]) => {
|
|
const rawRecord = isRecord(record) ? record : {};
|
|
|
|
return [
|
|
characterId,
|
|
{
|
|
history: readArray(rawRecord.history)
|
|
.filter(
|
|
(turn) =>
|
|
isRecord(turn) &&
|
|
typeof turn.text === 'string' &&
|
|
(turn.speaker === 'player' || turn.speaker === 'character'),
|
|
)
|
|
.map((turn) => ({
|
|
speaker: turn.speaker,
|
|
text: turn.text,
|
|
})),
|
|
summary: readString(rawRecord.summary),
|
|
updatedAt: readString(rawRecord.updatedAt) || null,
|
|
},
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function normalizeCompanionState(value: unknown) {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
const npcId = readString(value.npcId);
|
|
if (!npcId) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...jsonClone(value),
|
|
npcId,
|
|
characterId: readString(value.characterId),
|
|
joinedAtAffinity: Math.round(readNumber(value.joinedAtAffinity, 0)),
|
|
};
|
|
}
|
|
|
|
function dedupeCompanions(value: unknown) {
|
|
const seenNpcIds = new Set<string>();
|
|
|
|
return readArray(value)
|
|
.map((entry) => normalizeCompanionState(entry))
|
|
.filter((entry): entry is NonNullable<ReturnType<typeof normalizeCompanionState>> => {
|
|
if (!entry || seenNpcIds.has(entry.npcId)) {
|
|
return false;
|
|
}
|
|
|
|
seenNpcIds.add(entry.npcId);
|
|
return true;
|
|
});
|
|
}
|
|
|
|
function normalizeRoster(
|
|
roster: ReturnType<typeof dedupeCompanions>,
|
|
companions: ReturnType<typeof dedupeCompanions>,
|
|
) {
|
|
const activeNpcIds = new Set(companions.map((companion) => companion.npcId));
|
|
|
|
return roster.filter((companion) => !activeNpcIds.has(companion.npcId));
|
|
}
|
|
|
|
function normalizeNpcStates(value: unknown) {
|
|
return Object.fromEntries(
|
|
Object.entries(isRecord(value) ? value : {}).map(([npcId, npcState]) => {
|
|
const rawState = isRecord(npcState) ? npcState : {};
|
|
|
|
return [
|
|
npcId,
|
|
normalizeNpcPersistentState({
|
|
...jsonClone(rawState),
|
|
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: jsonClone(readArray(rawState.inventory)),
|
|
recruited: readBoolean(rawState.recruited),
|
|
}),
|
|
];
|
|
}),
|
|
);
|
|
}
|
|
|
|
function resolveInitialPlayerCurrency(gameState: JsonRecord) {
|
|
const customWorldProfile = isRecord(gameState.customWorldProfile)
|
|
? gameState.customWorldProfile
|
|
: null;
|
|
const customWorldInitialCurrency = readNumber(
|
|
(customWorldProfile?.ownedSettingLayers as JsonRecord | undefined)
|
|
?.ruleProfile &&
|
|
isRecord(
|
|
(customWorldProfile.ownedSettingLayers as JsonRecord).ruleProfile,
|
|
) &&
|
|
isRecord(
|
|
(
|
|
(customWorldProfile.ownedSettingLayers as JsonRecord)
|
|
.ruleProfile as JsonRecord
|
|
).economyProfile,
|
|
)
|
|
? (
|
|
(
|
|
(
|
|
customWorldProfile.ownedSettingLayers as JsonRecord
|
|
).ruleProfile as JsonRecord
|
|
).economyProfile as JsonRecord
|
|
).initialCurrency
|
|
: undefined,
|
|
Number.NaN,
|
|
);
|
|
if (Number.isFinite(customWorldInitialCurrency)) {
|
|
return Math.max(0, Math.round(customWorldInitialCurrency));
|
|
}
|
|
|
|
return readString(gameState.worldType).toUpperCase() === 'XIANXIA' ? 140 : 160;
|
|
}
|
|
|
|
function normalizeEquipmentLoadout(value: unknown) {
|
|
if (!isRecord(value)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
weapon: isRecord(value.weapon) ? jsonClone(value.weapon) : null,
|
|
armor: isRecord(value.armor) ? jsonClone(value.armor) : null,
|
|
relic: isRecord(value.relic) ? jsonClone(value.relic) : null,
|
|
};
|
|
}
|
|
|
|
function normalizePresetRarity(rarityText: string | undefined) {
|
|
if (!rarityText) return 'common' as const;
|
|
if (/传说|legendary/iu.test(rarityText)) return 'legendary' as const;
|
|
if (/史诗|epic/iu.test(rarityText)) return 'epic' as const;
|
|
if (/稀有|rare/iu.test(rarityText)) return 'rare' as const;
|
|
if (/优秀|uncommon/iu.test(rarityText)) return 'uncommon' as const;
|
|
return 'common' as const;
|
|
}
|
|
|
|
function inferEquipmentSlot(value: string) {
|
|
if (/武器|剑|弓|刀|拳套|战刃|佩刀|枪|刃/u.test(value)) {
|
|
return 'weapon' as const;
|
|
}
|
|
if (/护甲|甲|护臂|衣|袍|铠/u.test(value)) {
|
|
return 'armor' as const;
|
|
}
|
|
if (/饰品|护符|徽章|玉|珠|坠|铃|盘|令|匣/u.test(value)) {
|
|
return 'relic' as const;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function inferEquipmentTags(slot: RuntimeEquipmentSlotId, name: string) {
|
|
const tags = new Set<string>([slot]);
|
|
|
|
if (/灵|气|符|珠|盘|玉/u.test(name)) tags.add('mana');
|
|
if (/护|守|甲|铠/u.test(name)) tags.add('armor');
|
|
if (/刃|剑|弓|刀|拳/u.test(name)) tags.add('weapon');
|
|
if (/徽章|护符|坠|铃|盘|令/u.test(name)) tags.add('relic');
|
|
if (/疗|愈|血/u.test(name)) tags.add('healing');
|
|
|
|
return [...tags];
|
|
}
|
|
|
|
function getLegacyCharacterEquipment(character: JsonRecord): LegacyCharacterEquipmentItem[] {
|
|
const equipmentById: Record<string, LegacyCharacterEquipmentItem[]> = {
|
|
'sword-princess': [
|
|
{ slot: '武器', item: '王庭剑', rarity: '稀有' },
|
|
{ slot: '护甲', item: '王庭轻甲', rarity: '稀有' },
|
|
{ slot: '饰品', item: '皇室徽章', rarity: '史诗' },
|
|
],
|
|
'archer-hero': [
|
|
{ slot: '武器', item: '流风弓', rarity: '稀有' },
|
|
{ slot: '护甲', item: '风行者皮甲', rarity: '稀有' },
|
|
{ slot: '饰品', item: '鹰眼石', rarity: '史诗' },
|
|
],
|
|
'girl-hero': [
|
|
{ slot: '武器', item: '双影刃', rarity: '稀有' },
|
|
{ slot: '护甲', item: '疾影轻甲', rarity: '稀有' },
|
|
{ slot: '饰品', item: '敏捷徽章', rarity: '史诗' },
|
|
],
|
|
'punch-hero': [
|
|
{ slot: '武器', item: '破军拳套', rarity: '稀有' },
|
|
{ slot: '护甲', item: '刚岩护甲', rarity: '稀有' },
|
|
{ slot: '饰品', item: '力量护符', rarity: '史诗' },
|
|
],
|
|
'fighter-4': [
|
|
{ slot: '武器', item: '玄甲战刃', rarity: '稀有' },
|
|
{ slot: '护甲', item: '玄铁甲', rarity: '稀有' },
|
|
{ slot: '饰品', item: '守护徽章', rarity: '史诗' },
|
|
],
|
|
};
|
|
|
|
const characterId = readString(character.id);
|
|
if (equipmentById[characterId]) {
|
|
return equipmentById[characterId];
|
|
}
|
|
|
|
const characterName = readString(character.name, '旅人');
|
|
return EQUIPMENT_SLOTS.map((slot) => ({
|
|
slot: getEquipmentSlotLabel(slot),
|
|
item: {
|
|
weapon: `${characterName}的主手器`,
|
|
armor: `${characterName}的护身装`,
|
|
relic: `${characterName}的随身符`,
|
|
}[slot],
|
|
rarity: '普通',
|
|
}));
|
|
}
|
|
|
|
function buildLegacyStarterEquipmentLoadout(character: JsonRecord) {
|
|
const characterId = readString(character.id, 'unknown');
|
|
const loadout = createEmptyEquipmentLoadout();
|
|
const starterEquipment = getLegacyCharacterEquipment(character);
|
|
|
|
starterEquipment.forEach((equipmentItem, index) => {
|
|
const slot =
|
|
inferEquipmentSlot(`${equipmentItem.slot} ${equipmentItem.item}`) ??
|
|
EQUIPMENT_SLOTS[index] ??
|
|
null;
|
|
if (!slot || loadout[slot]) {
|
|
return;
|
|
}
|
|
|
|
loadout[slot] = {
|
|
id: `starter:${characterId}:${slot}`,
|
|
category: getEquipmentSlotLabel(slot),
|
|
name: equipmentItem.item,
|
|
quantity: 1,
|
|
rarity: normalizePresetRarity(equipmentItem.rarity),
|
|
tags: inferEquipmentTags(slot, equipmentItem.item),
|
|
equipmentSlotId: slot,
|
|
};
|
|
});
|
|
|
|
return loadout;
|
|
}
|
|
|
|
function hasEquippedItems(
|
|
equipment: ReturnType<typeof normalizeEquipmentLoadout>,
|
|
) {
|
|
return Boolean(equipment?.weapon || equipment?.armor || equipment?.relic);
|
|
}
|
|
|
|
function readCharacterAttributes(character: JsonRecord) {
|
|
return isRecord(character.attributes) ? character.attributes : {};
|
|
}
|
|
|
|
function getLegacyCharacterBaseMaxHp(character: JsonRecord) {
|
|
const attributes = readCharacterAttributes(character);
|
|
|
|
return Math.max(
|
|
120,
|
|
90 +
|
|
readNumber(attributes.strength, 0) * 10 +
|
|
readNumber(attributes.spirit, 0) * 4,
|
|
);
|
|
}
|
|
|
|
function buildCharacterResourceProfile(character: JsonRecord) {
|
|
const resourceProfile = isRecord(character.resourceProfile)
|
|
? character.resourceProfile
|
|
: null;
|
|
if (
|
|
resourceProfile &&
|
|
Number.isFinite(resourceProfile.maxHp) &&
|
|
Number.isFinite(resourceProfile.maxMana)
|
|
) {
|
|
return {
|
|
maxHp: Math.max(1, Math.round(readNumber(resourceProfile.maxHp, 1))),
|
|
maxMana: Math.max(
|
|
1,
|
|
Math.round(readNumber(resourceProfile.maxMana, UNIVERSAL_MAX_MANA)),
|
|
),
|
|
};
|
|
}
|
|
|
|
const source = [
|
|
readString(character.title),
|
|
readString(character.description),
|
|
readString(character.personality),
|
|
...readArray(character.combatTags).filter(
|
|
(tag): tag is string => typeof tag === 'string' && tag.trim().length > 0,
|
|
),
|
|
].join(' ');
|
|
const skills = readArray(character.skills);
|
|
const baseHp = /守|甲|拳|先锋|重击|护体/u.test(source)
|
|
? 210
|
|
: /远射|机动|快袭|游击/u.test(source)
|
|
? 168
|
|
: /法|符|阵|灵|术/u.test(source)
|
|
? 176
|
|
: 188;
|
|
|
|
return {
|
|
maxHp: Math.max(
|
|
getLegacyCharacterBaseMaxHp(character),
|
|
baseHp + Math.min(18, skills.length * 4),
|
|
),
|
|
maxMana: UNIVERSAL_MAX_MANA,
|
|
};
|
|
}
|
|
|
|
function normalizeSavedStory(currentStory: unknown) {
|
|
if (!isRecord(currentStory)) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...jsonClone(currentStory),
|
|
streaming: false,
|
|
};
|
|
}
|
|
|
|
function normalizeGameState(gameState: unknown) {
|
|
const rawState = isRecord(gameState) ? jsonClone(gameState) : {};
|
|
const { playerEquipment: _rawPlayerEquipment, ...rawStateWithoutEquipment } =
|
|
rawState;
|
|
const playerCharacter = isRecord(rawState.playerCharacter)
|
|
? rawState.playerCharacter
|
|
: null;
|
|
const companions = dedupeCompanions(rawState.companions);
|
|
const roster = normalizeRoster(dedupeCompanions(rawState.roster), companions);
|
|
const storyEngineMemory = {
|
|
...createEmptyStoryEngineMemoryState(),
|
|
...(isRecord(rawState.storyEngineMemory)
|
|
? jsonClone(rawState.storyEngineMemory)
|
|
: {}),
|
|
saveMigrationManifest: buildSaveMigrationManifest(),
|
|
};
|
|
const savedPlayerMaxHp = Math.max(
|
|
1,
|
|
Math.round(readNumber(rawState.playerMaxHp, 1)),
|
|
);
|
|
const savedPlayerMaxMana = Math.max(
|
|
1,
|
|
Math.round(readNumber(rawState.playerMaxMana, 1)),
|
|
);
|
|
const resolvedEquipment =
|
|
normalizeEquipmentLoadout(rawState.playerEquipment) ??
|
|
(playerCharacter ? buildLegacyStarterEquipmentLoadout(playerCharacter) : null);
|
|
const baseResourceProfile = playerCharacter
|
|
? buildCharacterResourceProfile(playerCharacter)
|
|
: null;
|
|
const basePlayerMaxHp = baseResourceProfile
|
|
? hasEquippedItems(resolvedEquipment)
|
|
? baseResourceProfile.maxHp
|
|
: Math.max(baseResourceProfile.maxHp, savedPlayerMaxHp)
|
|
: savedPlayerMaxHp;
|
|
const basePlayerMaxMana = baseResourceProfile
|
|
? hasEquippedItems(resolvedEquipment)
|
|
? baseResourceProfile.maxMana
|
|
: Math.max(baseResourceProfile.maxMana, savedPlayerMaxMana)
|
|
: savedPlayerMaxMana;
|
|
const normalizedCommonState = {
|
|
...rawStateWithoutEquipment,
|
|
customWorldProfile:
|
|
isRecord(rawState.customWorldProfile) || rawState.customWorldProfile === null
|
|
? rawState.customWorldProfile ?? null
|
|
: null,
|
|
runtimeStats: normalizeRuntimeStats(rawState.runtimeStats, {
|
|
isActiveRun: Boolean(
|
|
rawState.playerCharacter && rawState.currentScene === 'Story',
|
|
),
|
|
}),
|
|
storyEngineMemory,
|
|
chapterState:
|
|
rawState.chapterState ??
|
|
(isRecord(storyEngineMemory.currentChapter)
|
|
? storyEngineMemory.currentChapter
|
|
: null),
|
|
campaignState:
|
|
rawState.campaignState ??
|
|
(isRecord(storyEngineMemory.campaignState)
|
|
? storyEngineMemory.campaignState
|
|
: storyEngineMemory.campaignState ?? null),
|
|
activeScenarioPackId:
|
|
readString(rawState.activeScenarioPackId) ||
|
|
readString(
|
|
(rawState.customWorldProfile as JsonRecord | null)?.scenarioPackId,
|
|
) ||
|
|
null,
|
|
activeCampaignPackId:
|
|
readString(rawState.activeCampaignPackId) ||
|
|
readString(
|
|
(rawState.customWorldProfile as JsonRecord | null)?.campaignPackId,
|
|
) ||
|
|
null,
|
|
npcInteractionActive: readBoolean(rawState.npcInteractionActive),
|
|
playerCurrency:
|
|
typeof rawState.playerCurrency === 'number' &&
|
|
Number.isFinite(rawState.playerCurrency)
|
|
? Math.round(rawState.playerCurrency)
|
|
: resolveInitialPlayerCurrency(rawState),
|
|
quests: normalizeQuestEntries(
|
|
jsonClone(readArray(rawState.quests)) as Parameters<
|
|
typeof normalizeQuestEntries
|
|
>[0],
|
|
),
|
|
roster,
|
|
companions,
|
|
npcStates: normalizeNpcStates(rawState.npcStates),
|
|
characterChats: normalizeCharacterChats(rawState.characterChats),
|
|
activeBuildBuffs: jsonClone(readArray(rawState.activeBuildBuffs)),
|
|
runtimeSessionId: readString(rawState.runtimeSessionId) || null,
|
|
runtimeActionVersion:
|
|
typeof rawState.runtimeActionVersion === 'number' &&
|
|
Number.isFinite(rawState.runtimeActionVersion)
|
|
? Math.round(rawState.runtimeActionVersion)
|
|
: 0,
|
|
};
|
|
|
|
if (!playerCharacter) {
|
|
return {
|
|
...normalizedCommonState,
|
|
playerEquipment: createEmptyEquipmentLoadout(),
|
|
playerMaxHp: savedPlayerMaxHp,
|
|
playerHp: Math.max(
|
|
0,
|
|
Math.min(
|
|
savedPlayerMaxHp,
|
|
Math.round(readNumber(rawState.playerHp, savedPlayerMaxHp)),
|
|
),
|
|
),
|
|
playerMaxMana: savedPlayerMaxMana,
|
|
playerMana: Math.max(
|
|
0,
|
|
Math.min(
|
|
savedPlayerMaxMana,
|
|
Math.round(readNumber(rawState.playerMana, savedPlayerMaxMana)),
|
|
),
|
|
),
|
|
};
|
|
}
|
|
|
|
const stateWithResourceCaps = {
|
|
...normalizedCommonState,
|
|
playerCharacter,
|
|
playerMaxHp: basePlayerMaxHp,
|
|
playerHp: Math.max(
|
|
0,
|
|
Math.round(readNumber(rawState.playerHp, basePlayerMaxHp)),
|
|
),
|
|
playerMaxMana: basePlayerMaxMana,
|
|
playerMana: Math.max(
|
|
0,
|
|
Math.round(readNumber(rawState.playerMana, basePlayerMaxMana)),
|
|
),
|
|
};
|
|
|
|
if (!resolvedEquipment) {
|
|
return stateWithResourceCaps;
|
|
}
|
|
|
|
const equipmentBonuses = getEquipmentBonuses(resolvedEquipment);
|
|
const nextPlayerMaxHp = basePlayerMaxHp + equipmentBonuses.maxHpBonus;
|
|
const nextPlayerMaxMana = basePlayerMaxMana + equipmentBonuses.maxManaBonus;
|
|
|
|
return {
|
|
...stateWithResourceCaps,
|
|
playerEquipment: resolvedEquipment,
|
|
playerMaxHp: nextPlayerMaxHp,
|
|
playerHp: Math.min(nextPlayerMaxHp, stateWithResourceCaps.playerHp),
|
|
playerMaxMana: nextPlayerMaxMana,
|
|
playerMana: Math.min(nextPlayerMaxMana, stateWithResourceCaps.playerMana),
|
|
};
|
|
}
|
|
|
|
export function normalizeSavedSnapshotPayload<T extends SnapshotShape>(snapshot: T) {
|
|
return {
|
|
...snapshot,
|
|
bottomTab: normalizeBottomTab(snapshot.bottomTab),
|
|
gameState: normalizeGameState(snapshot.gameState),
|
|
currentStory: normalizeSavedStory(snapshot.currentStory),
|
|
};
|
|
}
|
|
|
|
export function hydrateSavedSnapshot(
|
|
snapshot: SavedSnapshot | null,
|
|
): SavedSnapshot | null {
|
|
if (!snapshot) {
|
|
return null;
|
|
}
|
|
|
|
return normalizeSavedSnapshotPayload(snapshot);
|
|
}
|