Compare commits

..
3 Commits
12 changed files with 991 additions and 41 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 仙途家族志 · 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
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.37", "version": "0.1.38",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
@@ -137,7 +137,7 @@ export class GameFacade {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return { return {
title: '仙途家族志', title: '仙途家族志',
version: '0.1.37', version: '0.1.38',
modules: this.world.systemList().length, modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length, systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length, plugins: this.world.pluginList().length,
+301 -19
View File
@@ -1,6 +1,24 @@
import { SaveSlot } from './slots' import { SaveSlot } from './slots'
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain' import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
import type { SaveDbDriver } from './slots' import type { SaveDbDriver } from './slots'
import { IndexedDBBackend } from './idb-backend'
// ────────────────────────────────────────────────────────────────────────────
// 0.1.38 存档根治(最终版):IndexedDB 持久化后端注入
//
// 根因分析(上一版修复遗留问题):
// 0.1.38 初版探测 OPFS → 不可用时降级 diskEngine='kv' → AriaEngine 内部
// new KVStoreBackend() 不传 medium → KVStore.defaultMedium() 在 OPFS 不可
// 用时返回 SharedMemoryBackend(纯内存!)→ 数据写入内存 → 进程关闭全丢。
// 即使 OPFS "假可用"getDirectory 不抛错但数据不持久化),也有同样问题。
//
// 最终方案:
// · 自建 IndexedDBBackendidb-backend.ts),用原生 IndexedDB API 持久化
// · MetonaSqlark.create 成功后,通过反射链替换底层 KVStore.medium
// · 替换后 reload() 重新从 IndexedDB 加载数据
// · 无论 OPFS 是否可用,数据始终写入 IndexedDB(跨进程持久化)
// · 保留 OPFS 探测和诊断能力供 UI 展示
// ────────────────────────────────────────────────────────────────────────────
// Adapter for @metona-team/metona-sqlark // Adapter for @metona-team/metona-sqlark
// The driver interface keeps the engine swappable (testable in node too). // The driver interface keeps the engine swappable (testable in node too).
@@ -14,36 +32,205 @@ interface SqlarkLike {
export function setSqlarkBackend(impl: AnyFactory): void { export function setSqlarkBackend(impl: AnyFactory): void {
MetonaSqlark = impl MetonaSqlark = impl
_backendType = 'mock'
} }
export async function makeDriver(): Promise<SaveDbDriver> { // ────────────────────────────────────────────────────────────────────────────
const factory = await getSqlarkFactory() // OPFS 可用性探测(一次性,进程级缓存)
return new SqlarkDriver(factory) // ────────────────────────────────────────────────────────────────────────────
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
// ────────────────────────────────────────────────────────────────────────────
/** 构建数据库配置——始终用 hybrid 模式(KVStore 后端),后续注入 IndexedDB medium */
async function buildDbConfig(name: string, _withCompression: boolean): Promise<{
name: string
mode: string
diskEngine: string
}> {
// 探测 OPFS 供诊断面板展示(不影响实际后端选择——始终注入 IndexedDB)
await probeOpfs()
_backendType = 'kv'
return {
name,
mode: 'hybrid',
diskEngine: 'kv',
}
}
// ────────────────────────────────────────────────────────────────────────────
// IndexedDB 后端注入——通过反射链替换底层 KVStore.medium
// ────────────────────────────────────────────────────────────────────────────
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Reflective = Record<string, any>
/**
* 通过反射链找到并替换 MetonaSqlark 实例底层 KVStore 的 medium。
*
* 反射路径(两种模式):
* · mode='aria': instance.engine.backend → KVStoreBackend.kv.medium
* instance.engine.backend → OPFSBackend (直接替换 this.backend)
* · mode='hybrid': instance.engine.diskEngine.kv.medium
*
* 替换后重新 open KVStore 并 reload HybridEngine 内存缓存。
*/
async function injectIndexedDBBackend(instance: SqlarkLike & { engine?: unknown }, dbName: string): Promise<void> {
// 测试环境用 mock backendsetSqlarkBackend),没有 engine 属性——跳过
const engine = (instance as Reflective).engine as Reflective | undefined
if (!engine) return
try {
// 尝试 mode='hybrid' 路径:engine.diskEngine.kv.medium
const diskEngine = engine.diskEngine as Reflective | undefined
if (diskEngine?.kv) {
const kv = diskEngine.kv as Reflective
const oldMedium = kv.medium as Reflective | undefined
const oldName = oldMedium?.constructor?.name ?? ''
// 无论 OPFS 还是 SharedMemory,都替换为 IndexedDB
// OPFS 在 Electron app:// 下可能"假可用"(不抛错但不持久化)
if (oldName !== 'IndexedDBBackend') {
const idbBackend = new IndexedDBBackend()
// 先打开新的 IndexedDB backendKVStore.reload 会再次调 open,幂等)
await idbBackend.open(dbName)
// 直接替换 medium(不先关闭旧的——kv.reload 内部会先排空 opQueue 再 open 新 medium
kv.medium = idbBackend
// 重新加载 KVStore 从新 medium 读取数据
if (typeof kv.reload === 'function') {
await kv.reload()
}
// 让 HybridEngine 从磁盘重新加载到内存
if (typeof engine.reloadMemoryFromDisk === 'function') {
await engine.reloadMemoryFromDisk()
}
_backendType = 'kv'
}
return
}
// 尝试 mode='aria' 路径:engine.backend
const ariaBackend = engine.backend as Reflective | undefined
if (ariaBackend) {
const backendName = ariaBackend.constructor?.name ?? ''
// 如果是 KVStoreBackend,走 kv.medium 路径
if (backendName === 'KVStoreBackend' && ariaBackend.kv) {
const kv = ariaBackend.kv as Reflective
const oldMedium = kv.medium as Reflective | undefined
const oldName = oldMedium?.constructor?.name ?? ''
if (oldName !== 'IndexedDBBackend') {
const idbBackend = new IndexedDBBackend()
await idbBackend.open(dbName)
try { await oldMedium?.close?.() } catch { /* ignore */ }
kv.medium = idbBackend
if (typeof kv.reload === 'function') {
await kv.reload()
}
_backendType = 'kv'
}
return
}
// 如果是 OPFSBackend,直接替换 engine.backend
// 但 AriaEngine 内部有大量组件引用 backendFileManager 等),直接替换不安全
// → 跳过,让 OPFS 自己工作(如果 OPFS 确实持久化就没问题)
// 如果 OPFS 不持久化,应该走 buildDbConfig 的 kv 降级路径
}
} catch {
// 反射失败(可能是 mock/test 环境)——静默跳过
}
}
// ────────────────────────────────────────────────────────────────────────────
// SqlarkDriver——根治 open 失败后永久锁死问题
// ────────────────────────────────────────────────────────────────────────────
export class SqlarkDriver implements SaveDbDriver {
private db: SqlarkLike | null = null private db: SqlarkLike | null = null
private opening: Promise<void> | null = null private opening: Promise<void> | null = null
private lastError: string | null = null
constructor(private factory: AnyFactory) {} constructor(private factory: AnyFactory) {}
async open(name: string): Promise<void> { async open(name: string): Promise<void> {
if (this.db) return 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.opening = (async () => {
this.db = (await this.factory.create({ try {
name, const config = await buildDbConfig(name, true)
mode: 'aria', const instance = (await this.factory.create(config)) as SqlarkLike & {
diskEngine: 'opfs', engine?: unknown
aria: { walSyncMode: 'full', compression: true } }
})) as SqlarkLike // 0.1.38 最终方案:注入 IndexedDB 持久化后端
this.opening = null // 无论 OPFS 是否可用,都替换为 IndexedDBBackend 确保数据跨进程持久化
await injectIndexedDBBackend(instance, name)
this.db = instance
this.lastError = null
} catch (e) {
this.lastError = String(e)
throw e
} finally {
// 无论成功失败都清除 opening,允许后续重试
this.opening = null
}
})() })()
return this.opening return this.opening
} }
async close(): Promise<void> { async close(): Promise<void> {
this.db = null 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> { async run(sql: string, params: unknown[] = []): Promise<unknown> {
@@ -65,6 +252,11 @@ class SqlarkDriver implements SaveDbDriver {
} }
} }
export async function makeDriver(): Promise<SaveDbDriver> {
const factory = await getSqlarkFactory()
return new SqlarkDriver(factory)
}
const META_DB = 'cotyc-appmeta' const META_DB = 'cotyc-appmeta'
let singletonManager: SlotManager | null = null let singletonManager: SlotManager | null = null
@@ -83,6 +275,12 @@ export async function getSqlarkFactory(): Promise<AnyFactory> {
} }
const slotCache = new Map<number, SaveSlot>() 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> { export async function getSaveSlot(slot: number): Promise<SaveSlot> {
let s = slotCache.get(slot) let s = slotCache.get(slot)
if (!s) { if (!s) {
@@ -93,18 +291,48 @@ export async function getSaveSlot(slot: number): Promise<SaveSlot> {
return s return s
} }
// ────────────────────────────────────────────────────────────────────────────
// SlotManager——0.1.38 同样走降级策略 + 错误恢复
// ────────────────────────────────────────────────────────────────────────────
export class SlotManager { export class SlotManager {
private metaDb: SqlarkLike | null = null private metaDb: SqlarkLike | null = null
private metaOpening: Promise<void> | null = null
private metaError: string | null = null
async ensureMeta(): Promise<void> { async ensureMeta(): Promise<void> {
if (this.metaDb) return if (this.metaDb) return
this.metaDb = (await (await getSqlarkFactory()).create({ if (this.metaOpening) {
name: META_DB, try {
mode: 'aria', return await this.metaOpening
diskEngine: 'opfs', } catch {
aria: { walSyncMode: 'full' } this.metaOpening = null
})) as SqlarkLike }
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`) }
this.metaOpening = (async () => {
try {
const config = await buildDbConfig(META_DB, false)
const instance = (await (await getSqlarkFactory()).create(config)) as SqlarkLike & {
engine?: unknown
}
// 注入 IndexedDB 持久化后端
await injectIndexedDBBackend(instance, META_DB)
this.metaDb = instance
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)[]> { async listSlotMetas(): Promise<(SaveMeta | null)[]> {
@@ -142,5 +370,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 } export type { GameState, SaveMeta, SnapshotMeta }
+274
View File
@@ -0,0 +1,274 @@
// ────────────────────────────────────────────────────────────────────────────
// IndexedDB Storage Backend — 自建 IStorageBackend 实现
//
// 0.1.38 根治方案:
// MetonaSqlark 的 KVStore 在 OPFS 不可用时 fallback 到 SharedMemoryBackend(纯内存),
// 进程关闭后数据全部丢失。此模块用原生 IndexedDB API 实现 IStorageBackend 接口,
// 确保数据真正持久化到浏览器/Electron 的 IndexedDB 中。
//
// 设计要点:
// · 每个 key 对应一条 IndexedDB 记录(value 为 ArrayBuffer
// · open 时创建 object store(如不存在)
// · 所有写操作通过单条 IDB 事务保证原子性
// · writeMany 用单事务多 put 保证原子批量写入
// · append 用读-改-写(单事务内 read+write 保证原子性)
// · close 不删数据(持久化语义——重开同名库可读到上次数据)
// ────────────────────────────────────────────────────────────────────────────
/** IStorageBackend 最小接口(与 metona-sqlark 的 IStorageBackend 对齐) */
export interface IStorageBackendLike {
open(name: string): Promise<void>
close(): Promise<void>
isOpen(): boolean
read(key: string): Promise<ArrayBuffer | null>
write(key: string, data: ArrayBuffer): Promise<void>
append?(key: string, data: ArrayBuffer): Promise<void>
writeMany(entries: Record<string, ArrayBuffer>): Promise<void>
delete(key: string): Promise<void>
deleteMany(keys: string[]): Promise<void>
listKeys(): Promise<string[]>
exists(key: string): Promise<boolean>
clear(): Promise<void>
}
const STORE_NAME = 'kv'
const DB_PREFIX = 'cotyc-idb-'
/**
* 基于 IndexedDB 的持久化存储后端。
*
* 用法:传入 KVStoreBackend 或 KVStore 构造函数作为 medium 参数,
* 替代 OPFSBackend / SharedMemoryBackend。
*/
export class IndexedDBBackend implements IStorageBackendLike {
private dbName = ''
private db: IDBDatabase | null = null
private writeQueue: Promise<void> = Promise.resolve()
async open(name: string): Promise<void> {
// 幂等:如果已打开同名库,直接返回(KVStore.reload 会二次调 open
if (this.db && this.dbName === name) return
// 如果打开了不同名的库,先关闭旧的
if (this.db) {
this.db.close()
this.db = null
}
this.dbName = name
const fullDbName = DB_PREFIX + name
return new Promise((resolve, reject) => {
const req = indexedDB.open(fullDbName, 1)
req.onupgradeneeded = () => {
const db = req.result
if (!db.objectStoreNames.contains(STORE_NAME)) {
db.createObjectStore(STORE_NAME)
}
}
req.onsuccess = () => {
this.db = req.result
resolve()
}
req.onerror = () => {
reject(new Error(`IndexedDB open failed: ${req.error?.message ?? 'unknown'}`))
}
req.onblocked = () => {
reject(new Error('IndexedDB open blocked (another tab holding connection)'))
}
})
}
async close(): Promise<void> {
// 等待写队列排空
try { await this.writeQueue } catch { /* ignore */ }
if (this.db) {
this.db.close()
this.db = null
}
}
isOpen(): boolean {
return this.db !== null
}
async read(key: string): Promise<ArrayBuffer | null> {
if (!this.db) return null
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const req = store.get(key)
req.onsuccess = () => {
const result = req.result
if (result === undefined) {
resolve(null)
} else if (result instanceof ArrayBuffer) {
resolve(result)
} else if (result instanceof Uint8Array) {
// 某些浏览器可能返回 Uint8Array
resolve(result.buffer as ArrayBuffer)
} else {
// 兜底:尝试作为 ArrayBuffer 处理
resolve(result as ArrayBuffer)
}
}
req.onerror = () => reject(req.error ?? new Error(`IDB read failed: ${key}`))
})
}
async write(key: string, data: ArrayBuffer): Promise<void> {
const run = this.writeQueue.then(() => this._writeRaw(key, data))
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _writeRaw(key: string, data: ArrayBuffer): Promise<void> {
if (!this.db) return Promise.resolve()
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.put(data, key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error(`IDB write failed: ${key}`))
tx.onabort = () => reject(tx.error ?? new Error(`IDB write aborted: ${key}`))
})
}
async append(key: string, data: ArrayBuffer): Promise<void> {
if (!this.db) return
const run = this.writeQueue.then(() => this._appendRaw(key, data))
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _appendRaw(key: string, data: ArrayBuffer): Promise<void> {
if (!this.db) return Promise.resolve()
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
const getReq = store.get(key)
getReq.onsuccess = () => {
const existing = getReq.result
let combined: ArrayBuffer
if (existing instanceof ArrayBuffer) {
const merged = new Uint8Array(existing.byteLength + data.byteLength)
merged.set(new Uint8Array(existing), 0)
merged.set(new Uint8Array(data), existing.byteLength)
combined = merged.buffer as ArrayBuffer
} else if (existing instanceof Uint8Array) {
const merged = new Uint8Array(existing.byteLength + data.byteLength)
merged.set(existing, 0)
merged.set(new Uint8Array(data), existing.byteLength)
combined = merged.buffer as ArrayBuffer
} else if (existing !== undefined) {
combined = existing as ArrayBuffer
} else {
combined = data
}
store.put(combined, key)
}
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error(`IDB append failed: ${key}`))
tx.onabort = () => reject(tx.error ?? new Error(`IDB append aborted: ${key}`))
})
}
async writeMany(entries: Record<string, ArrayBuffer>): Promise<void> {
if (!this.db || Object.keys(entries).length === 0) return
const run = this.writeQueue.then(() => this._writeManyRaw(entries))
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _writeManyRaw(entries: Record<string, ArrayBuffer>): Promise<void> {
if (!this.db) return Promise.resolve()
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
for (const [key, data] of Object.entries(entries)) {
store.put(data, key)
}
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IDB writeMany failed'))
tx.onabort = () => reject(tx.error ?? new Error('IDB writeMany aborted'))
})
}
async delete(key: string): Promise<void> {
if (!this.db) return
const run = this.writeQueue.then(() => this._deleteRaw(key))
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _deleteRaw(key: string): Promise<void> {
if (!this.db) return Promise.resolve()
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.delete(key)
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error(`IDB delete failed: ${key}`))
})
}
async deleteMany(keys: string[]): Promise<void> {
if (!this.db || keys.length === 0) return
const run = this.writeQueue.then(() => this._deleteManyRaw(keys))
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _deleteManyRaw(keys: string[]): Promise<void> {
if (!this.db) return Promise.resolve()
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
for (const key of keys) {
store.delete(key)
}
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IDB deleteMany failed'))
})
}
async listKeys(): Promise<string[]> {
if (!this.db) return []
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const req = store.getAllKeys()
req.onsuccess = () => {
const keys = req.result as IDBValidKey[]
resolve(keys.map((k) => String(k)))
}
req.onerror = () => reject(req.error ?? new Error('IDB listKeys failed'))
})
}
async exists(key: string): Promise<boolean> {
if (!this.db) return false
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readonly')
const store = tx.objectStore(STORE_NAME)
const req = store.count(key)
req.onsuccess = () => resolve(req.result > 0)
req.onerror = () => reject(req.error ?? new Error(`IDB exists failed: ${key}`))
})
}
async clear(): Promise<void> {
if (!this.db) return
const run = this.writeQueue.then(() => this._clearRaw())
this.writeQueue = run.then(() => undefined, () => undefined)
return run
}
private _clearRaw(): Promise<void> {
if (!this.db) return Promise.resolve()
return new Promise((resolve, reject) => {
const tx = this.db!.transaction(STORE_NAME, 'readwrite')
const store = tx.objectStore(STORE_NAME)
store.clear()
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error ?? new Error('IDB clear failed'))
})
}
}
+18 -1
View File
@@ -1,7 +1,7 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate' import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate'
const GAME_VERSION = '0.1.37' const GAME_VERSION = '0.1.38'
const DB_PREFIX = 'cotyc-save-' const DB_PREFIX = 'cotyc-save-'
let seqCounter = 0 let seqCounter = 0
@@ -47,6 +47,23 @@ export class SaveSlot {
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`, `INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
[id, state.year, state.month, new Date().toISOString(), label, json] [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 keep = 12
const rows = await this.driver.all<{ id: string }>( const rows = await this.driver.all<{ id: string }>(
`SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC` `SELECT id FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC`
+69 -1
View File
@@ -5,6 +5,7 @@ import { examplePlugin } from '../../game/engine/runtime/demo-plugins'
import { ensureFx } from '../fx' import { ensureFx } from '../fx'
import { SYSTEM_DEFS } from '../../game/engine/runtime/capabilities' import { SYSTEM_DEFS } from '../../game/engine/runtime/capabilities'
import { buildBiography } from '../../game/engine/narrative/biography' import { buildBiography } from '../../game/engine/narrative/biography'
import { diagnoseStorage, type StorageHealthReport } from '../../game/storage/db'
export default function SettingsPanel() { export default function SettingsPanel() {
const world = useGameStore((s) => s.world) const world = useGameStore((s) => s.world)
@@ -24,6 +25,8 @@ export default function SettingsPanel() {
const toggleSound = useGameStore((s) => s.toggleSound) const toggleSound = useGameStore((s) => s.toggleSound)
const setNextToast = useGameStore((s) => s.setNextToast) const setNextToast = useGameStore((s) => s.setNextToast)
const [fxMode, setFxModeState] = useState('std') const [fxMode, setFxModeState] = useState('std')
const [healthReport, setHealthReport] = useState<StorageHealthReport | null>(null)
const [testing, setTesting] = useState(false)
const setFxMode = (m: 'soft' | 'std' | 'full') => { const setFxMode = (m: 'soft' | 'std' | 'full') => {
setFxModeState(m) setFxModeState(m)
ensureFx().setMode(m) ensureFx().setMode(m)
@@ -191,6 +194,71 @@ export default function SettingsPanel() {
</div> </div>
</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 settings-card">
<div className="card-title"></div> <div className="card-title"></div>
@@ -286,7 +354,7 @@ export default function SettingsPanel() {
· <br /> · <br />
· ·
</div> </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>
</div> </div>
) )
+45 -14
View File
@@ -4,7 +4,7 @@ import { GameEngine } from '../game/engine/GameEngine'
import { EventDef } from '../game/data/events' import { EventDef } from '../game/data/events'
import { findEvent, applyEventChoice } from '../game/engine/runtime/Systems/events' import { findEvent, applyEventChoice } from '../game/engine/runtime/Systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain' 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 { metaFromState, updateSlotMeta } from './storeHelper'
import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade' import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade'
import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound' import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound'
@@ -157,12 +157,30 @@ export const useGameStore = create<GameStore>((set, get) => ({
soundOn: true, soundOn: true,
init: async () => { 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)丢档) // P1-13 落盘兜底:页面隐藏/关闭时瞬时保存(防节流窗口(≤12tick)丢档)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const flush = () => { const flush = () => {
const st = useGameStore.getState() const st = useGameStore.getState()
if (!st.world || st.screen !== 'game') return 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) window.addEventListener('beforeunload', flush)
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
@@ -290,15 +308,20 @@ export const useGameStore = create<GameStore>((set, get) => ({
// 自动速度节流落盘:疾进 12 tick/常速 6 tick/缓行 4 tick;手动单步即时落盘 // 自动速度节流落盘:疾进 12 tick/常速 6 tick/缓行 4 tick;手动单步即时落盘
const saveEvery = st.speed >= 3 ? 12 : st.speed === 2 ? 6 : 4 const saveEvery = st.speed >= 3 ? 12 : st.speed === 2 ? 6 : 4
if (st.speed === 0 || w.state.totalTicks % saveEvery === 0) { 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) { } catch (e) {
// 出险保护:先落一档"出险前"现场再提示回档 // 出险保护:先落一档"出险前"现场再提示回档
console.error('[advance-crash]', e) console.error('[advance-crash]', e)
try { try {
await Stash.save(st, '出险前') const sr = await Stash.save(st, '出险前')
} catch { 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) const detail = String(e).slice(0, 120)
let safed = false let safed = false
@@ -398,8 +421,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
}, },
saveNow: async (label = '手动') => { saveNow: async (label = '手动') => {
await Stash.save(get(), label) const r = await Stash.save(get(), label)
setNextToast(`已落笔——第${get().slot}档(${label}`) if (r.ok) {
setNextToast(`已落笔——第${get().slot}档(${label}`)
} else {
setNextToast(`存档失败:${r.error}——请检查存储权限或使用导出功能。`)
}
}, },
bump: () => set((s) => ({ revision: s.revision + 1 })), bump: () => set((s) => ({ revision: s.revision + 1 })),
@@ -443,6 +470,7 @@ export const useGameStore = create<GameStore>((set, get) => ({
const st = get() const st = get()
if (!st.world || !window.api) return if (!st.world || !window.api) return
const file = await getSaveSlot(st.slot) const file = await getSaveSlot(st.slot)
await file.open()
const meta0 = metaFromState(st.world.state, st.slot) const meta0 = metaFromState(st.world.state, st.slot)
st.world.syncRng() st.world.syncRng()
await file.saveState(st.world.state, '导出前') await file.saveState(st.world.state, '导出前')
@@ -588,19 +616,22 @@ function actDirect(w: World, name: ActName, payload: import('../game/engine/runt
} }
const Stash = { const Stash = {
async save(st: GameStore, label: string): Promise<void> { async save(st: GameStore, label: string): Promise<{ ok: true } | { ok: false; error: string }> {
if (!st.world) return if (!st.world) return { ok: true }
try { try {
const file = await getSaveSlot(st.slot) const file = await getSaveSlot(st.slot)
await file.open() await file.open()
st.world.syncRng() st.world.syncRng()
await file.saveState(st.world.state, label) await file.saveState(st.world.state, label)
const meta = metaFromState(st.world.state, st.slot) const meta = metaFromState(st.world.state, st.slot)
await file.setMeta(meta) await file.setMeta(meta)
await updateSlotMeta(st.slot, meta) await updateSlotMeta(st.slot, meta)
return { ok: true }
} catch (e) { } catch (e) {
// P0-S:存储层并发/锁冲突兜底不抛(容忍一次失败,下次自动再试) // 0.1.38:错误不再静默——上浮给调用方,让用户可见
const msg = String(e).slice(0, 120)
console.error('[stash-save]', e) 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, members: alive,
reputation: state.family.reputation, reputation: state.family.reputation,
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
version: '0.1.37' version: '0.1.38'
} }
} }
+1 -1
View File
@@ -162,7 +162,7 @@ describe('GameFacade 门面', () => {
const f = new GameFacade(w, 1) const f = new GameFacade(w, 1)
const info = f.about() const info = f.about()
expect(info.title).toBe('仙途家族志') 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.modules).toBeGreaterThanOrEqual(11)
expect(info.systems).toBeGreaterThan(0) expect(info.systems).toBeGreaterThan(0)
expect(info.plugins).toBeGreaterThanOrEqual(3) expect(info.plugins).toBeGreaterThanOrEqual(3)
+1 -1
View File
@@ -51,7 +51,7 @@ describe('GameEngine 引擎门面', () => {
const e = new GameEngine({ seed: 'engine-5' }) const e = new GameEngine({ seed: 'engine-5' })
const a = e.about() const a = e.about()
expect(a.plugins).toBeGreaterThanOrEqual(3) expect(a.plugins).toBeGreaterThanOrEqual(3)
expect(a.version).toContain('0.1.36') expect(a.version).toContain('0.1.38')
}) })
}) })
+278
View File
@@ -0,0 +1,278 @@
/**
* 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,
} 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')
})
})