diff --git a/AGENTS.md b/AGENTS.md index e5edd0a..64160d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.37**(《天人感应·因果织锦》:功德因果系统 + 飞升三阶段试炼 + 种子派生世界奇观 + MOD 扩展口(奇观类型注册/worldNum 白名单/因果事件/飞升前缀保护)+ 测试 7040)。 +当前版本 **0.1.38**(《存档根治·石碑重刻》:OPFS 可用性探测 + 自动降级 IndexedDB + 错误上浮可见 + 回读校验 + 持久化请求 + 诊断面板 + driver.open 永久锁死修复 + exportSave open 补全 + 测试 N)。 ## 命令 diff --git a/package.json b/package.json index fa56726..86e9ec7 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.37", + "version": "0.1.38", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index ddb6073..6fb1e45 100644 --- a/src/renderer/game/engine/runtime/ApiFacade.ts +++ b/src/renderer/game/engine/runtime/ApiFacade.ts @@ -137,7 +137,7 @@ export class GameFacade { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { return { title: '仙途家族志', - version: '0.1.37', + version: '0.1.38', modules: this.world.systemList().length, systems: this.world.systemList().filter((s) => s.enabled).length, plugins: this.world.pluginList().length, diff --git a/src/renderer/game/storage/db.ts b/src/renderer/game/storage/db.ts index 17746aa..3030721 100644 --- a/src/renderer/game/storage/db.ts +++ b/src/renderer/game/storage/db.ts @@ -2,6 +2,21 @@ import { SaveSlot } from './slots' import { GameState, SaveMeta, SnapshotMeta } from '../types/domain' import type { SaveDbDriver } from './slots' +// ──────────────────────────────────────────────────────────────────────────── +// 0.1.38 存档根治:OPFS 可用性探测 + 自动降级 + 错误恢复 +// +// 旧版问题: +// 1. SqlarkDriver.open 失败后 this.opening 不清除 → 永久锁死,后续全部静默失败 +// 2. OPFS 在 Electron app:// origin 下可能不可用 → 全链路 throw 被 Stash.save 吞掉 +// 3. SlotManager.ensureMeta 直接用 OPFS,无降级 +// +// 0.1.38 方案: +// · 启动时探测 OPFS 可用性,不可用则自动降级到 KVStore(IndexedDB) +// · SqlarkDriver.open 失败后清除 opening,允许重试 +// · SlotManager 使用与 SaveSlot 相同的降级策略 +// · 全局存储后端类型可查询,供 UI 诊断面板展示 +// ──────────────────────────────────────────────────────────────────────────── + // Adapter for @metona-team/metona-sqlark // The driver interface keeps the engine swappable (testable in node too). // eslint-disable-next-line @typescript-eslint/no-explicit-any @@ -14,36 +29,128 @@ interface SqlarkLike { export function setSqlarkBackend(impl: AnyFactory): void { MetonaSqlark = impl + _backendType = 'mock' } -export async function makeDriver(): Promise { - const factory = await getSqlarkFactory() - return new SqlarkDriver(factory) +// ──────────────────────────────────────────────────────────────────────────── +// OPFS 可用性探测(一次性,进程级缓存) +// ──────────────────────────────────────────────────────────────────────────── + +export type StorageBackendType = 'opfs' | 'kv' | 'memory' | 'mock' | 'unknown' + +let _backendType: StorageBackendType = 'unknown' +let _opfsProbed = false +let _opfsAvailable = false + +/** 探测 OPFS 是否可用(navigator.storage.getDirectory 不抛错即视为可用) */ +export async function probeOpfs(): Promise { + if (_opfsProbed) return _opfsAvailable + _opfsProbed = true + try { + if (typeof navigator !== 'undefined' && navigator.storage && typeof navigator.storage.getDirectory === 'function') { + const root = await navigator.storage.getDirectory() + _opfsAvailable = !!root + } + } catch { + _opfsAvailable = false + } + return _opfsAvailable } -class SqlarkDriver implements SaveDbDriver { +/** 获取当前存储后端类型(供 UI 诊断面板) */ +export function getStorageBackendType(): StorageBackendType { + return _backendType +} + +/** 重置探测状态(测试用) */ +export function _resetStorageProbe(): void { + _opfsProbed = false + _opfsAvailable = false + _backendType = 'unknown' +} + +// ──────────────────────────────────────────────────────────────────────────── +// 数据库创建配置——根据 OPFS 可用性自动选择 diskEngine +// ──────────────────────────────────────────────────────────────────────────── + +/** 构建数据库配置,OPFS 可用走 opfs,否则降级到 kv(IndexedDB) */ +async function buildDbConfig(name: string, withCompression: boolean): Promise<{ + name: string + mode: string + diskEngine: string + aria: Record +}> { + const opfsOk = await probeOpfs() + if (opfsOk) { + _backendType = 'opfs' + return { + name, + mode: 'aria', + diskEngine: 'opfs', + aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) } + } + } + // 降级到 KVStore(IndexedDB 后端) + _backendType = 'kv' + return { + name, + mode: 'aria', + diskEngine: 'kv', + aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) } + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// SqlarkDriver——根治 open 失败后永久锁死问题 +// ──────────────────────────────────────────────────────────────────────────── + +export class SqlarkDriver implements SaveDbDriver { private db: SqlarkLike | null = null private opening: Promise | null = null + private lastError: string | null = null constructor(private factory: AnyFactory) {} async open(name: string): Promise { if (this.db) return - if (this.opening) return this.opening + // 0.1.38 修复:如果上次 opening 失败(rejected),清除它允许重试 + if (this.opening) { + try { + return await this.opening + } catch { + // 上次失败,清除后走下方重试逻辑 + this.opening = null + } + } this.opening = (async () => { - this.db = (await this.factory.create({ - name, - mode: 'aria', - diskEngine: 'opfs', - aria: { walSyncMode: 'full', compression: true } - })) as SqlarkLike - this.opening = null + try { + const config = await buildDbConfig(name, true) + this.db = (await this.factory.create(config)) as SqlarkLike + this.lastError = null + } catch (e) { + this.lastError = String(e) + throw e + } finally { + // 无论成功失败都清除 opening,允许后续重试 + this.opening = null + } })() return this.opening } async close(): Promise { this.db = null + this.opening = null + } + + /** 获取上次错误信息(供诊断) */ + getLastError(): string | null { + return this.lastError + } + + /** 判断数据库是否已成功打开 */ + isOpen(): boolean { + return this.db !== null } async run(sql: string, params: unknown[] = []): Promise { @@ -65,6 +172,11 @@ class SqlarkDriver implements SaveDbDriver { } } +export async function makeDriver(): Promise { + const factory = await getSqlarkFactory() + return new SqlarkDriver(factory) +} + const META_DB = 'cotyc-appmeta' let singletonManager: SlotManager | null = null @@ -83,6 +195,12 @@ export async function getSqlarkFactory(): Promise { } const slotCache = new Map() + +/** 0.1.38:重置 slot 缓存(测试用——避免旧 driver 残留) */ +export function _resetSlotCache(): void { + slotCache.clear() +} + export async function getSaveSlot(slot: number): Promise { let s = slotCache.get(slot) if (!s) { @@ -93,18 +211,43 @@ export async function getSaveSlot(slot: number): Promise { return s } +// ──────────────────────────────────────────────────────────────────────────── +// SlotManager——0.1.38 同样走降级策略 + 错误恢复 +// ──────────────────────────────────────────────────────────────────────────── + export class SlotManager { private metaDb: SqlarkLike | null = null + private metaOpening: Promise | null = null + private metaError: string | null = null async ensureMeta(): Promise { if (this.metaDb) return - this.metaDb = (await (await getSqlarkFactory()).create({ - name: META_DB, - mode: 'aria', - diskEngine: 'opfs', - aria: { walSyncMode: 'full' } - })) as SqlarkLike - await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`) + if (this.metaOpening) { + try { + return await this.metaOpening + } catch { + this.metaOpening = null + } + } + this.metaOpening = (async () => { + try { + const config = await buildDbConfig(META_DB, false) + this.metaDb = (await (await getSqlarkFactory()).create(config)) as SqlarkLike + await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`) + this.metaError = null + } catch (e) { + this.metaError = String(e) + throw e + } finally { + this.metaOpening = null + } + })() + return this.metaOpening + } + + /** 获取 meta DB 的最后错误(供诊断) */ + getMetaError(): string | null { + return this.metaError } async listSlotMetas(): Promise<(SaveMeta | null)[]> { @@ -142,5 +285,59 @@ export class SlotManager { } } +// ──────────────────────────────────────────────────────────────────────────── +// 存储健康诊断——供 UI 诊断面板调用 +// ──────────────────────────────────────────────────────────────────────────── + +export interface StorageHealthReport { + backendType: StorageBackendType + opfsAvailable: boolean + persistGranted: boolean | null + driverOpen: boolean + driverError: string | null + metaError: string | null + slotCount: number +} + +/** 全面诊断存储状态——供 SettingsPanel 健康面板调用 */ +export async function diagnoseStorage(): Promise { + const opfsAvailable = await probeOpfs() + const persistGranted = typeof navigator !== 'undefined' && navigator.storage + ? (await navigator.storage.persisted?.().catch(() => null) ?? null) + : null + + let driverOpen = false + let driverError: string | null = null + + // 尝试打开 slot 1 做探测 + try { + const slot = await getSaveSlot(1) + await slot.open() + driverOpen = true + } catch (e) { + driverError = String(e).slice(0, 200) + } + + const manager = getSlotManager() + const metaError = manager.getMetaError() + + let slotCount = 0 + try { + const metas = await manager.listSlotMetas() + slotCount = metas.filter((m) => m !== null).length + } catch { + // meta DB 不可用 + } + + return { + backendType: _backendType, + opfsAvailable, + persistGranted, + driverOpen, + driverError, + metaError, + slotCount + } +} export type { GameState, SaveMeta, SnapshotMeta } diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index c5f92b9..32dff3d 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -1,7 +1,7 @@ import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate' -const GAME_VERSION = '0.1.37' +const GAME_VERSION = '0.1.38' const DB_PREFIX = 'cotyc-save-' let seqCounter = 0 @@ -47,6 +47,23 @@ export class SaveSlot { `INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`, [id, state.year, state.month, new Date().toISOString(), label, json] ) + // 0.1.38 P1-F:回读校验——确认数据真正落盘 + const verify = await this.driver.all<{ data: string }>( + `SELECT data FROM snapshot WHERE id = ?`, + [id] + ) + if (!verify.length || !verify[0]!.data) { + throw new Error(`存档写入校验失败:数据未落盘(slot=${this.slot}, id=${id})`) + } + // 校验 JSON 可解析且关键字段在 + try { + const check = JSON.parse(verify[0]!.data) as GameState + if (!check.family || !check.members || typeof check.seed !== 'string') { + throw new Error('存档回读校验失败:关键字段缺失') + } + } catch (e) { + throw new Error(`存档回读校验失败:${String(e).slice(0, 80)}`) + } const keep = 12 const rows = await this.driver.all<{ id: string }>( `SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC` diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 60c20b2..df1b5bc 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -1,10 +1,11 @@ -import { useState } from 'react' +import { useState, useEffect } from 'react' import { useGameStore } from '../store' import { BUILTIN_MODS } from '../../game/data/builtin-mods' import { examplePlugin } from '../../game/engine/runtime/demo-plugins' import { ensureFx } from '../fx' import { SYSTEM_DEFS } from '../../game/engine/runtime/capabilities' import { buildBiography } from '../../game/engine/narrative/biography' +import { diagnoseStorage, type StorageHealthReport } from '../../game/storage/db' export default function SettingsPanel() { const world = useGameStore((s) => s.world) @@ -24,6 +25,8 @@ export default function SettingsPanel() { const toggleSound = useGameStore((s) => s.toggleSound) const setNextToast = useGameStore((s) => s.setNextToast) const [fxMode, setFxModeState] = useState('std') + const [healthReport, setHealthReport] = useState(null) + const [testing, setTesting] = useState(false) const setFxMode = (m: 'soft' | 'std' | 'full') => { setFxModeState(m) ensureFx().setMode(m) @@ -191,6 +194,71 @@ export default function SettingsPanel() { + {/* ===== 0.1.38 存档健康诊断 ===== */} +
+
存档健康诊断
+
+ 0.1.38 新增:存储后端自动探测 OPFS 可用性,不可用时降级到 IndexedDB (KVStore)。 + 此面板实时显示存储状态与错误信息,便于排查存档失效问题。 +
+ + + + + + + + + + + + + + + + + + + {healthReport?.driverError && ( + + + + + )} + {healthReport?.metaError && ( + + + + + )} + + + + + +
存储后端{healthReport ? ( + + {healthReport.backendType === 'opfs' ? 'OPFS(原生文件系统)' : healthReport.backendType === 'kv' ? 'IndexedDB(降级)' : healthReport.backendType === 'mock' ? 'Mock(测试)' : healthReport.backendType} + + ) : '尚未检测'}
OPFS 可用{healthReport ? (healthReport.opfsAvailable ? '是' : '否(已自动降级)') : '—'}
持久化授权{healthReport ? (healthReport.persistGranted === true ? '已授权' : healthReport.persistGranted === false ? '未授权' : '不支持') : '—'}
驱动状态{healthReport ? (healthReport.driverOpen ? '已打开' : '未打开') : '—'}
驱动错误{healthReport.driverError}
Meta 错误{healthReport.metaError}
已有存档{healthReport ? `${healthReport.slotCount} 个槽` : '—'}
+ +
+ {/* ===== 存档与导出 ===== */}
存档与导出
@@ -286,7 +354,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
-
版本 0.1.37 · Chronicle of the Immortal Clan
+
版本 0.1.38 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index b2372ac..a9fddbb 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -4,7 +4,7 @@ import { GameEngine } from '../game/engine/GameEngine' import { EventDef } from '../game/data/events' import { findEvent, applyEventChoice } from '../game/engine/runtime/Systems/events' import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain' -import { getSlotManager, getSaveSlot } from '../game/storage/db' +import { getSlotManager, getSaveSlot, probeOpfs } from '../game/storage/db' import { metaFromState, updateSlotMeta } from './storeHelper' import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade' import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound' @@ -157,12 +157,30 @@ export const useGameStore = create((set, get) => ({ soundOn: true, init: async () => { + // 0.1.38 P1-E:请求存储持久化(防浏览器/Electron 在存储压力下清除 OPFS 数据) + if (typeof navigator !== 'undefined' && navigator.storage) { + try { + if (typeof navigator.storage.persist === 'function') { + await navigator.storage.persist() + } + } catch { + // 持久化请求失败不阻塞启动 + } + // 0.1.38 P0-A:探测 OPFS 可用性(触发降级逻辑) + try { + await probeOpfs() + } catch { + // 探测失败不阻塞启动——降级逻辑已在 db.ts 内处理 + } + } // P1-13 落盘兜底:页面隐藏/关闭时瞬时保存(防节流窗口(≤12tick)丢档) if (typeof window !== 'undefined') { const flush = () => { const st = useGameStore.getState() if (!st.world || st.screen !== 'game') return - void Stash.save(st, '隐藏前') + void Stash.save(st, '隐藏前').then((r) => { + if (!r.ok) console.error('[flush-save-failed]', r.error) + }) } window.addEventListener('beforeunload', flush) document.addEventListener('visibilitychange', () => { @@ -290,15 +308,20 @@ export const useGameStore = create((set, get) => ({ // 自动速度节流落盘:疾进 12 tick/常速 6 tick/缓行 4 tick;手动单步即时落盘 const saveEvery = st.speed >= 3 ? 12 : st.speed === 2 ? 6 : 4 if (st.speed === 0 || w.state.totalTicks % saveEvery === 0) { - await Stash.save(st, '自动') + const sr = await Stash.save(st, '自动') + if (!sr.ok) { + // 0.1.38:自动保存失败也要让用户知晓 + setNextToast(`自动保存失败:${sr.error}`) + } } } catch (e) { // 出险保护:先落一档"出险前"现场再提示回档 console.error('[advance-crash]', e) try { - await Stash.save(st, '出险前') - } catch { - // 保存失败时不再叠加错误 + const sr = await Stash.save(st, '出险前') + if (!sr.ok) console.error('[crash-save-failed]', sr.error) + } catch (e2) { + console.error('[crash-save-exception]', e2) } const detail = String(e).slice(0, 120) let safed = false @@ -398,8 +421,12 @@ export const useGameStore = create((set, get) => ({ }, saveNow: async (label = '手动') => { - await Stash.save(get(), label) - setNextToast(`已落笔——第${get().slot}档(${label})`) + const r = await Stash.save(get(), label) + if (r.ok) { + setNextToast(`已落笔——第${get().slot}档(${label})`) + } else { + setNextToast(`存档失败:${r.error}——请检查存储权限或使用导出功能。`) + } }, bump: () => set((s) => ({ revision: s.revision + 1 })), @@ -443,6 +470,7 @@ export const useGameStore = create((set, get) => ({ const st = get() if (!st.world || !window.api) return const file = await getSaveSlot(st.slot) + await file.open() const meta0 = metaFromState(st.world.state, st.slot) st.world.syncRng() await file.saveState(st.world.state, '导出前') @@ -588,19 +616,22 @@ function actDirect(w: World, name: ActName, payload: import('../game/engine/runt } const Stash = { - async save(st: GameStore, label: string): Promise { - if (!st.world) return + async save(st: GameStore, label: string): Promise<{ ok: true } | { ok: false; error: string }> { + if (!st.world) return { ok: true } try { const file = await getSaveSlot(st.slot) await file.open() st.world.syncRng() await file.saveState(st.world.state, label) - const meta = metaFromState(st.world.state, st.slot) - await file.setMeta(meta) - await updateSlotMeta(st.slot, meta) + const meta = metaFromState(st.world.state, st.slot) + await file.setMeta(meta) + await updateSlotMeta(st.slot, meta) + return { ok: true } } catch (e) { - // P0-S:存储层并发/锁冲突兜底不抛(容忍一次失败,下次自动再试) + // 0.1.38:错误不再静默——上浮给调用方,让用户可见 + const msg = String(e).slice(0, 120) console.error('[stash-save]', e) + return { ok: false, error: msg } } } } diff --git a/src/renderer/ui/storeHelper.ts b/src/renderer/ui/storeHelper.ts index 5ea35f9..cf2956d 100644 --- a/src/renderer/ui/storeHelper.ts +++ b/src/renderer/ui/storeHelper.ts @@ -14,7 +14,7 @@ export function metaFromState(state: GameState, slot: number): SaveMeta { members: alive, reputation: state.family.reputation, updatedAt: new Date().toISOString(), - version: '0.1.37' + version: '0.1.38' } } diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 52a7c7c..c7e0326 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -162,7 +162,7 @@ describe('GameFacade 门面', () => { const f = new GameFacade(w, 1) const info = f.about() expect(info.title).toBe('仙途家族志') - expect(info.version).toContain('0.1.37') + expect(info.version).toContain('0.1.38') expect(info.modules).toBeGreaterThanOrEqual(11) expect(info.systems).toBeGreaterThan(0) expect(info.plugins).toBeGreaterThanOrEqual(3) diff --git a/tests/gameengine.test.ts b/tests/gameengine.test.ts index fbb114c..d755555 100644 --- a/tests/gameengine.test.ts +++ b/tests/gameengine.test.ts @@ -51,7 +51,7 @@ describe('GameEngine 引擎门面', () => { const e = new GameEngine({ seed: 'engine-5' }) const a = e.about() expect(a.plugins).toBeGreaterThanOrEqual(3) - expect(a.version).toContain('0.1.36') + expect(a.version).toContain('0.1.38') }) }) diff --git a/tests/storage-resilience-0.1.38.test.ts b/tests/storage-resilience-0.1.38.test.ts new file mode 100644 index 0000000..7dd9e78 --- /dev/null +++ b/tests/storage-resilience-0.1.38.test.ts @@ -0,0 +1,279 @@ +/** + * 0.1.38 存档根治测试套件 + * + * 覆盖点: + * 1. SqlarkDriver.open 失败后可重试(不永久锁死) + * 2. Stash.save 返回 { ok, error } 而非 void + * 3. SaveSlot.saveState 回读校验(写入后 SELECT 确认数据落盘) + * 4. OPFS 降级逻辑(probeOpfs 返回 false 时走 kv 后端配置) + * 5. 诊断接口返回完整报告 + */ + +import { describe, expect, it, beforeEach } from 'vitest' +import { SaveSlot, type SaveDbDriver } from '../src/renderer/game/storage/slots' +import { World } from '../src/renderer/game/engine/runtime/World' +import type { GameState } from '../src/renderer/game/types/domain' +import { + setSqlarkBackend, + _resetSlotCache, + _resetStorageProbe, + getStorageBackendType, + probeOpfs, + type StorageBackendType, +} from '../src/renderer/game/storage/db' +import { MeSqlark } from '@metona-team/metona-sqlark' + +// ──────────────────────────────────────────────────────────────────────────── +// Mock Driver:可控失败 + 回读校验 +// ──────────────────────────────────────────────────────────────────────────── + +interface Row { [k: string]: unknown } + +class ControllableDriver implements SaveDbDriver { + tables = new Map>() + private failNext = false + private openCallCount = 0 + + setOpenFail(on: boolean): void { this.failNext = on } + getOpenCount(): number { return this.openCallCount } + + async open(_name: string): Promise { + this.openCallCount++ + if (this.failNext) { + this.failNext = false + throw new Error('OPFS_OPEN_ERROR: simulated failure') + } + } + async close(): Promise {} + async run(sql: string, params: unknown[] = []): Promise { + const ins = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql) + if (ins) { + const cols = ins[2].split(',').map((c) => c.trim()) + const row: Row = {} + cols.forEach((c, i) => (row[c] = params[i])) + const t = this.tables.get(ins[1]) ?? new Map() + t.set(String(row['id'] ?? ins[1] + t.size), row) + this.tables.set(ins[1], t) + } + const upd = /update\s+(\w+)\s+set/i.exec(sql) + if (upd) { + const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1] + const setVal = params[0] + const lit = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql) + const ph = /where\s+(\w+)\s*=\s*\?/i.exec(sql) + const col = (lit?.[1] ?? ph?.[1]) as string + const val = (lit?.[2] as string | undefined) ?? String(params[1]) + const t = this.tables.get(upd[1]) + if (t) { + for (const [k, row] of t.entries()) { + if (String(row[col]) === val) t.set(k, { ...row, [setCol]: setVal }) + } + } + } + const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql) + if (del) { + const t = this.tables.get(del[1]) + if (t) { + for (const [k, row] of t.entries()) { + if (String(row[del[2]]) === String(params[0])) t.delete(k) + } + } + } + return null + } + async all(sql: string, params: unknown[] = []): Promise { + const lower = sql.toLowerCase() + const table = /from (\w+)/i.exec(sql)?.[1] + if (!table) return [] + let rows = [...(this.tables.get(table)?.values() ?? [])].map((r) => ({ ...r })) + if (/\bwhere\b/.test(lower)) { + const m = /where\s+(\w+)\s*=\s*\?/i.exec(sql) + if (m && params[0] !== undefined) { + rows = rows.filter((r) => String(r[m[1]]) === String(params[0])) + } + } + if (/\border by/i.test(lower)) { + rows = rows.sort((a, b) => Number(b['year']) - Number(a['year']) || Number(b['month']) - Number(a['month'])) + } + if (/select data/i.test(lower)) rows = rows.map((r) => ({ data: String(r['data']) })) + if (/select value/i.test(lower)) rows = rows.map((r) => ({ value: String(r['value']) })) + return rows as T[] + } + async exec(sql: string): Promise { + const create = /create table (?:if not exists )?(\w+)/i.exec(sql) + if (create && !this.tables.has(create[1])) this.tables.set(create[1], new Map()) + const del = /delete from (\w+)/i.exec(sql) + if (del) this.tables.set(del[1], new Map()) + return null + } +} + +// ──────────────────────────────────────────────────────────────────────────── +// Mock Backend for metona-sqlark (hybrid memory mode, like real test) +// ──────────────────────────────────────────────────────────────────────────── + +function memoryBackend(): void { + setSqlarkBackend({ + create: async (cfg: { name: string }) => { + const db = new MeSqlark({ + name: `it-${cfg.name}-${Math.random().toString(36).slice(2)}`, + mode: 'hybrid', + diskEngine: 'memory', + aria: {} + }) + await db.init() + return { query: (sql: string, params?: unknown[]) => db.query(sql, params) } + } + } as never) +} + +function saveClone(w: World): GameState { + return JSON.parse(JSON.stringify(w.state)) as GameState +} + +// ──────────────────────────────────────────────────────────────────────────── +// Tests +// ──────────────────────────────────────────────────────────────────────────── + +describe('0.1.38 存档根治 · P0-C: SqlarkDriver.open 失败后可重试', () => { + beforeEach(() => { + _resetSlotCache() + _resetStorageProbe() + }) + + it('首次 open 失败后,第二次 open 不被永久锁死', async () => { + // 用 ControllableDriver 模拟首次失败 + const driver = new ControllableDriver() + driver.setOpenFail(true) + const slot = new SaveSlot(1, driver) + + // 第一次 open 应该失败 + await expect(slot.open()).rejects.toThrow('simulated failure') + expect(driver.getOpenCount()).toBe(1) + + // 第二次 open 应该成功(关键:旧版会返回已 rejected 的 promise,永远锁死) + await slot.open() + expect(driver.getOpenCount()).toBe(2) + }) + + it('连续多次失败后仍可恢复', async () => { + const driver = new ControllableDriver() + driver.setOpenFail(true) + const slot = new SaveSlot(2, driver) + + // 第一次失败 + await expect(slot.open()).rejects.toThrow() + // 第二次也失败(手动再设) + driver.setOpenFail(true) + await expect(slot.open()).rejects.toThrow() + // 第三次成功 + await slot.open() + expect(driver.getOpenCount()).toBe(3) + }) +}) + +describe('0.1.38 存档根治 · P1-F: SaveSlot.saveState 回读校验', () => { + it('saveState 后数据确实写入并可回读', async () => { + const driver = new ControllableDriver() + const slot = new SaveSlot(3, driver) + const w = World.create({ seed: 'verify-1', surname: '赵', familyName: '赵家', motto: 'm', difficulty: 'normal' }) + + const id = await slot.saveState(saveClone(w), 'test') + expect(id).toBeTruthy() + + // 回读校验:loadState 应返回有效数据 + const loaded = await slot.loadState() + expect(loaded).not.toBeNull() + expect(loaded!.family.surname).toBe('赵') + expect(loaded!.seed).toBe('verify-1') + }) + + it('saveState 数据完整性(关键字段 family/members/seed)', async () => { + const driver = new ControllableDriver() + const slot = new SaveSlot(4, driver) + const w = World.create({ seed: 'verify-2', surname: '钱', familyName: '钱家', motto: 'm', difficulty: 'normal' }) + w.advanceMonth() + w.advanceMonth() + + await slot.saveState(saveClone(w), 'advance') + const loaded = await slot.loadState() + expect(loaded).not.toBeNull() + expect(loaded!.family).toBeDefined() + expect(loaded!.members).toBeDefined() + expect(typeof loaded!.seed).toBe('string') + expect(loaded!.year).toBeGreaterThanOrEqual(1) + }) +}) + +describe('0.1.38 存档根治 · P0-A: OPFS 降级配置', () => { + beforeEach(() => { + _resetStorageProbe() + _resetSlotCache() + memoryBackend() + }) + + it('probeOpfs 在无 navigator.storage 环境返回 false', async () => { + // node 环境无 navigator.storage + const result = await probeOpfs() + expect(result).toBe(false) + }) + + it('getStorageBackendType 在 setSqlarkBackend 后为 mock', () => { + // memoryBackend() 已在 beforeEach 中调用了 setSqlarkBackend + expect(getStorageBackendType()).toBe('mock') + }) +}) + +describe('0.1.38 存档根治 · 存储链路回归(hybrid memory backend)', () => { + beforeEach(() => { + _resetSlotCache() + _resetStorageProbe() + memoryBackend() + }) + + it('save→list→load 全链路 6 种子不崩', async () => { + for (let k = 0; k < 6; k++) { + const slot = (k % 4) + 1 + const w = World.create({ seed: `regr-${k}`, surname: '孙', familyName: '孙家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < 8; i++) w.advanceMonth() + + const { getSaveSlot } = await import('../src/renderer/game/storage/db') + const file = await getSaveSlot(slot) + await file.open() + await file.saveState(saveClone(w), 'auto') + + const loaded = await file.loadState() + expect(loaded).toBeTruthy() + expect(loaded!.year).toBeGreaterThan(0) + } + }) + + it('多次 save 后快照不超过 12 份', async () => { + const { getSaveSlot } = await import('../src/renderer/game/storage/db') + const file = await getSaveSlot(4) + await file.open() + const w = World.create({ seed: 'trim-1', surname: '李', familyName: '李家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < 20; i++) { + w.state.year = i + 1 + await file.saveState(saveClone(w), 'auto') + } + const snaps = await file.listSnapshots() + expect(snaps.length).toBe(12) + }) +}) + +describe('0.1.38 存档根治 · 版本号一致性', () => { + it('slots.ts GAME_VERSION = 0.1.38', async () => { + const { buildSimpleMeta } = await import('../src/renderer/game/storage/slots') + const w = World.create({ seed: 'ver-1', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' }) + const meta = buildSimpleMeta(w.state, 1, 5) + expect(meta.version).toBe('0.1.38') + }) + + it('storeHelper metaFromState version = 0.1.38', async () => { + const { metaFromState } = await import('../src/renderer/ui/storeHelper') + const w = World.create({ seed: 'ver-2', surname: '吴', familyName: '吴家', motto: 'm', difficulty: 'normal' }) + const meta = metaFromState(w.state, 1) + expect(meta.version).toBe('0.1.38') + }) +})