import { buildInitialNpcState, createNpcBattleMonster, } from '../data/npcInteractions'; import { normalizePlayerProgressionState } from '../data/playerProgression'; import type { Encounter, GameState, NpcPersistentState, SceneHostileNpc, StoryMoment, } from '../types'; import { WorldType } from '../types'; import type { BottomTab } from '../types/navigation'; import type { HydratableGameState, HydratedGameState, HydratedSnapshotState, SnapshotState, } from './runtimeSnapshotTypes'; function normalizeBottomTab(bottomTab: string | null | undefined): BottomTab { return bottomTab === 'character' || bottomTab === 'inventory' ? bottomTab : 'adventure'; } function normalizeEquipmentLoadout( playerEquipment: HydratableGameState['playerEquipment'], ) { if (!playerEquipment || typeof playerEquipment !== 'object') { return null; } return { weapon: playerEquipment.weapon ?? null, armor: playerEquipment.armor ?? null, relic: playerEquipment.relic ?? null, } satisfies GameState['playerEquipment']; } function createEmptyEquipmentLoadout() { return { weapon: null, armor: null, relic: null, } satisfies GameState['playerEquipment']; } function resolveHydrationWorldType(worldType: GameState['worldType']) { const normalizedWorldType = typeof worldType === 'string' ? worldType.toUpperCase() : worldType; if (normalizedWorldType === WorldType.WUXIA) { return WorldType.WUXIA; } if (normalizedWorldType === WorldType.XIANXIA) { return WorldType.XIANXIA; } if (normalizedWorldType === WorldType.CUSTOM) { return WorldType.CUSTOM; } return null; } function hasRenderableRuntimeNpcBattleFields(hostileNpc: SceneHostileNpc) { const candidate = hostileNpc as Partial; return Boolean( candidate.encounter && typeof candidate.animation === 'string' && typeof candidate.xMeters === 'number' && typeof candidate.yOffset === 'number' && typeof candidate.facing === 'string' && typeof candidate.attackRange === 'number' && typeof candidate.speed === 'number', ); } function normalizeRuntimeBattleEncounter( encounter: GameState['currentEncounter'], ): Encounter | null { if (!encounter || encounter.kind !== 'npc') { return null; } const npcName = typeof encounter.npcName === 'string' ? encounter.npcName.trim() : ''; if (!npcName) { return null; } return { ...encounter, kind: 'npc', npcName, npcDescription: typeof encounter.npcDescription === 'string' ? encounter.npcDescription : '', npcAvatar: typeof encounter.npcAvatar === 'string' ? encounter.npcAvatar : '', context: typeof encounter.context === 'string' ? encounter.context : '', hostile: true, levelProfile: encounter.levelProfile, experienceReward: encounter.experienceReward, } satisfies Encounter; } function resolveRuntimeNpcBattleState( gameState: Pick< GameState, | 'currentBattleNpcId' | 'currentEncounter' | 'customWorldProfile' | 'npcStates' | 'sceneHostileNpcs' | 'worldType' >, ) { const encounter = normalizeRuntimeBattleEncounter(gameState.currentEncounter); if (!encounter || gameState.sceneHostileNpcs.length === 0) { return null; } const npcStateKey = gameState.currentBattleNpcId ?? encounter.id ?? encounter.npcName; const npcState = gameState.npcStates[npcStateKey] ?? buildInitialNpcState( encounter, resolveHydrationWorldType(gameState.worldType), gameState as GameState, ); return { encounter, npcState, }; } function hydrateRuntimeNpcBattleMonster(params: { hostileNpc: SceneHostileNpc; encounter: Encounter; npcState: NpcPersistentState; gameState: Pick; battleMode: NonNullable; }) { const template = createNpcBattleMonster( params.encounter, params.npcState, params.battleMode, { worldType: resolveHydrationWorldType(params.gameState.worldType), customWorldProfile: params.gameState.customWorldProfile, }, ); const candidate = params.hostileNpc as Partial; const xMeters = typeof candidate.xMeters === 'number' ? candidate.xMeters : template.xMeters; const yOffset = typeof candidate.yOffset === 'number' ? candidate.yOffset : template.yOffset; return { ...template, id: typeof candidate.id === 'string' && candidate.id.trim() ? candidate.id : template.id, name: typeof candidate.name === 'string' && candidate.name.trim() ? candidate.name : template.name, description: typeof candidate.description === 'string' ? candidate.description : template.description, hp: typeof candidate.hp === 'number' ? candidate.hp : template.hp, maxHp: typeof candidate.maxHp === 'number' ? candidate.maxHp : template.maxHp, animation: typeof candidate.animation === 'string' ? candidate.animation : template.animation, xMeters, yOffset, facing: candidate.facing === 'left' || candidate.facing === 'right' ? candidate.facing : template.facing, attackRange: typeof candidate.attackRange === 'number' ? candidate.attackRange : template.attackRange, speed: typeof candidate.speed === 'number' ? candidate.speed : template.speed, levelProfile: candidate.levelProfile ?? template.levelProfile, experienceReward: typeof candidate.experienceReward === 'number' ? candidate.experienceReward : template.experienceReward, encounter: { ...template.encounter, xMeters, }, } satisfies SceneHostileNpc; } export function hydrateRuntimeNpcBattleGameState( gameState: HydratedGameState, ): HydratedGameState { const battleMode = gameState.currentNpcBattleMode; if ( gameState.inBattle !== true || (battleMode !== 'fight' && battleMode !== 'spar') || gameState.currentEncounter?.kind !== 'npc' || gameState.sceneHostileNpcs.length === 0 || gameState.sceneHostileNpcs.every(hasRenderableRuntimeNpcBattleFields) ) { return gameState; } const resolvedState = resolveRuntimeNpcBattleState(gameState); if (!resolvedState) { return gameState; } return { ...gameState, sceneHostileNpcs: gameState.sceneHostileNpcs.map((hostileNpc) => hasRenderableRuntimeNpcBattleFields(hostileNpc) ? hostileNpc : hydrateRuntimeNpcBattleMonster({ hostileNpc, encounter: resolvedState.encounter, npcState: resolvedState.npcState, gameState, battleMode, }), ), }; } export function normalizeSavedStory(story: StoryMoment | null) { if (!story) { return null; } return { ...story, streaming: false, } satisfies StoryMoment; } export function normalizeSavedGameState(gameState: GameState) { const hydratableState = gameState as HydratableGameState; const resolvedEquipment = normalizeEquipmentLoadout( hydratableState.playerEquipment, ); const playerMaxHp = Math.max(1, hydratableState.playerMaxHp); const playerMaxMana = Math.max(1, hydratableState.playerMaxMana); return hydrateRuntimeNpcBattleGameState({ ...hydratableState, playerProgression: normalizePlayerProgressionState( hydratableState.playerProgression ?? null, ), playerMaxHp, playerHp: Math.min(hydratableState.playerHp, playerMaxHp), playerMaxMana, playerMana: Math.min(hydratableState.playerMana, playerMaxMana), playerEquipment: resolvedEquipment ?? createEmptyEquipmentLoadout(), runtimeActionVersion: typeof hydratableState.runtimeActionVersion === 'number' ? hydratableState.runtimeActionVersion : 0, runtimeSessionId: typeof hydratableState.runtimeSessionId === 'string' ? hydratableState.runtimeSessionId : null, } satisfies HydratedGameState); } export function hydrateSnapshotState(snapshot: { gameState: GameState; currentStory: StoryMoment | null; bottomTab: string; }): HydratedSnapshotState { return { gameState: normalizeSavedGameState(snapshot.gameState), currentStory: normalizeSavedStory(snapshot.currentStory), bottomTab: normalizeBottomTab(snapshot.bottomTab), }; } export function isHydratedSnapshotState( snapshot: SnapshotState, ): snapshot is HydratedSnapshotState { const { gameState, currentStory, bottomTab } = snapshot; return Boolean( (bottomTab === 'adventure' || bottomTab === 'character' || bottomTab === 'inventory') && (!currentStory || currentStory.streaming !== true) && typeof gameState.runtimeActionVersion === 'number' && (gameState.runtimeSessionId === null || typeof gameState.runtimeSessionId === 'string') && (!gameState.playerCharacter || Boolean( gameState.playerEquipment && typeof gameState.playerEquipment === 'object', )), ); } export function rehydrateSavedSnapshot( snapshot: T, ): T { const hydratedGameState = hydrateRuntimeNpcBattleGameState( snapshot.gameState, ); if (hydratedGameState === snapshot.gameState) { return snapshot; } return { ...snapshot, gameState: hydratedGameState, }; } export function resolveHydratedSnapshotState(snapshot: SnapshotState) { return isHydratedSnapshotState(snapshot) ? rehydrateSavedSnapshot(snapshot) : hydrateSnapshotState(snapshot); }