初始仓库迁移
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
2026-04-04 23:57:06 +08:00
parent 80986b790d
commit c49c64896a
18446 changed files with 532435 additions and 2 deletions

View File

@@ -0,0 +1,58 @@
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 = {
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);
}

View File

@@ -0,0 +1,61 @@
import {afterEach, describe, expect, it, vi} from 'vitest';
import {
clampVolume,
DEFAULT_MUSIC_VOLUME,
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,
});
});
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,
});
});
it('writes versioned settings payloads', () => {
const storage = createMemoryStorage();
vi.stubGlobal('window', {localStorage: storage});
writeSavedSettings({musicVolume: clampVolume(0.6)});
expect(JSON.parse(storage.getItem('tavernrealms.settings.v1') ?? '{}')).toEqual({
version: 1,
musicVolume: 0.6,
});
});
});

View File

@@ -0,0 +1,64 @@
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;
};
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));
}
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),
};
}
if (typeof value.musicVolume === 'number') {
return {
musicVolume: clampVolume(value.musicVolume),
};
}
return null;
}
export function readSavedSettings() {
return (
readStoredJson({
key: SETTINGS_STORAGE_KEY,
parse: parseSavedSettings,
}) ?? {
musicVolume: DEFAULT_MUSIC_VOLUME,
}
);
}
export function writeSavedSettings(settings: SavedGameSettings) {
const payload: StoredGameSettings = {
version: SETTINGS_STORAGE_VERSION,
musicVolume: clampVolume(settings.musicVolume),
};
return writeStoredJson({
key: SETTINGS_STORAGE_KEY,
value: payload,
});
}

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

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