1
This commit is contained in:
643
server-node/src/modules/runtime/runtimeSnapshotHydration.ts
Normal file
643
server-node/src/modules/runtime/runtimeSnapshotHydration.ts
Normal file
@@ -0,0 +1,643 @@
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user