This commit is contained in:
18
src/persistence/gameSaveStorage.ts
Normal file
18
src/persistence/gameSaveStorage.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import {
|
||||
type SavedGameSnapshot as SharedSavedGameSnapshot,
|
||||
type SavedGameSnapshotInput as SharedSavedGameSnapshotInput,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import type {GameState, StoryMoment} from '../types';
|
||||
import type {BottomTab} from '../types/navigation';
|
||||
|
||||
export type SavedGameSnapshot = SharedSavedGameSnapshot<
|
||||
GameState,
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
|
||||
export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput<
|
||||
GameState,
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
83
src/persistence/gameSettingsStorage.test.ts
Normal file
83
src/persistence/gameSettingsStorage.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import {afterEach, describe, expect, it, vi} from 'vitest';
|
||||
|
||||
import {
|
||||
clampVolume,
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
normalizePlatformTheme,
|
||||
readSavedSettings,
|
||||
writeSavedSettings,
|
||||
} from './gameSettingsStorage';
|
||||
import type {JsonStorage} from './storage';
|
||||
|
||||
function createMemoryStorage(): JsonStorage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('gameSettingsStorage', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('falls back to defaults when nothing has been saved', () => {
|
||||
vi.stubGlobal('window', {localStorage: createMemoryStorage()});
|
||||
|
||||
expect(readSavedSettings()).toEqual({
|
||||
musicVolume: DEFAULT_MUSIC_VOLUME,
|
||||
platformTheme: 'light',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads legacy unversioned payloads and clamps the volume', () => {
|
||||
const storage = createMemoryStorage();
|
||||
storage.setItem('tavernrealms.settings.v1', JSON.stringify({musicVolume: 2}));
|
||||
vi.stubGlobal('window', {localStorage: storage});
|
||||
|
||||
expect(readSavedSettings()).toEqual({
|
||||
musicVolume: 1,
|
||||
platformTheme: 'light',
|
||||
});
|
||||
});
|
||||
|
||||
it('reads stored platform theme when available', () => {
|
||||
const storage = createMemoryStorage();
|
||||
storage.setItem(
|
||||
'tavernrealms.settings.v1',
|
||||
JSON.stringify({musicVolume: 0.5, platformTheme: 'dark'}),
|
||||
);
|
||||
vi.stubGlobal('window', {localStorage: storage});
|
||||
|
||||
expect(readSavedSettings()).toEqual({
|
||||
musicVolume: 0.5,
|
||||
platformTheme: 'dark',
|
||||
});
|
||||
expect(normalizePlatformTheme('unknown')).toBe('light');
|
||||
});
|
||||
|
||||
it('writes versioned settings payloads', () => {
|
||||
const storage = createMemoryStorage();
|
||||
vi.stubGlobal('window', {localStorage: storage});
|
||||
|
||||
writeSavedSettings({
|
||||
musicVolume: clampVolume(0.6),
|
||||
platformTheme: 'dark',
|
||||
});
|
||||
|
||||
expect(JSON.parse(storage.getItem('tavernrealms.settings.v1') ?? '{}')).toEqual({
|
||||
version: 1,
|
||||
musicVolume: 0.6,
|
||||
platformTheme: 'dark',
|
||||
});
|
||||
});
|
||||
});
|
||||
76
src/persistence/gameSettingsStorage.ts
Normal file
76
src/persistence/gameSettingsStorage.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
DEFAULT_PLATFORM_THEME,
|
||||
type PlatformTheme,
|
||||
type RuntimeSettings,
|
||||
} from '../../packages/shared/src/contracts/runtime';
|
||||
import {isRecord, readStoredJson, writeStoredJson} from './storage';
|
||||
|
||||
const SETTINGS_STORAGE_KEY = 'tavernrealms.settings.v1';
|
||||
const SETTINGS_STORAGE_VERSION = 1;
|
||||
|
||||
export type SavedGameSettings = RuntimeSettings;
|
||||
export { DEFAULT_MUSIC_VOLUME };
|
||||
|
||||
type StoredGameSettings = SavedGameSettings & {
|
||||
version: number;
|
||||
};
|
||||
|
||||
export function clampVolume(value: number) {
|
||||
if (!Number.isFinite(value)) {
|
||||
return DEFAULT_MUSIC_VOLUME;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(1, value));
|
||||
}
|
||||
|
||||
export function normalizePlatformTheme(value: unknown): PlatformTheme {
|
||||
return value === 'dark' ? 'dark' : DEFAULT_PLATFORM_THEME;
|
||||
}
|
||||
|
||||
function parseSavedSettings(value: unknown): SavedGameSettings | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.version === SETTINGS_STORAGE_VERSION && typeof value.musicVolume === 'number') {
|
||||
return {
|
||||
musicVolume: clampVolume(value.musicVolume),
|
||||
platformTheme: normalizePlatformTheme(value.platformTheme),
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof value.musicVolume === 'number') {
|
||||
return {
|
||||
musicVolume: clampVolume(value.musicVolume),
|
||||
platformTheme: normalizePlatformTheme(value.platformTheme),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function readSavedSettings() {
|
||||
return (
|
||||
readStoredJson({
|
||||
key: SETTINGS_STORAGE_KEY,
|
||||
parse: parseSavedSettings,
|
||||
}) ?? {
|
||||
musicVolume: DEFAULT_MUSIC_VOLUME,
|
||||
platformTheme: DEFAULT_PLATFORM_THEME,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function writeSavedSettings(settings: SavedGameSettings) {
|
||||
const payload: StoredGameSettings = {
|
||||
version: SETTINGS_STORAGE_VERSION,
|
||||
musicVolume: clampVolume(settings.musicVolume),
|
||||
platformTheme: normalizePlatformTheme(settings.platformTheme),
|
||||
};
|
||||
|
||||
return writeStoredJson({
|
||||
key: SETTINGS_STORAGE_KEY,
|
||||
value: payload,
|
||||
});
|
||||
}
|
||||
255
src/persistence/runtimeSnapshot.test.ts
Normal file
255
src/persistence/runtimeSnapshot.test.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
347
src/persistence/runtimeSnapshot.ts
Normal file
347
src/persistence/runtimeSnapshot.ts
Normal file
@@ -0,0 +1,347 @@
|
||||
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<SceneHostileNpc>;
|
||||
|
||||
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<GameState, 'customWorldProfile' | 'worldType'>;
|
||||
battleMode: NonNullable<GameState['currentNpcBattleMode']>;
|
||||
}) {
|
||||
const template = createNpcBattleMonster(
|
||||
params.encounter,
|
||||
params.npcState,
|
||||
params.battleMode,
|
||||
{
|
||||
worldType: resolveHydrationWorldType(params.gameState.worldType),
|
||||
customWorldProfile: params.gameState.customWorldProfile,
|
||||
},
|
||||
);
|
||||
const candidate = params.hostileNpc as Partial<SceneHostileNpc>;
|
||||
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<T extends HydratedSnapshotState>(
|
||||
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);
|
||||
}
|
||||
31
src/persistence/runtimeSnapshotTypes.ts
Normal file
31
src/persistence/runtimeSnapshotTypes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import type { BottomTab } from '../types/navigation';
|
||||
import type { SavedGameSnapshot } from './gameSaveStorage';
|
||||
|
||||
export type HydratableGameState = GameState & {
|
||||
playerEquipment?: GameState['playerEquipment'] | null;
|
||||
};
|
||||
|
||||
export type HydratedGameState = GameState & {
|
||||
playerEquipment: GameState['playerEquipment'];
|
||||
runtimeActionVersion: number;
|
||||
runtimeSessionId: string | null;
|
||||
};
|
||||
|
||||
export type SnapshotState = {
|
||||
gameState: GameState;
|
||||
currentStory: StoryMoment | null;
|
||||
bottomTab: string;
|
||||
};
|
||||
|
||||
export type HydratedSnapshotState = {
|
||||
gameState: HydratedGameState;
|
||||
currentStory: StoryMoment | null;
|
||||
bottomTab: BottomTab;
|
||||
};
|
||||
|
||||
export type HydratedSavedGameSnapshot = Omit<
|
||||
SavedGameSnapshot,
|
||||
'gameState' | 'currentStory' | 'bottomTab'
|
||||
> &
|
||||
HydratedSnapshotState;
|
||||
65
src/persistence/storage.test.ts
Normal file
65
src/persistence/storage.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import {describe, expect, it} from 'vitest';
|
||||
|
||||
import {isRecord, type JsonStorage,readStoredJson, removeStoredJson, writeStoredJson} from './storage';
|
||||
|
||||
function createMemoryStorage(): JsonStorage {
|
||||
const values = new Map<string, string>();
|
||||
|
||||
return {
|
||||
getItem(key) {
|
||||
return values.has(key) ? values.get(key)! : null;
|
||||
},
|
||||
setItem(key, value) {
|
||||
values.set(key, value);
|
||||
},
|
||||
removeItem(key) {
|
||||
values.delete(key);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('storage helpers', () => {
|
||||
it('reads parsed json values from storage', () => {
|
||||
const storage = createMemoryStorage();
|
||||
|
||||
writeStoredJson({
|
||||
key: 'example',
|
||||
value: {value: 42},
|
||||
storage,
|
||||
});
|
||||
|
||||
const result = readStoredJson({
|
||||
key: 'example',
|
||||
storage,
|
||||
parse: value => (isRecord(value) && typeof value.value === 'number' ? value.value : null),
|
||||
});
|
||||
|
||||
expect(result).toBe(42);
|
||||
});
|
||||
|
||||
it('returns null when persisted json cannot be parsed', () => {
|
||||
const storage = createMemoryStorage();
|
||||
storage.setItem('broken', '{not-valid-json');
|
||||
|
||||
const result = readStoredJson({
|
||||
key: 'broken',
|
||||
storage,
|
||||
parse: () => 'unreachable',
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('removes persisted values', () => {
|
||||
const storage = createMemoryStorage();
|
||||
|
||||
writeStoredJson({
|
||||
key: 'example',
|
||||
value: {value: 'keep'},
|
||||
storage,
|
||||
});
|
||||
removeStoredJson('example', storage);
|
||||
|
||||
expect(storage.getItem('example')).toBeNull();
|
||||
});
|
||||
});
|
||||
65
src/persistence/storage.ts
Normal file
65
src/persistence/storage.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
export type JsonStorage = Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
|
||||
|
||||
export function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function getJsonStorage(storage?: JsonStorage | null) {
|
||||
if (storage !== undefined) {
|
||||
return storage;
|
||||
}
|
||||
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.localStorage;
|
||||
}
|
||||
|
||||
export function readStoredJson<T>({
|
||||
key,
|
||||
parse,
|
||||
storage,
|
||||
}: {
|
||||
key: string;
|
||||
parse: (value: unknown) => T | null;
|
||||
storage?: JsonStorage | null;
|
||||
}) {
|
||||
const resolvedStorage = getJsonStorage(storage);
|
||||
if (!resolvedStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const raw = resolvedStorage.getItem(key);
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return parse(JSON.parse(raw));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function writeStoredJson({
|
||||
key,
|
||||
value,
|
||||
storage,
|
||||
}: {
|
||||
key: string;
|
||||
value: unknown;
|
||||
storage?: JsonStorage | null;
|
||||
}) {
|
||||
const resolvedStorage = getJsonStorage(storage);
|
||||
if (!resolvedStorage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
resolvedStorage.setItem(key, JSON.stringify(value));
|
||||
return true;
|
||||
}
|
||||
|
||||
export function removeStoredJson(key: string, storage?: JsonStorage | null) {
|
||||
getJsonStorage(storage)?.removeItem(key);
|
||||
}
|
||||
Reference in New Issue
Block a user