Files
ChronicleOfTheImmortalClan/src/renderer/game/storage/slots.ts
T

170 lines
5.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate'
const GAME_VERSION = '0.1.41'
const DB_PREFIX = 'cotyc-save-'
let seqCounter = 0
export interface SaveDbDriver {
open(name: string): Promise<void>
close(): Promise<void>
run(sql: string, params?: unknown[]): Promise<unknown>
all<T>(sql: string, params?: unknown[]): Promise<T[]>
exec(sql: string): Promise<unknown>
}
export interface TableDef {
name: string
cols: Record<string, string>
}
export class SaveSlot {
constructor(
public slot: number,
private driver: SaveDbDriver
) {}
private get name(): string {
return `${DB_PREFIX}${this.slot}`
}
async open(): Promise<void> {
await this.driver.open(this.name)
await this.ensureSchema()
}
private async ensureSchema(): Promise<void> {
const driver = this.driver
await driver.exec(`CREATE TABLE IF NOT EXISTS meta (key string PRIMARY KEY, value string)`)
await driver.exec(`CREATE TABLE IF NOT EXISTS snapshot (id string PRIMARY KEY, year number, month number, savedAt string, label string, data string)`)
}
async saveState(state: GameState, label: string): Promise<string> {
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
const json = JSON.stringify(state)
await this.driver.run(
`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`
)
if (rows.length > keep) {
const drop = rows.slice(keep).map((r) => r.id)
for (const d of drop) {
await this.driver.run(`DELETE FROM snapshot WHERE id = ?`, [d])
}
}
return id
}
async listSnapshots(): Promise<SnapshotMeta[]> {
const rows = await this.driver.all<SnapshotMeta & { data?: string }>(`SELECT id, year, month, savedAt, label FROM snapshot ORDER BY year DESC, month DESC`)
return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label }))
}
async loadState(id?: string): Promise<GameState | null> {
const rows = await this.driver.all<{ data: string }>(
id
? `SELECT data FROM snapshot WHERE id = ?`
: `SELECT data FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC LIMIT 1`,
id ? [id] : []
)
if (!rows || rows.length === 0) return null
const state = JSON.parse(rows[0]!.data) as GameState
const r = validateLoadedState(state)
if (!r.ok) throw new Error(r.message)
return r.state
}
async deleteSnapshot(id: string): Promise<void> {
await this.driver.run(`DELETE FROM snapshot WHERE id = ?`, [id])
}
async getMeta(): Promise<SaveMeta | null> {
const rows = await this.driver.all<{ value: string }>(`SELECT value FROM meta WHERE key = 'meta'`)
if (!rows || rows.length === 0) return null
return JSON.parse(rows[0].value) as SaveMeta
}
async setMeta(meta: SaveMeta): Promise<void> {
const exists = await this.driver.all<{ key: string }>(`SELECT key FROM meta WHERE key = 'meta'`)
const json = JSON.stringify(meta)
if (exists.length > 0) {
await this.driver.run(`UPDATE meta SET value = ? WHERE key = 'meta'`, [json])
} else {
await this.driver.run(`INSERT INTO meta (key, value) VALUES (?, ?)`, ['meta', json])
}
}
async exportAll(): Promise<string> {
const state = await this.loadState()
if (!state) throw new Error('无可导出存档')
const meta = await this.getMeta()
const envelope = JSON.parse(exportEnvelope(state)) as { payload: GameState }
return JSON.stringify({ app: 'cotyc', meta, payload: envelope.payload })
}
async importAll(data: string): Promise<boolean> {
try {
const parsed = parseImportEnvelope(data)
if (!parsed.ok) return false
const state = parsed.state
await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`)
await this.saveState(state, 'import')
const aliveCount = Object.values(state.members).filter((c) => c.alive).length
await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
return true
} catch {
return false
}
}
async wipe(): Promise<void> {
await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`)
await this.driver.exec(`DELETE FROM chronicle`)
}
}
export function buildSimpleMeta(state: GameState, slot: number, memberCount: number): SaveMeta {
return {
slot,
surname: state.family.surname,
name: state.family.name,
estate: state.family.estate,
year: state.year,
month: state.month,
generation: state.family.generation,
members: memberCount,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: GAME_VERSION
}
}
export function saveNameOf(meta: SaveMeta | null): string {
if (!meta) return ''
return `${meta.name} · ${meta.estate}`
}