import { describe, expect, it } from 'vitest'; import type { GameState, StoryMoment } from '../types'; import { WorldType } from '../types'; import { rehydrateSavedSnapshot, resolveHydratedSnapshotState, } from './runtimeSnapshot'; function createStory(text: string, streaming = false): StoryMoment { return { text, options: [], streaming, }; } function createHydratedBattleSnapshot( gameStateOverrides: Partial = {}, ) { return { version: 3, savedAt: '2026-04-14T00:00:00.000Z', bottomTab: 'adventure' as const, currentStory: createStory('战斗故事'), gameState: { worldType: WorldType.WUXIA, customWorldProfile: null, playerCharacter: { id: 'hero', }, runtimeActionVersion: 3, runtimeSessionId: 'runtime-main', currentScene: 'Story', runtimeStats: { playTimeMs: 0, lastPlayTickAt: null, hostileNpcsDefeated: 0, questsAccepted: 0, itemsUsed: 0, scenesTraveled: 0, }, storyHistory: [], characterChats: {}, animationState: 'idle', currentEncounter: { kind: 'npc', id: 'npc-fighter', npcName: '断桥客', npcDescription: '拦路的刀客', context: '断桥对峙', hostile: false, }, npcInteractionActive: false, currentScenePreset: null, sceneHostileNpcs: [ { id: 'npc-fighter', name: '断桥客', hp: 18, maxHp: 32, description: '拦路的刀客', levelProfile: { level: 4, referenceStrength: 202, progressionRole: 'rival', source: 'manual', }, experienceReward: 20, }, ], playerX: 0, playerOffsetY: 0, playerFacing: 'right', playerActionMode: 'idle', scrollWorld: false, inBattle: true, playerHp: 40, playerMaxHp: 40, playerMana: 18, playerMaxMana: 18, playerSkillCooldowns: {}, activeCombatEffects: [], playerCurrency: 0, playerInventory: [], playerEquipment: { weapon: null, armor: null, relic: null, }, npcStates: { 'npc-fighter': { affinity: -16, chattedCount: 0, helpUsed: false, giftsGiven: 0, inventory: [], recruited: false, }, }, quests: [], roster: [], companions: [], currentBattleNpcId: 'npc-fighter', currentNpcBattleMode: 'fight', currentNpcBattleOutcome: null, sparReturnEncounter: null, sparPlayerHpBefore: null, sparPlayerMaxHpBefore: null, sparStoryHistoryBefore: null, ...gameStateOverrides, } as unknown as GameState, }; } describe('runtimeSnapshot', () => { it('keeps server-hydrated snapshots unchanged', () => { const snapshot = { gameState: { playerCharacter: { id: 'sword-princess', }, playerEquipment: { weapon: null, armor: null, relic: null, }, runtimeActionVersion: 3, runtimeSessionId: 'runtime-main', } as GameState, currentStory: createStory('服务端恢复故事'), bottomTab: 'inventory', }; expect(resolveHydratedSnapshotState(snapshot)).toBe(snapshot); }); it('only applies minimal local shape hydration for non-hydrated legacy snapshots', () => { const snapshot = { gameState: { worldType: 'WUXIA', customWorldProfile: null, playerCharacter: { id: 'sword-princess', }, playerHp: 999, playerMaxHp: 12, playerMana: 999, playerMaxMana: 12, runtimeActionVersion: undefined, runtimeSessionId: undefined, playerEquipment: null, } as unknown as GameState, currentStory: createStory('旧快照故事', true), bottomTab: 'unknown', }; const hydrated = resolveHydratedSnapshotState(snapshot); expect(hydrated.bottomTab).toBe('adventure'); expect(hydrated.currentStory?.streaming).toBe(false); expect(hydrated.gameState.playerEquipment).toEqual({ weapon: null, armor: null, relic: null, }); expect(hydrated.gameState.playerProgression).toEqual({ level: 1, currentLevelXp: 0, totalXp: 0, xpToNextLevel: 60, pendingLevelUps: 0, lastGrantedSource: null, }); expect(hydrated.gameState.playerMaxHp).toBe(12); expect(hydrated.gameState.playerHp).toBe(12); expect(hydrated.gameState.playerMaxMana).toBe(12); expect(hydrated.gameState.playerMana).toBe(12); }); it('rehydrates minimal npc battle snapshots into renderable combatants', () => { const snapshot = createHydratedBattleSnapshot(); const hydrated = rehydrateSavedSnapshot(snapshot); const hostileNpc = hydrated.gameState.sceneHostileNpcs[0]; expect(hydrated).not.toBe(snapshot); expect(hostileNpc).toEqual( expect.objectContaining({ id: 'npc-fighter', name: '断桥客', description: '拦路的刀客', hp: 18, maxHp: 32, levelProfile: { level: 4, referenceStrength: 202, progressionRole: 'rival', source: 'manual', }, experienceReward: 20, attackRange: expect.any(Number), speed: expect.any(Number), animation: 'idle', renderKind: 'npc', encounter: expect.objectContaining({ kind: 'npc', id: 'npc-fighter', npcName: '断桥客', xMeters: expect.any(Number), }), }), ); }); it('does not rewrite already renderable npc battle snapshots', () => { const snapshot = createHydratedBattleSnapshot({ sceneHostileNpcs: [ { id: 'npc-fighter', name: '断桥客', action: '摆开架势,随时准备出手', description: '拦路的刀客', animation: 'idle', xMeters: 3.2, yOffset: 0, facing: 'left', attackRange: 1.8, speed: 7, hp: 18, maxHp: 32, levelProfile: { level: 4, referenceStrength: 202, progressionRole: 'rival', source: 'manual', }, experienceReward: 20, renderKind: 'npc', encounter: { kind: 'npc', id: 'npc-fighter', npcName: '断桥客', npcDescription: '拦路的刀客', npcAvatar: '', context: '断桥对峙', xMeters: 3.2, }, }, ], }); expect(rehydrateSavedSnapshot(snapshot)).toBe(snapshot); }); });