Files
ChronicleOfTheImmortalClan/tests/save-ext.test.ts
T
thzxx ff6df8054c refactor(0.1.14-P1): 内核归位——game/ 按引擎架构重排(零行为漂移)
结构(旧 game/core+engine → 新 engine/ 域):
- engine/kernel/  时钟/随机/插件协议/fxqueue/timesense/format/urgency/guide/names(原 core)
- engine/narrative/  legacy/报告/列传/谱系/年轴(原 core 叙事族)
- engine/runtime/   World/creation/pcgen/ApiFacade/capabilities/pluginManager/boot/clocks + Systems/*(12 系统)
- engine/sim/     Market(未来 WorldSim 同行)
- 旧 game/core、engine/systems、engine/world.ts 等路径全部废弃(无 re-export 兼容层)

验证:35 套件/967 测试全绿(金钟罩三档零漂移=纯搬迁无行为变化)
typecheck 0 error
2026-08-23 13:23:25 +08:00

146 lines
5.0 KiB
TypeScript

import { describe, expect, it } from 'vitest'
import { SaveSlot, SaveDbDriver } from '../src/renderer/game/storage/slots'
import { World } from '../src/renderer/game/engine/runtime/World'
interface Row {
[k: string]: unknown
}
class MemoryDriver implements SaveDbDriver {
tables = new Map<string, Map<string, Row>>()
async open(_name: string): Promise<void> {}
async close(): Promise<void> {}
async run(sql: string, params: unknown[] = []): Promise<unknown> {
const ins = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql)
if (ins) {
const cols = ins[2].split(',').map((c) => c.trim())
const row: Row = {}
cols.forEach((c, i) => (row[c] = params[i]))
const t = this.tables.get(ins[1]) ?? new Map<string, Row>()
t.set(String(row['id'] ?? ins[1] + t.size), row)
this.tables.set(ins[1], t)
}
const upd = /update\s+(\w+)\s+set/i.exec(sql)
if (upd) {
const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1]
const setVal = params[0]
const lit = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql)
const ph = /where\s+(\w+)\s*=\s*\?/i.exec(sql)
const col = (lit?.[1] ?? ph?.[1]) as string
const val = (lit?.[2] as string | undefined) ?? String(params[1])
const t = this.tables.get(upd[1])
if (t) {
for (const [k, row] of t.entries()) {
if (String(row[col]) === val) t.set(k, { ...row, [setCol]: setVal })
}
}
}
const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql)
if (del) {
const t = this.tables.get(del[1])
if (t) {
for (const [k, row] of t.entries()) {
if (String(row[del[2]]) === String(params[0])) t.delete(k)
}
}
}
return null
}
async all<T>(sql: string, params: unknown[] = []): Promise<T[]> {
const lower = sql.toLowerCase()
const table = /from (\w+)/i.exec(sql)?.[1]
if (!table) return []
let rows = [...(this.tables.get(table)?.values() ?? [])].map((r) => ({ ...r }))
if (/\bwhere\b/.test(lower)) {
const m = /where\s+(\w+)\s*=\s*\?/i.exec(sql)
if (m && params[0] !== undefined) {
rows = rows.filter((r) => String(r[m[1]]) === String(params[0]))
}
}
if (/\border by/i.test(lower)) {
rows = rows.sort((a, b) => Number(b['year']) - Number(a['year']) || Number(b['month']) - Number(a['month']))
}
if (/select data/i.test(lower)) rows = rows.map((r) => ({ data: String(r['data']) }))
if (/select value/i.test(lower)) rows = rows.map((r) => ({ value: String(r['value']) }))
return rows as T[]
}
async exec(sql: string): Promise<unknown> {
const create = /create table (?:if not exists )?(\w+)/i.exec(sql)
if (create && !this.tables.has(create[1])) this.tables.set(create[1], new Map())
const del = /delete from (\w+)/i.exec(sql)
if (del) this.tables.set(del[1], new Map())
return null
}
}
function world(seed: string) {
return World.create({ seed, surname: '楚', familyName: '楚家', motto: 'm', difficulty: 'normal' })
}
describe('SaveSlot 存储扩展', () => {
it('snapshot 保留最近 12 份并淘汰最旧', async () => {
const driver = new MemoryDriver()
const file = new SaveSlot(9, driver)
const w = world('st-9')
for (let i = 0; i < 20; i++) {
w.state.year = i + 1
await file.saveState(w.state, 'x')
}
const snaps = await file.listSnapshots()
expect(snaps.length).toBe(12)
expect(snaps[0].year).toBe(20)
})
it('wipe 后库中没有快照与 meta', async () => {
const driver = new MemoryDriver()
const file = new SaveSlot(10, driver)
await file.saveState(world('st-10').state, 'x')
await file.wipe()
const state = await file.loadState()
expect(state).toBeNull()
})
it('非法 json 导入返回 false 且原数据不动', async () => {
const driver = new MemoryDriver()
const file = new SaveSlot(11, driver)
await file.saveState(world('st-11').state, 'x')
const ok = await file.importAll('not json at all')
expect(ok).toBe(false)
const still = await file.loadState()
expect(still).toBeTruthy()
})
it('export→import 完全恢复(含 rng 状态推进一致)', async () => {
const driver = new MemoryDriver()
const file = new SaveSlot(12, driver)
const w = world('st-12')
for (let i = 0; i < 10; i++) w.advanceMonth()
w.syncRng()
await file.saveState(w.state, 'y')
const json = await file.exportAll()
const d2 = new MemoryDriver()
const f2 = new SaveSlot(12, d2)
await f2.importAll(json)
const restored = await f2.loadState()
expect(restored?.totalTicks).toBe(10)
// 此后推进与原始世界分叉前提一致(同 rng 同初始 = 同序列)
const w2 = new World(restored!)
w.advanceMonth()
w2.advanceMonth()
expect(w2.state.family.stones).toBe(w.state.family.stones)
})
})
// 修正语法:listSnapshots async
declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface ProcessEnv {}
}
}