v0.1.0: 仙途家族志 · Chronicle of the Immortal Clan

家族模拟器首版:修仙/经营/战斗/外交/叙事全套系统
- Electron + React + TS + MetonaSqlark(aria+OPFS)
- 水墨中国风 UI
- 引擎种子随机、确定性可回放
- 19 项单元测试、端到端冒烟验证
This commit is contained in:
2026-08-23 00:04:16 +08:00
commit adb22f0558
69 changed files with 14305 additions and 0 deletions
+156
View File
@@ -0,0 +1,156 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
const DB_PREFIX = 'cotyc-save-'
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)`)
await driver.exec(`CREATE TABLE IF NOT EXISTS chronicle (id string PRIMARY KEY, year number, month number, category string, important number, memberId string, text string)`)
}
async saveState(state: GameState, label: string): Promise<string> {
const id = `${state.year}.${state.month}.${Date.now().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]
)
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 saveChronicle(chronicle: GameState['chronicle']): Promise<void> {
const driver = this.driver
for (const e of chronicle) {
try {
const exists = await driver.all<{ id: string }>(`SELECT id FROM chronicle WHERE id = ?`, [e.id])
if (exists.length > 0) continue
await driver.run(
`INSERT INTO chronicle (id, year, month, category, important, memberId, text) VALUES (?, ?, ?, ?, ?, ?, ?)`,
[e.id, e.year, e.month, e.category, e.important ? 1 : 0, e.memberId ?? null, e.text]
)
} catch {
// chronicle table redundancy is best-effort
}
}
}
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 rowid DESC LIMIT 1`
, id ? [id] : [])
if (!rows || rows.length === 0) return null
return JSON.parse(rows[0]!.data) as GameState
}
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()
const meta = await this.getMeta()
return JSON.stringify({ app: 'cotyc', schemaVersion: 1, meta, state })
}
async importAll(data: string): Promise<boolean> {
try {
const parsed = JSON.parse(data) as { app?: string; schemaVersion?: number; state?: GameState }
if (!parsed.state) return false
await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`)
await this.saveState(parsed.state, 'import')
const aliveCount = Object.values(parsed.state.members).filter((c) => c.alive).length
await this.setMeta(buildSimpleMeta(parsed.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: state.schemaVersion
}
}
export function saveNameOf(meta: SaveMeta | null): string {
if (!meta) return ''
return `${meta.name} · ${meta.estate}`
}