0.1.38 存档根治·石碑重刻:OPFS降级+错误上浮+回读校验+持久化请求+诊断面板+driver.open锁死修复+exportSave补open
This commit is contained in:
@@ -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)。
|
||||
|
||||
## 命令
|
||||
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
+215
-18
@@ -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<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,否则降级到 kv(IndexedDB) */
|
||||
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 } : {}) }
|
||||
}
|
||||
}
|
||||
// 降级到 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<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
|
||||
this.opening = (async () => {
|
||||
this.db = (await this.factory.create({
|
||||
name,
|
||||
mode: 'aria',
|
||||
diskEngine: 'opfs',
|
||||
aria: { walSyncMode: 'full', compression: true }
|
||||
})) as SqlarkLike
|
||||
// 0.1.38 修复:如果上次 opening 失败(rejected),清除它允许重试
|
||||
if (this.opening) {
|
||||
try {
|
||||
return await this.opening
|
||||
} catch {
|
||||
// 上次失败,清除后走下方重试逻辑
|
||||
this.opening = null
|
||||
}
|
||||
}
|
||||
this.opening = (async () => {
|
||||
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
|
||||
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 }
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
+41
-10
@@ -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)
|
||||
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,8 +616,8 @@ 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()
|
||||
@@ -598,9 +626,12 @@ const Stash = {
|
||||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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<string, Map<string, Row>>()
|
||||
private failNext = false
|
||||
private openCallCount = 0
|
||||
|
||||
setOpenFail(on: boolean): void { this.failNext = on }
|
||||
getOpenCount(): number { return this.openCallCount }
|
||||
|
||||
async open(_name: string): Promise<void> {
|
||||
this.openCallCount++
|
||||
if (this.failNext) {
|
||||
this.failNext = false
|
||||
throw new Error('OPFS_OPEN_ERROR: simulated failure')
|
||||
}
|
||||
}
|
||||
async close(): Promise<void> {}
|
||||
async run(sql: string, params: unknown[] = []): Promise<unknown> {
|
||||
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<string, Row>()
|
||||
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<T>(sql: string, params: unknown[] = []): Promise<T[]> {
|
||||
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<unknown> {
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user