v0.1.1: 养成闭环 + 管理自主 + 存档安全

- 指婚/续弦:成员详情可主动找人结亲(血亲自动排除、鳏寡可再醮)
- 装备 UI:法宝从府库装备/替换,战力实时反馈
- 御敌点将:劫掠事件可自选迎战阵容(默认 Top4 可改)
- 宗祠祭祖:每两年一次,灵石150 → 声望+6/全族修为小升
- 回档时间轴:每季快照列表,一键回卷(回卷前自动保当前档)
- 战报匣子:史书页全文战报留存可翻阅
- 年度族簿纸笺:每年初弹收支/人丁/战力简报
- 媒人提示条 + 寻衅一年冷却 + 出生率与劫掠频率微调
- 单测 19→29(storage mock roundtrip/回档/指婚血亲/祭祖/寻衅/装备/账本)
This commit is contained in:
2026-08-23 08:16:40 +08:00
parent 4bacbd432e
commit 6b15a97d0c
24 changed files with 734 additions and 26 deletions
+162
View File
@@ -0,0 +1,162 @@
import { describe, expect, it } from 'vitest'
import { SaveSlot, SaveDbDriver } from '../src/renderer/game/storage/slots'
import { GameState } from '../src/renderer/game/types/domain'
import { World } from '../src/renderer/game/engine/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 m = /insert into (\w+)\s*\(([^)]+)\)\s*values\s*\(([^)]+)\)/i.exec(sql)
if (m) {
const table = m[1]
const cols = m[2].split(',').map((c) => c.trim())
const row: Row = {}
cols.forEach((c, i) => {
row[c] = params[i]
})
const t = this.tables.get(table) ?? new Map<string, Row>()
t.set(String(row['id'] ?? table + t.size), row)
this.tables.set(table, t)
}
if (/update\s+(\w+)\s+set/i.test(sql)) {
const table = /update\s+(\w+)/i.exec(sql)![1]
const setCol = /set\s+(\w+)\s*=\s*\?/i.exec(sql)![1]
const setVal = params[0]
const whereLiteral = /\bwhere\s+(\w+)\s*=\s*'([^']+)'/i.exec(sql)
const wherePlaceholder = /where\s+(\w+)\s*=\s*\?/i.exec(sql)
const whereCol = (whereLiteral?.[1] ?? wherePlaceholder?.[1]) as string | undefined
const whereVal =
(whereLiteral?.[2] as string | undefined) ?? (wherePlaceholder ? String(params[1]) : undefined)
if (whereCol && whereVal !== undefined) {
const t = this.tables.get(table)
if (t) {
for (const [k, row] of t.entries()) {
if (String(row[whereCol]) === whereVal) {
t.set(k, { ...row, [setCol]: setVal })
}
}
}
}
}
const del = /delete from (\w+)\s+where\s+(\w+)\s*=\s*\?/i.exec(sql)
if (del) {
const table = del[1]
const col = del[2]
const val = String(params[0])
const t = this.tables.get(table)
if (t) {
for (const [k, row] of t.entries()) {
if (String(row[col]) === val) t.delete(k)
}
}
}
return null
}
async all<T>(sql: string, params: unknown[] = []): Promise<T[]> {
const lower = sql.toLowerCase()
const isSnapshot = /from snapshot/.test(lower)
const isMeta = /from meta/.test(lower)
const isChronicle = /from chronicle/.test(lower)
const table = isSnapshot ? 'snapshot' : isMeta ? 'meta' : isChronicle ? 'chronicle' : null
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(lower)
if (m) {
const col = m[1]
const val = String(params[0])
rows = rows.filter((r) => String(r[col]) === val)
}
}
if (isSnapshot && /\border by/i.test(lower)) {
rows = rows.sort((a, b) => {
const y = Number(b['year']) - Number(a['year'])
if (y !== 0) return y
return Number(b['month']) - Number(a['month'])
})
}
if (isSnapshot) {
const wantData = /select data/.test(lower)
rows = rows.map((r) => (wantData ? { data: String(r['data']) } : r))
} else if (isMeta) {
const wantValue = /select value/.test(lower)
rows = rows.map((r) => (wantValue ? { value: String(r['value']) } : r))
}
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
}
}
describe('SaveSlot storage roundtrip', () => {
it('saves & loads latest state faithfully', async () => {
const slot = new SaveSlot(1, new MemoryDriver())
const w = World.create({ seed: 's1', surname: '陈', familyName: '陈家', motto: 'm', difficulty: 'normal' })
const id = await slot.saveState(w.state, '自动')
const loaded = await slot.loadState()
expect(loaded).not.toBeNull()
expect(loaded!.family.surname).toBe('陈')
expect(loaded!.members['x1'].name).toBe(w.state.members['x1'].name)
expect(id).toBeTruthy()
})
it('lists snapshots newest first', async () => {
const slot = new SaveSlot(2, new MemoryDriver())
const w = World.create({ seed: 's2', surname: '周', familyName: '周家', motto: 'm', difficulty: 'normal' })
w.state.year = 1
w.state.month = 1
await slot.saveState(w.state, 'a')
const s2 = JSON.parse(JSON.stringify(w.state)) as GameState
s2.year = 2
s2.month = 3
await slot.saveState(s2, 'b')
const snaps = await slot.listSnapshots()
expect(snaps.length).toBe(2)
expect(snaps[0].year).toBe(2)
expect(snaps[1].year).toBe(1)
})
it('loads by id and rounds meta', async () => {
const slot = new SaveSlot(3, new MemoryDriver())
const w = World.create({ seed: 's3', surname: '郑', familyName: '郑家', motto: 'm', difficulty: 'normal' })
const id = await slot.saveState(w.state, 'm')
const byId = await slot.loadState(id)
expect(byId?.family.name).toBe('郑家')
await slot.setMeta({ slot: 3, surname: '郑', name: '郑家', estate: w.state.family.estate, year: 1, month: 1, generation: 1, members: 5, reputation: 5, updatedAt: 'now', version: 1 })
const meta = await slot.getMeta()
expect(meta?.name).toBe('郑家')
expect(meta?.members).toBe(5)
})
it('keeps snapshot tail trimmed', async () => {
const slot = new SaveSlot(5, new MemoryDriver())
const w = World.create({ seed: 's5', surname: '钱', familyName: '钱家', motto: 'm', difficulty: 'normal' })
for (let i = 0; i < 15; i++) {
w.state.year = i + 1
await slot.saveState(w.state, 's' + i)
}
const snaps = await slot.listSnapshots()
expect(snaps.length).toBe(12)
expect(snaps[0].year).toBe(15)
})
})