256 lines
6.7 KiB
TypeScript
256 lines
6.7 KiB
TypeScript
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<GameState> = {},
|
|
) {
|
|
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);
|
|
});
|
|
});
|