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; 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(); return readArray(value) .map((entry) => normalizeCompanionState(entry)) .filter((entry): entry is NonNullable> => { if (!entry || seenNpcIds.has(entry.npcId)) { return false; } seenNpcIds.add(entry.npcId); return true; }); } function normalizeRoster( roster: ReturnType, companions: ReturnType, ) { 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([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 = { '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, ) { 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(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); }