1
This commit is contained in:
@@ -1,58 +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';
|
||||
import {isRecord, readStoredJson, removeStoredJson, writeStoredJson} from './storage';
|
||||
|
||||
const SAVE_STORAGE_KEY = 'tavernrealms.save.v1';
|
||||
const SAVE_VERSION = 2;
|
||||
export type SavedGameSnapshot = SharedSavedGameSnapshot<
|
||||
GameState,
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
|
||||
export type SavedGameSnapshot = {
|
||||
version: number;
|
||||
savedAt: string;
|
||||
gameState: GameState;
|
||||
bottomTab: BottomTab;
|
||||
currentStory: StoryMoment | null;
|
||||
};
|
||||
|
||||
export type SavedGameSnapshotInput = Omit<SavedGameSnapshot, 'savedAt' | 'version'> & {
|
||||
savedAt?: string;
|
||||
};
|
||||
|
||||
function parseSavedSnapshot(value: unknown): SavedGameSnapshot | null {
|
||||
if (!isRecord(value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value.version !== SAVE_VERSION || typeof value.savedAt !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!('gameState' in value) || !('bottomTab' in value) || !('currentStory' in value)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value as SavedGameSnapshot;
|
||||
}
|
||||
|
||||
export function readSavedSnapshot() {
|
||||
return readStoredJson({
|
||||
key: SAVE_STORAGE_KEY,
|
||||
parse: parseSavedSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
export function writeSavedSnapshot(snapshot: SavedGameSnapshotInput) {
|
||||
return writeStoredJson({
|
||||
key: SAVE_STORAGE_KEY,
|
||||
value: {
|
||||
version: SAVE_VERSION,
|
||||
savedAt: snapshot.savedAt ?? new Date().toISOString(),
|
||||
gameState: snapshot.gameState,
|
||||
bottomTab: snapshot.bottomTab,
|
||||
currentStory: snapshot.currentStory,
|
||||
} satisfies SavedGameSnapshot,
|
||||
});
|
||||
}
|
||||
|
||||
export function clearSavedSnapshot() {
|
||||
removeStoredJson(SAVE_STORAGE_KEY);
|
||||
}
|
||||
export type SavedGameSnapshotInput = SharedSavedGameSnapshotInput<
|
||||
GameState,
|
||||
BottomTab,
|
||||
StoryMoment
|
||||
>;
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import {
|
||||
DEFAULT_MUSIC_VOLUME,
|
||||
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 const DEFAULT_MUSIC_VOLUME = 0.42;
|
||||
|
||||
export type SavedGameSettings = {
|
||||
musicVolume: number;
|
||||
};
|
||||
export type SavedGameSettings = RuntimeSettings;
|
||||
export { DEFAULT_MUSIC_VOLUME };
|
||||
|
||||
type StoredGameSettings = SavedGameSettings & {
|
||||
version: number;
|
||||
|
||||
75
src/persistence/runtimeSnapshot.test.ts
Normal file
75
src/persistence/runtimeSnapshot.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import type { GameState, StoryMoment } from '../types';
|
||||
import {
|
||||
resolveHydratedSnapshotState,
|
||||
} from './runtimeSnapshot';
|
||||
|
||||
function createStory(
|
||||
text: string,
|
||||
streaming = false,
|
||||
): StoryMoment {
|
||||
return {
|
||||
text,
|
||||
options: [],
|
||||
streaming,
|
||||
};
|
||||
}
|
||||
|
||||
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.playerMaxHp).toBe(12);
|
||||
expect(hydrated.gameState.playerHp).toBe(12);
|
||||
expect(hydrated.gameState.playerMaxMana).toBe(12);
|
||||
expect(hydrated.gameState.playerMana).toBe(12);
|
||||
});
|
||||
});
|
||||
112
src/persistence/runtimeSnapshot.ts
Normal file
112
src/persistence/runtimeSnapshot.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import type { GameState, StoryMoment } 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'];
|
||||
}
|
||||
|
||||
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 {
|
||||
...hydratableState,
|
||||
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 resolveHydratedSnapshotState(snapshot: SnapshotState) {
|
||||
return isHydratedSnapshotState(snapshot)
|
||||
? 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;
|
||||
Reference in New Issue
Block a user