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
+140
View File
@@ -0,0 +1,140 @@
import { SaveSlot } from './slots'
import { GameState, SaveMeta, SnapshotMeta } from '../types/domain'
import type { SaveDbDriver } from './slots'
// Adapter for @metona-team/metona-sqlark
// The driver interface keeps the engine swappable (testable in node too).
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type AnyFactory = { create: (config: any) => Promise<any> }
let MetonaSqlark: AnyFactory | null = null
interface SqlarkLike {
query: (sql: string, params?: unknown[]) => Promise<unknown[]>
}
export function setSqlarkBackend(impl: AnyFactory): void {
MetonaSqlark = impl
}
export async function makeDriver(): Promise<SaveDbDriver> {
const factory = await getSqlarkFactory()
return new SqlarkDriver(factory)
}
class SqlarkDriver implements SaveDbDriver {
private db: SqlarkLike | null = null
constructor(private factory: AnyFactory) {}
async open(name: string): Promise<void> {
if (this.db) return
this.db = (await this.factory.create({
name,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full', compression: true }
})) as SqlarkLike
}
async close(): Promise<void> {
this.db = null
}
async run(sql: string, params: unknown[] = []): Promise<unknown> {
if (!this.db) throw new Error('db not open')
await this.db.query(sql, params)
return null
}
async all<T>(sql: string, params: unknown[] = []): Promise<T[]> {
if (!this.db) throw new Error('db not open')
const rows = (await this.db.query(sql, params)) as T[]
return rows ?? []
}
async exec(sql: string): Promise<unknown> {
if (!this.db) throw new Error('db not open')
await this.db.query(sql)
return null
}
}
const META_DB = 'cotyc-appmeta'
let singletonManager: SlotManager | null = null
export function getSlotManager(): SlotManager {
if (!singletonManager) singletonManager = new SlotManager()
return singletonManager
}
export async function getSqlarkFactory(): Promise<AnyFactory> {
if (!MetonaSqlark) {
const mod = await import('@metona-team/metona-sqlark')
MetonaSqlark = mod.MetonaSqlark ?? mod.MeSqlark
}
if (!MetonaSqlark) throw new Error('metona-sqlark not available')
return MetonaSqlark
}
const slotCache = new Map<number, SaveSlot>()
export async function getSaveSlot(slot: number): Promise<SaveSlot> {
let s = slotCache.get(slot)
if (!s) {
const factory = await getSqlarkFactory()
s = new SaveSlot(slot, new SqlarkDriver(factory))
slotCache.set(slot, s)
}
return s
}
export class SlotManager {
private metaDb: SqlarkLike | null = null
async ensureMeta(): Promise<void> {
if (this.metaDb) return
this.metaDb = (await (await getSqlarkFactory()).create({
name: META_DB,
mode: 'aria',
diskEngine: 'opfs',
aria: { walSyncMode: 'full' }
})) as SqlarkLike
await this.metaDb.query(`CREATE TABLE IF NOT EXISTS slots (slot number PRIMARY KEY, meta string)`)
}
async listSlotMetas(): Promise<(SaveMeta | null)[]> {
await this.ensureMeta()
const rows = await this.metaDb!.query(`SELECT slot, meta FROM slots ORDER BY slot`)
const map = new Map<number, SaveMeta>()
for (const r of rows as { slot: number; meta: string }[]) {
try {
map.set(r.slot, JSON.parse(r.meta) as SaveMeta)
} catch {
map.set(r.slot, null as unknown as SaveMeta)
}
}
const out: (SaveMeta | null)[] = []
for (let i = 1; i <= 4; i++) {
out.push(map.get(i) ?? null)
}
return out
}
async updateSlotMeta(slot: number, meta: SaveMeta): Promise<void> {
await this.ensureMeta()
const existing = await this.metaDb!.query(`SELECT slot FROM slots WHERE slot = ?`, [slot])
const json = JSON.stringify(meta)
if (existing.length > 0) {
await this.metaDb!.query(`UPDATE slots SET meta = ? WHERE slot = ?`, [json, slot])
} else {
await this.metaDb!.query(`INSERT INTO slots (slot, meta) VALUES (?, ?)`, [slot, json])
}
}
async removeSlotMeta(slot: number): Promise<void> {
await this.ensureMeta()
await this.metaDb!.query(`DELETE FROM slots WHERE slot = ?`, [slot])
}
}
export type { GameState, SaveMeta, SnapshotMeta }