170 lines
5.8 KiB
TypeScript
170 lines
5.8 KiB
TypeScript
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}`
|
||
}
|