0.1.38 存档根治·石碑重刻:OPFS降级+错误上浮+回读校验+持久化请求+诊断面板+driver.open锁死修复+exportSave补open
This commit is contained in:
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user