This commit is contained in:
2026-04-10 15:37:02 +08:00
parent 161cd32277
commit f19e482c8f
233 changed files with 43987 additions and 5127 deletions

View File

@@ -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
>;

View File

@@ -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;

View 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);
});
});

View 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);
}

View 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;