0.1.38 存档根治·石碑重刻:OPFS降级+错误上浮+回读校验+持久化请求+诊断面板+driver.open锁死修复+exportSave补open

This commit is contained in:
2026-09-13 08:02:04 +08:00
parent 8f1f8d48ce
commit 5030eb04d2
11 changed files with 634 additions and 42 deletions
@@ -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,
+216 -19
View File
@@ -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 可用性,不可用则自动降级到 KVStoreIndexedDB
// · 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<SaveDbDriver> {
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<boolean> {
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,否则降级到 kvIndexedDB */
async function buildDbConfig(name: string, withCompression: boolean): Promise<{
name: string
mode: string
diskEngine: string
aria: Record<string, unknown>
}> {
const opfsOk = await probeOpfs()
if (opfsOk) {
_backendType = 'opfs'
return {
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', ...(withCompression ? { compression: true } : {}) }
}
}
// 降级到 KVStoreIndexedDB 后端)
_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<void> | null = null
private lastError: string | null = null
constructor(private factory: AnyFactory) {}
async open(name: string): Promise<void> {
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<void> {
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<unknown> {
@@ -65,6 +172,11 @@ class SqlarkDriver implements SaveDbDriver {
}
}
export async function makeDriver(): Promise<SaveDbDriver> {
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<AnyFactory> {
}
const slotCache = new Map<number, SaveSlot>()
/** 0.1.38:重置 slot 缓存(测试用——避免旧 driver 残留) */
export function _resetSlotCache(): void {
slotCache.clear()
}
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
let s = slotCache.get(slot)
if (!s) {
@@ -93,18 +211,43 @@ export async function getSaveSlot(slot: number): Promise<SaveSlot> {
return s
}
// ────────────────────────────────────────────────────────────────────────────
// SlotManager——0.1.38 同样走降级策略 + 错误恢复
// ────────────────────────────────────────────────────────────────────────────
export class SlotManager {
private metaDb: SqlarkLike | null = null
private metaOpening: Promise<void> | null = null
private metaError: string | null = null
async ensureMeta(): Promise<void> {
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<StorageHealthReport> {
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 }
+18 -1
View File
@@ -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`
+70 -2
View File
@@ -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<StorageHealthReport | null>(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() {
</div>
</div>
{/* ===== 0.1.38 存档健康诊断 ===== */}
<div className="card settings-card">
<div className="card-title"></div>
<div className="help-text">
0.1.38 OPFS IndexedDB (KVStore)
便
</div>
<table className="settings-table">
<tbody>
<tr>
<td></td>
<td>{healthReport ? (
<span className={healthReport.backendType === 'opfs' ? 'gold' : healthReport.backendType === 'kv' ? '' : 'bad'}>
{healthReport.backendType === 'opfs' ? 'OPFS(原生文件系统)' : healthReport.backendType === 'kv' ? 'IndexedDB(降级)' : healthReport.backendType === 'mock' ? 'Mock(测试)' : healthReport.backendType}
</span>
) : '尚未检测'}</td>
</tr>
<tr>
<td>OPFS </td>
<td>{healthReport ? (healthReport.opfsAvailable ? '是' : '否(已自动降级)') : '—'}</td>
</tr>
<tr>
<td></td>
<td>{healthReport ? (healthReport.persistGranted === true ? '已授权' : healthReport.persistGranted === false ? '未授权' : '不支持') : '—'}</td>
</tr>
<tr>
<td></td>
<td>{healthReport ? (healthReport.driverOpen ? '已打开' : '未打开') : '—'}</td>
</tr>
{healthReport?.driverError && (
<tr>
<td></td>
<td className="bad">{healthReport.driverError}</td>
</tr>
)}
{healthReport?.metaError && (
<tr>
<td>Meta </td>
<td className="bad">{healthReport.metaError}</td>
</tr>
)}
<tr>
<td></td>
<td>{healthReport ? `${healthReport.slotCount} 个槽` : '—'}</td>
</tr>
</tbody>
</table>
<button
className="btn btn-sm"
disabled={testing}
onClick={async () => {
setTesting(true)
try {
const r = await diagnoseStorage()
setHealthReport(r)
setNextToast(r.driverOpen ? `诊断完成:${r.backendType} 后端可用。` : `诊断失败:${r.driverError ?? '未知错误'}`)
} catch (e) {
setNextToast(`诊断异常:${String(e).slice(0, 80)}`)
} finally {
setTesting(false)
}
}}
>{testing ? '诊断中…' : '运行诊断'}</button>
</div>
{/* ===== 存档与导出 ===== */}
<div className="card settings-card">
<div className="card-title"></div>
@@ -286,7 +354,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.37 · Chronicle of the Immortal Clan</div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.38 · Chronicle of the Immortal Clan</div>
</div>
</div>
)
+45 -14
View File
@@ -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<GameStore>((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<GameStore>((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<GameStore>((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<GameStore>((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<void> {
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 }
}
}
}
+1 -1
View File
@@ -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'
}
}