v0.1.35b: 兼容层铲除——迁移链/版本戳/死表死代码/normalize 兼容批/死字段
【A+I】migrate.ts 迁移链(CURRENT_SCHEMA/MIGRATIONS/migrateIfNeeded/MigrationError)整链铲除→validateLoadedState 结构校验(seed/family/members 断言);信封统一 payload 单格式(去双格式兜底);APP_ID 保留 【F】schemaVersion 字段全删(domain/creation/save 8 处);SaveMeta.version 记游戏版本字符串 【C】chronicle 死表+saveChronicle 死方法(首行 return+unreachable legacy 僵尸)全删 【D/E】GameEngine __state globalThis 泄漏口+seed 三元(恒不可达)删除;engineFromSnapshot/noAutoCreate 保留(测试便利——非兼容机器) 【B/H】normalizeGameState 兼容批删:老档字段补(28行)/worldGen 重放(18行)/P0-2 占位 def(9行)/worldSim 字段批删;保留 npcs 重注册/headId 修复/d distress 防护;initSim 补 0.1.34 四键(唯一初始源) 【G】domain 收紧 5 兼容 optional(techniqueRank/Progress/tribBoost/worldGen 必填)+pcgen 必写 【测试】删 9 个兼容验证体(audit 0.1.0-era/全残缺矩阵/空 worldSim 兜底);5989 全绿——金钟罩零漂(新档结构全字段,删除不涉世界数值)
This commit is contained in:
@@ -42,20 +42,20 @@ export class GameEngine {
|
|||||||
this.kernel = makeKernel(this._seed)
|
this.kernel = makeKernel(this._seed)
|
||||||
this.datapack = PACK
|
this.datapack = PACK
|
||||||
if (opts.datapack) this.datapack.override(opts.datapack)
|
if (opts.datapack) this.datapack.override(opts.datapack)
|
||||||
// 组装World(内核注入→时钟/随机/总线单源)
|
// 组装World(内核注入→时钟/随机/总线单源;0.1.35 移除 __state 泄漏口——seed 恒用 _seed)
|
||||||
if (opts.noAutoCreate) {
|
if (opts.noAutoCreate) {
|
||||||
this.world = new World(createWorldState({
|
this.world = new World(createWorldState({
|
||||||
seed: this._seed, surname: '占位', familyName: '占位', motto: '', difficulty: 'normal'
|
seed: this._seed, surname: '占位', familyName: '占位', motto: '', difficulty: 'normal'
|
||||||
}), [], this.kernel)
|
}), [], this.kernel)
|
||||||
} else {
|
} else {
|
||||||
const state = opts.seed ? createWorldState({
|
const state = createWorldState({
|
||||||
seed: this._seed,
|
seed: this._seed,
|
||||||
surname: opts.surname?.trim() || '林',
|
surname: opts.surname?.trim() || '林',
|
||||||
familyName: opts.familyName?.trim() || `${opts.surname?.trim() || '林'}氏`,
|
familyName: opts.familyName?.trim() || `${opts.surname?.trim() || '林'}氏`,
|
||||||
motto: opts.motto?.trim() || '耕读传家,术法继世',
|
motto: opts.motto?.trim() || '耕读传家,术法继世',
|
||||||
difficulty: opts.difficulty ?? 'normal'
|
difficulty: opts.difficulty ?? 'normal'
|
||||||
}) : (globalThis as never as { __state?: GameState }).__state
|
})
|
||||||
this.world = new World(state!, [], this.kernel)
|
this.world = new World(state, [], this.kernel)
|
||||||
}
|
}
|
||||||
this.facade = new ApiFacade(this.world, 1)
|
this.facade = new ApiFacade(this.world, 1)
|
||||||
// Kernel 总线 → World.out 桥接(attached 后同步 feed)
|
// Kernel 总线 → World.out 桥接(attached 后同步 feed)
|
||||||
@@ -132,7 +132,7 @@ export class GameEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 已用旧内核时钟注册的世界快照,反序列化时公用(便捷入口) */
|
/** 已用旧内核时钟注册的世界快照,反序列化时公用(测试/便捷入口——非兼容机器) */
|
||||||
export function engineFromSnapshot(state: GameState): GameEngine {
|
export function engineFromSnapshot(state: GameState): GameEngine {
|
||||||
const eng = new GameEngine({ seed: state.seed, noAutoCreate: true })
|
const eng = new GameEngine({ seed: state.seed, noAutoCreate: true })
|
||||||
eng.restore(state)
|
eng.restore(state)
|
||||||
|
|||||||
@@ -78,90 +78,14 @@ export function normalizeGameState(state: GameState): GameState {
|
|||||||
registerNpcDef(n as never)
|
registerNpcDef(n as never)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// P0-2 双保险:npcFamilies 中缺 def 的 id → 占位 def(防极端旧档僵尸)
|
|
||||||
for (const id of Object.keys(state.npcFamilies ?? {})) {
|
|
||||||
if (!npcById(id)) {
|
|
||||||
const n = state.npcFamilies[id]
|
|
||||||
registerNpcDef({
|
|
||||||
id, name: n.name, region: n.region, style: '遗族', desc: '世族遗脉,家道中落而名犹在册。',
|
|
||||||
leaderRealm: 'foundation', initialPower: n.power, powerGrowth: [2, 6], sells: [], buys: ['lingcao']
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!state.worldGen && state.seed) {
|
|
||||||
const wg = generateWorld(state.seed)
|
|
||||||
state.worldGen = {
|
|
||||||
relations: wg.relations,
|
|
||||||
eras: wg.eras,
|
|
||||||
marketOffset: wg.marketOffset,
|
|
||||||
regionFlavor: wg.regionFlavor,
|
|
||||||
alliances: wg.alliances,
|
|
||||||
feuds: wg.feuds,
|
|
||||||
npcCount: wg.npcs.length,
|
|
||||||
// P1-9 重放同步 npcs 摘要(与 creation 一致——该档自此自捕获,不再每次重放)
|
|
||||||
npcs: wg.npcs.map((n) => ({
|
|
||||||
id: n.id, name: n.name, region: n.region, style: n.style,
|
|
||||||
desc: n.desc, leaderRealm: n.leaderRealm, initialPower: n.initialPower,
|
|
||||||
powerGrowth: n.powerGrowth, sells: n.sells, buys: n.buys
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃
|
|
||||||
if (!state.finance) state.finance = { accum: 0 }
|
|
||||||
if (!state.yearStats) state.yearStats = { births: 0, deaths: 0 }
|
|
||||||
if (!state.yearlyReports) state.yearlyReports = []
|
|
||||||
if (!state.stats) {
|
|
||||||
state.stats = {
|
|
||||||
repPeak: state.family?.reputation ?? 0,
|
|
||||||
popPeak: Object.values(state.members).filter((c) => c.alive).length,
|
|
||||||
maxRealmIdx: peakRealmIndex(state.members ?? {}),
|
|
||||||
techniqueGrand: grandTechniqueCount(state.members ?? {}),
|
|
||||||
tourneyHistory: [],
|
|
||||||
feishengCount: 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof state.totalTicks !== 'number') state.totalTicks = 0
|
|
||||||
if (typeof state.seq !== 'number') state.seq = 10
|
|
||||||
if (!state.battles) state.battles = []
|
|
||||||
if (!state.eventQueue) state.eventQueue = []
|
|
||||||
if (!state.completedEvents) state.completedEvents = []
|
|
||||||
if (!state.missions) state.missions = []
|
|
||||||
if (!state.flags) state.flags = {}
|
|
||||||
if (!state.npcFamilies) state.npcFamilies = {}
|
|
||||||
if (!state.family.flag) state.family.flag = {}
|
|
||||||
if (!state.family.inventory) state.family.inventory = {}
|
|
||||||
if (!state.family.buildings) state.family.buildings = {}
|
|
||||||
if (!state.family.techniques) state.family.techniques = []
|
|
||||||
if (!state.family.missionIds) state.family.missionIds = []
|
|
||||||
if (!state.stats.tourneyHistory) state.stats.tourneyHistory = []
|
|
||||||
if (state.stats.tourneyBest === undefined) state.stats.tourneyBest = undefined
|
|
||||||
if (state.worldSim) {
|
if (state.worldSim) {
|
||||||
state.worldSim.marketPool = state.worldSim.marketPool ?? {}
|
// 0.1.35 收敛:初始字段职责归 initSim(WorldSim.ts),此处仅防脏(异常落盘/测试注入)
|
||||||
state.worldSim.npcDyn = state.worldSim.npcDyn ?? {}
|
// 防护一:era 非法值兜底(语义防护——主产物 initSim 造)
|
||||||
state.worldSim.secretQi = state.worldSim.secretQi ?? {}
|
|
||||||
state.worldSim.newsFeed = state.worldSim.newsFeed ?? []
|
|
||||||
if (typeof state.worldSim.tide !== 'number') state.worldSim.tide = 0.5
|
|
||||||
if (typeof state.worldSim.warFatigue !== 'number') state.worldSim.warFatigue = 0
|
|
||||||
if (!state.worldSim.shortage) state.worldSim.shortage = {}
|
|
||||||
if (!state.worldSim.shortageWarned) state.worldSim.shortageWarned = {}
|
|
||||||
if (!state.worldSim.wonders) state.worldSim.wonders = {}
|
|
||||||
if (typeof state.worldSim.tideTicks !== 'number') state.worldSim.tideTicks = 0
|
|
||||||
if (typeof state.worldSim.npcSuccessions !== 'number') state.worldSim.npcSuccessions = 0
|
|
||||||
if (typeof state.worldSim.calamityLeft !== 'number') state.worldSim.calamityLeft = 0
|
|
||||||
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
|
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
|
||||||
state.worldSim.era = 'pingshi'
|
state.worldSim.era = 'pingshi'
|
||||||
state.worldSim.eraStartYear = 1
|
state.worldSim.eraStartYear = 1
|
||||||
} else if (typeof state.worldSim.eraStartYear !== 'number') {
|
|
||||||
state.worldSim.eraStartYear = state.year ?? 1
|
|
||||||
}
|
}
|
||||||
if (typeof state.worldSim.overfluxYear !== 'number') state.worldSim.overfluxYear = -99
|
|
||||||
if (!Array.isArray(state.worldSim.worldAnnals)) state.worldSim.worldAnnals = []
|
|
||||||
if (state.worldSim.distress && typeof state.worldSim.distress !== 'object') state.worldSim.distress = undefined
|
if (state.worldSim.distress && typeof state.worldSim.distress !== 'object') state.worldSim.distress = undefined
|
||||||
for (const dyn of Object.values(state.worldSim.npcDyn)) {
|
|
||||||
if (dyn && typeof (dyn as { prosperity?: unknown }).prosperity !== 'number') {
|
|
||||||
(dyn as { prosperity: number }).prosperity = 50
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (state.family.headId && !state.members[state.family.headId]) {
|
if (state.family.headId && !state.members[state.family.headId]) {
|
||||||
const firstAlive = Object.values(state.members).find((c) => c.alive)
|
const firstAlive = Object.values(state.members).find((c) => c.alive)
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ export function createWorldState(opts: NewGameOptions): GameState {
|
|||||||
|
|
||||||
const worldGen = generateWorld(opts.seed)
|
const worldGen = generateWorld(opts.seed)
|
||||||
const state: GameState = {
|
const state: GameState = {
|
||||||
schemaVersion: 1,
|
|
||||||
seed: opts.seed,
|
seed: opts.seed,
|
||||||
worldGen: {
|
worldGen: {
|
||||||
relations: worldGen.relations,
|
relations: worldGen.relations,
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ export function newCharacter(
|
|||||||
state: 'idle',
|
state: 'idle',
|
||||||
health: 100,
|
health: 100,
|
||||||
alive: true,
|
alive: true,
|
||||||
|
techniqueRank: 0,
|
||||||
|
techniqueProgress: 0,
|
||||||
|
tribBoost: 0,
|
||||||
isHead: opts.isHead,
|
isHead: opts.isHead,
|
||||||
isFounder: opts.isFounder
|
isFounder: opts.isFounder
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,11 @@ export function makeWorldSimState(): WorldSimState {
|
|||||||
era: 'pingshi',
|
era: 'pingshi',
|
||||||
eraStartYear: 1,
|
eraStartYear: 1,
|
||||||
overfluxYear: -99,
|
overfluxYear: -99,
|
||||||
worldAnnals: []
|
worldAnnals: [],
|
||||||
|
warFatigue: 0,
|
||||||
|
shortage: {},
|
||||||
|
shortageWarned: {},
|
||||||
|
wonders: {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,75 +1,32 @@
|
|||||||
import { GameState } from '../types/domain'
|
import { GameState } from '../types/domain'
|
||||||
import { normalizeGameState } from '../engine/runtime/World'
|
|
||||||
|
|
||||||
export const CURRENT_SCHEMA = 2
|
|
||||||
export const APP_ID = 'cotyc'
|
export const APP_ID = 'cotyc'
|
||||||
|
|
||||||
export class MigrationError extends Error {
|
|
||||||
code: 'TOO_NEW' | 'UNKNOWN_VERSION'
|
|
||||||
version: number
|
|
||||||
constructor(code: 'TOO_NEW' | 'UNKNOWN_VERSION', version: number, message: string) {
|
|
||||||
super(message)
|
|
||||||
this.code = code
|
|
||||||
this.version = version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 迁移链:obj.from 升到 obj.to 逐级执行(任何一步失败即中断抛出,原状态不被写入)。
|
* 0.1.35 荡涤:迁移链(CURRENT_SCHEMA/MIGRATIONS/migrateIfNeeded)整体铲除。
|
||||||
* v1(≤0.1.8)→ v2(0.1.9):补 stats/finance/annals 字段并打上 schema 版本戳。
|
* 未发布游戏不做版本迁移——读档只需结构粗检 + World 构造器 normalize 兜底/防护。
|
||||||
*/
|
*/
|
||||||
const MIGRATIONS: Array<{ from: number; to: number; fn: (s: GameState) => GameState }> = [
|
export function validateLoadedState(state: unknown): { ok: true; state: GameState } | { ok: false; message: string } {
|
||||||
{
|
const s = state as GameState | null
|
||||||
from: 1,
|
if (!s || typeof s !== 'object') return { ok: false, message: '存档内容为空或损坏。' }
|
||||||
to: 2,
|
if (!s.family || !s.members || typeof s.seed !== 'string') {
|
||||||
fn: (s) => {
|
return { ok: false, message: '存档结构缺失(family/members/seed)——本作未发布变更频繁,旧档不可读。' }
|
||||||
normalizeGameState(s)
|
|
||||||
s.schemaVersion = 2
|
|
||||||
return s
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
]
|
return { ok: true, state: s }
|
||||||
|
|
||||||
export function migrateIfNeeded(state: GameState): { ok: true; state: GameState } | { ok: false; error: MigrationError } {
|
|
||||||
const version = typeof state.schemaVersion === 'number' ? state.schemaVersion : 1
|
|
||||||
if (version > CURRENT_SCHEMA) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: new MigrationError('TOO_NEW', version, `存档版本 ${version} 高于当前游戏支持(${CURRENT_SCHEMA}),请升级游戏。`)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let cur = state
|
|
||||||
let v = version
|
|
||||||
let guard = 0
|
|
||||||
while (v < CURRENT_SCHEMA && guard < 20) {
|
|
||||||
const step = MIGRATIONS.find((m) => m.from === v)
|
|
||||||
if (!step) {
|
|
||||||
return { ok: false, error: new MigrationError('UNKNOWN_VERSION', v, `未知存档版本 ${v},无法迁移。`) }
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
cur = step.fn(cur)
|
|
||||||
} catch (e) {
|
|
||||||
return { ok: false, error: new MigrationError('UNKNOWN_VERSION', v, `迁移失败:${String(e)}`) }
|
|
||||||
}
|
|
||||||
v = step.to
|
|
||||||
guard++
|
|
||||||
}
|
|
||||||
return { ok: true, state: cur }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function exportEnvelope(state: GameState): string {
|
export function exportEnvelope(state: GameState): string {
|
||||||
return JSON.stringify({ app: APP_ID, schemaVersion: state.schemaVersion ?? CURRENT_SCHEMA, payload: state })
|
return JSON.stringify({ app: APP_ID, payload: state })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } {
|
export function parseImportEnvelope(text: string): { ok: true; state: GameState } | { ok: false; error: string } {
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(text) as { app?: string; schemaVersion?: number; payload?: GameState; state?: GameState }
|
const raw = JSON.parse(text) as { app?: string; payload?: GameState; state?: GameState }
|
||||||
if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' }
|
if (raw.app !== undefined && raw.app !== APP_ID) return { ok: false, error: '非本作存档(app 标识不符)。' }
|
||||||
const state = raw.payload ?? raw.state
|
const state = raw.payload ?? raw.state
|
||||||
if (!state || typeof state !== 'object') return { ok: false, error: '存档内容为空或损坏。' }
|
const checked = validateLoadedState(state)
|
||||||
const migrated = migrateIfNeeded(state as GameState)
|
if (checked.ok) return { ok: true, state: checked.state }
|
||||||
if (migrated.ok) return { ok: true, state: migrated.state }
|
return { ok: false, error: checked.message }
|
||||||
return { ok: false, error: migrated.error.message }
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return { ok: false, error: `解析失败:${String(e)}` }
|
return { ok: false, error: `解析失败:${String(e)}` }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
|
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
|
||||||
import { CURRENT_SCHEMA, APP_ID, exportEnvelope, parseImportEnvelope, migrateIfNeeded, MigrationError } from './migrate'
|
import { validateLoadedState, exportEnvelope, parseImportEnvelope } from './migrate'
|
||||||
|
|
||||||
|
const GAME_VERSION = '0.1.35'
|
||||||
|
|
||||||
const DB_PREFIX = 'cotyc-save-'
|
const DB_PREFIX = 'cotyc-save-'
|
||||||
let seqCounter = 0
|
let seqCounter = 0
|
||||||
@@ -36,12 +38,10 @@ export class SaveSlot {
|
|||||||
const driver = this.driver
|
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 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 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> {
|
async saveState(state: GameState, label: string): Promise<string> {
|
||||||
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
|
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
|
||||||
state.schemaVersion = CURRENT_SCHEMA
|
|
||||||
const json = JSON.stringify(state)
|
const json = JSON.stringify(state)
|
||||||
await this.driver.run(
|
await this.driver.run(
|
||||||
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
|
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
@@ -60,29 +60,6 @@ export class SaveSlot {
|
|||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
/** P1-T chronicle 表已废(write-only 无读取者;史书数据随快照 data 保存)——保持 no-op 接口 */
|
|
||||||
async saveChronicle(_chronicle: GameState['chronicle']): Promise<void> {
|
|
||||||
return
|
|
||||||
/* eslint-disable no-unreachable */
|
|
||||||
try {
|
|
||||||
// legacy(0.1.29 前占位;不再维护)
|
|
||||||
const rows = await this.driver.all<{ id: string }>(`SELECT id FROM chronicle`)
|
|
||||||
const existing = new Set(rows.map((r) => r.id))
|
|
||||||
for (const e of _chronicle) {
|
|
||||||
if (existing.has(e.id)) continue
|
|
||||||
try {
|
|
||||||
await this.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 {
|
|
||||||
// row-level best-effort
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 冗余表失败吞掉(主挡位用 snapshot 兜底)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
async listSnapshots(): Promise<SnapshotMeta[]> {
|
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`)
|
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 }))
|
return rows.map((r) => ({ id: r.id, year: r.year, month: r.month, savedAt: r.savedAt, label: r.label }))
|
||||||
@@ -97,8 +74,8 @@ export class SaveSlot {
|
|||||||
)
|
)
|
||||||
if (!rows || rows.length === 0) return null
|
if (!rows || rows.length === 0) return null
|
||||||
const state = JSON.parse(rows[0]!.data) as GameState
|
const state = JSON.parse(rows[0]!.data) as GameState
|
||||||
const r = migrateIfNeeded(state)
|
const r = validateLoadedState(state)
|
||||||
if (!r.ok) throw new MigrationError(r.error.code, r.error.version, r.error.message)
|
if (!r.ok) throw new Error(r.message)
|
||||||
return r.state
|
return r.state
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,8 +103,8 @@ export class SaveSlot {
|
|||||||
const state = await this.loadState()
|
const state = await this.loadState()
|
||||||
if (!state) throw new Error('无可导出存档')
|
if (!state) throw new Error('无可导出存档')
|
||||||
const meta = await this.getMeta()
|
const meta = await this.getMeta()
|
||||||
const envelope = JSON.parse(exportEnvelope(state)) as { schemaVersion: number; payload: GameState }
|
const envelope = JSON.parse(exportEnvelope(state)) as { payload: GameState }
|
||||||
return JSON.stringify({ app: APP_ID, schemaVersion: envelope.schemaVersion, meta, state: envelope.payload })
|
return JSON.stringify({ app: 'cotyc', meta, payload: envelope.payload })
|
||||||
}
|
}
|
||||||
|
|
||||||
async importAll(data: string): Promise<boolean> {
|
async importAll(data: string): Promise<boolean> {
|
||||||
@@ -135,10 +112,8 @@ export class SaveSlot {
|
|||||||
const parsed = parseImportEnvelope(data)
|
const parsed = parseImportEnvelope(data)
|
||||||
if (!parsed.ok) return false
|
if (!parsed.ok) return false
|
||||||
const state = parsed.state
|
const state = parsed.state
|
||||||
state.schemaVersion = CURRENT_SCHEMA
|
|
||||||
await this.driver.exec(`DELETE FROM snapshot`)
|
await this.driver.exec(`DELETE FROM snapshot`)
|
||||||
await this.driver.exec(`DELETE FROM meta`)
|
await this.driver.exec(`DELETE FROM meta`)
|
||||||
await this.driver.exec(`DELETE FROM chronicle`)
|
|
||||||
await this.saveState(state, 'import')
|
await this.saveState(state, 'import')
|
||||||
const aliveCount = Object.values(state.members).filter((c) => c.alive).length
|
const aliveCount = Object.values(state.members).filter((c) => c.alive).length
|
||||||
await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
|
await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
|
||||||
@@ -167,7 +142,7 @@ export function buildSimpleMeta(state: GameState, slot: number, memberCount: num
|
|||||||
members: memberCount,
|
members: memberCount,
|
||||||
reputation: state.family.reputation,
|
reputation: state.family.reputation,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
version: state.schemaVersion
|
version: GAME_VERSION
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ export interface Character {
|
|||||||
fortune: number
|
fortune: number
|
||||||
traits: string[]
|
traits: string[]
|
||||||
techniqueId?: string
|
techniqueId?: string
|
||||||
techniqueRank?: number
|
techniqueRank: number // 0.1.35 必填(pcgen 必写——归一化兼容批已删)
|
||||||
techniqueProgress?: number
|
techniqueProgress: number
|
||||||
post?: string
|
post?: string
|
||||||
equipment?: string
|
equipment?: string
|
||||||
state: CharState
|
state: CharState
|
||||||
@@ -58,7 +58,7 @@ export interface Character {
|
|||||||
tribDelayYear?: number
|
tribDelayYear?: number
|
||||||
tribPendingMonths?: number
|
tribPendingMonths?: number
|
||||||
/** 渡劫药力加成(破境丹等),渡劫结算后清零 */
|
/** 渡劫药力加成(破境丹等),渡劫结算后清零 */
|
||||||
tribBoost?: number
|
tribBoost: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FamilyState {
|
export interface FamilyState {
|
||||||
@@ -164,7 +164,6 @@ export interface YearlyReport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface GameState {
|
export interface GameState {
|
||||||
schemaVersion: number
|
|
||||||
seed: string
|
seed: string
|
||||||
rng: RngState
|
rng: RngState
|
||||||
year: number
|
year: number
|
||||||
@@ -190,7 +189,7 @@ export interface GameState {
|
|||||||
/** 读档时缺失的插件(工厂未注册/版本不符)——UI 提示:MOD 文件可能需重新放入 mods 目录 */
|
/** 读档时缺失的插件(工厂未注册/版本不符)——UI 提示:MOD 文件可能需重新放入 mods 目录 */
|
||||||
pluginsMissing?: string[]
|
pluginsMissing?: string[]
|
||||||
/** 天下轮廓(种子生成器产物:关系网/开局 era/市场偏移/世仇盟约) */
|
/** 天下轮廓(种子生成器产物:关系网/开局 era/市场偏移/世仇盟约) */
|
||||||
worldGen?: {
|
worldGen: {
|
||||||
relations: Record<string, Record<string, number>>
|
relations: Record<string, Record<string, number>>
|
||||||
eras: 'shengshi' | 'pingshi' | 'luanshi'
|
eras: 'shengshi' | 'pingshi' | 'luanshi'
|
||||||
marketOffset: Record<string, number>
|
marketOffset: Record<string, number>
|
||||||
@@ -258,7 +257,7 @@ export interface SaveMeta {
|
|||||||
members: number
|
members: number
|
||||||
reputation: number
|
reputation: number
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
version: number
|
version: string // 0.1.35 记录游戏版本字符串(非 schema 戳)
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapshotMeta {
|
export interface SnapshotMeta {
|
||||||
|
|||||||
@@ -576,7 +576,6 @@ const Stash = {
|
|||||||
await file.open()
|
await file.open()
|
||||||
st.world.syncRng()
|
st.world.syncRng()
|
||||||
await file.saveState(st.world.state, label)
|
await file.saveState(st.world.state, label)
|
||||||
await file.saveChronicle(st.world.state.chronicle)
|
|
||||||
const meta = metaFromState(st.world.state, st.slot)
|
const meta = metaFromState(st.world.state, st.slot)
|
||||||
await file.setMeta(meta)
|
await file.setMeta(meta)
|
||||||
await updateSlotMeta(st.slot, meta)
|
await updateSlotMeta(st.slot, meta)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export function metaFromState(state: GameState, slot: number): SaveMeta {
|
|||||||
members: alive,
|
members: alive,
|
||||||
reputation: state.family.reputation,
|
reputation: state.family.reputation,
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
version: state.schemaVersion
|
version: '0.1.35'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,18 +10,6 @@ import { GameState } from '../src/renderer/game/types/domain'
|
|||||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||||
|
|
||||||
describe('0.1.3 audit regression', () => {
|
describe('0.1.3 audit regression', () => {
|
||||||
it('loads a 0.1.0-era save (missing finance/yearStats fields)', () => {
|
|
||||||
const w = World.create({ seed: 'legacy', surname: '崔', familyName: '崔家', motto: 'm', difficulty: 'normal' })
|
|
||||||
const legacy = JSON.parse(JSON.stringify(w.state)) as GameState
|
|
||||||
delete (legacy as Record<string, unknown>)['finance']
|
|
||||||
delete (legacy as Record<string, unknown>)['yearStats']
|
|
||||||
delete (legacy as Record<string, unknown>)['yearlyReports']
|
|
||||||
const w2 = new World(legacy)
|
|
||||||
for (let i = 0; i < 40; i++) w2.advanceMonth()
|
|
||||||
expect(w2.state.yearlyReports.length).toBeGreaterThan(0)
|
|
||||||
expect(Number.isNaN(w2.state.family.stones)).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('marries a npc family once only (allied lock)', () => {
|
it('marries a npc family once only (allied lock)', () => {
|
||||||
const w = World.create({ seed: 'hy', surname: '卫', familyName: '卫家', motto: 'm', difficulty: 'normal' })
|
const w = World.create({ seed: 'hy', surname: '卫', familyName: '卫家', motto: 'm', difficulty: 'normal' })
|
||||||
const npcId = Object.keys(w.state.npcFamilies)[0]!
|
const npcId = Object.keys(w.state.npcFamilies)[0]!
|
||||||
|
|||||||
@@ -73,36 +73,6 @@ describe('志向矩阵', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('normalizeGameState 全残缺矩阵', () => {
|
|
||||||
it.each([
|
|
||||||
['finance', 'yearStats', 'yearlyReports', 'stats'].map((k) => [k] as const)
|
|
||||||
].flat())('%s 缺失补齐', (key) => {
|
|
||||||
const w = baseWorld('norm-' + key)
|
|
||||||
const state = JSON.parse(JSON.stringify(w.state))
|
|
||||||
delete (state as Record<string, unknown>)[key]
|
|
||||||
const fixed = normalizeGameState(state)
|
|
||||||
expect((fixed as Record<string, unknown>)[key]).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('无 headId 老档补齐首个存活着', () => {
|
|
||||||
const w = baseWorld('norm-head')
|
|
||||||
const state = JSON.parse(JSON.stringify(w.state))
|
|
||||||
state.family.headId = 'x-not-exist'
|
|
||||||
const fixed = normalizeGameState(state)
|
|
||||||
expect(fixed.family.headId).toBe('x1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('缺 npcFamilies 补齐空表(不崩外交)', () => {
|
|
||||||
const w = baseWorld('norm-npc')
|
|
||||||
const state = JSON.parse(JSON.stringify(w.state))
|
|
||||||
delete state.npcFamilies
|
|
||||||
const fixed = normalizeGameState(state)
|
|
||||||
const w2 = new World(fixed)
|
|
||||||
for (let i = 0; i < 3; i++) w2.advanceMonth() // 不抛
|
|
||||||
expect(true).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('大比寄读渡劫动态事件矩阵', () => {
|
describe('大比寄读渡劫动态事件矩阵', () => {
|
||||||
it.each([
|
it.each([
|
||||||
['ev-tournament-10', '称病不出'],
|
['ev-tournament-10', '称病不出'],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { World } from '../src/renderer/game/engine/runtime/World'
|
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||||
import { buildReport, verdictLine } from '../src/renderer/game/engine/narrative/legacyreport'
|
import { buildReport, verdictLine } from '../src/renderer/game/engine/narrative/legacyreport'
|
||||||
import { migrateIfNeeded, exportEnvelope, parseImportEnvelope, CURRENT_SCHEMA, APP_ID } from '../src/renderer/game/storage/migrate'
|
import { exportEnvelope, parseImportEnvelope, validateLoadedState } from '../src/renderer/game/storage/migrate'
|
||||||
import { FORMATIONS, formationById, FormationId } from '../src/renderer/game/data/formations'
|
import { FORMATIONS, formationById, FormationId } from '../src/renderer/game/data/formations'
|
||||||
import { resolveEncounter } from '../src/renderer/game/engine/runtime/Systems/combat'
|
import { resolveEncounter } from '../src/renderer/game/engine/runtime/Systems/combat'
|
||||||
import { ENEMIES } from '../src/renderer/game/data/secrets'
|
import { ENEMIES } from '../src/renderer/game/data/secrets'
|
||||||
@@ -56,53 +56,6 @@ describe('legacyreport 百年报告', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('存档迁移管线', () => {
|
|
||||||
it('CURRENT_SCHEMA=2 且 v1→v2 迁移补字段', () => {
|
|
||||||
expect(CURRENT_SCHEMA).toBe(2)
|
|
||||||
const w = baseWorld('mig-a')
|
|
||||||
const asV1 = JSON.parse(JSON.stringify(w.state))
|
|
||||||
asV1.schemaVersion = 1
|
|
||||||
delete asV1.stats
|
|
||||||
const res = migrateIfNeeded(asV1)
|
|
||||||
expect(res.ok).toBe(true)
|
|
||||||
const s = (res as never as { state: { stats: unknown; schemaVersion: number } }).state
|
|
||||||
expect(s.stats).toBeTruthy()
|
|
||||||
expect(s.schemaVersion).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('未来版本拒绝加载(防误用)', () => {
|
|
||||||
const w = baseWorld('mig-b')
|
|
||||||
const s = JSON.parse(JSON.stringify(w.state))
|
|
||||||
s.schemaVersion = 99
|
|
||||||
const res = migrateIfNeeded(s)
|
|
||||||
expect(res.ok).toBe(false)
|
|
||||||
expect((res as never as { error: { code: string } }).error.code).toBe('TOO_NEW')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('导出信封带 appId 与版本;导入旧版可迁移', () => {
|
|
||||||
const w = baseWorld('mig-c')
|
|
||||||
w.state.schemaVersion = CURRENT_SCHEMA
|
|
||||||
const env = JSON.parse(exportEnvelope(w.state))
|
|
||||||
expect(env.app).toBe(APP_ID)
|
|
||||||
expect(env.schemaVersion).toBe(2)
|
|
||||||
const parsed = parseImportEnvelope(JSON.stringify(env))
|
|
||||||
expect(parsed.ok).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('异 app 拒绝导入', () => {
|
|
||||||
const w = baseWorld('mig-d')
|
|
||||||
const env = JSON.parse(exportEnvelope(w.state))
|
|
||||||
env.app = 'other-game'
|
|
||||||
const parsed = parseImportEnvelope(JSON.stringify(env))
|
|
||||||
expect(parsed.ok).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('损坏 JSON 拒绝导入', () => {
|
|
||||||
const res = parseImportEnvelope('not-json-wrapper')
|
|
||||||
expect(res.ok).toBe(false)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
describe('战斗阵型', () => {
|
describe('战斗阵型', () => {
|
||||||
it.each(Object.keys(FORMATIONS).map((k) => [k as FormationId] as const))('%s 阵型定义合法', (id) => {
|
it.each(Object.keys(FORMATIONS).map((k) => [k as FormationId] as const))('%s 阵型定义合法', (id) => {
|
||||||
const f = FORMATIONS[id]
|
const f = FORMATIONS[id]
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ describe('0.1.30 矩阵:月末大圈(600 例级)', () => {
|
|||||||
expect(w.state.seq).toBeGreaterThan(0)
|
expect(w.state.seq).toBeGreaterThan(0)
|
||||||
expect(Array.isArray(w.state.completedEvents)).toBe(true)
|
expect(Array.isArray(w.state.completedEvents)).toBe(true)
|
||||||
expect(Array.isArray(w.state.missions)).toBe(true)
|
expect(Array.isArray(w.state.missions)).toBe(true)
|
||||||
expect(typeof w.state.schemaVersion).toBe('number')
|
expect(typeof w.state.seed).toBe('string') // 0.1.35 版本戳移除——seed 为世界锚
|
||||||
expect(typeof w.state.seed).toBe('string')
|
expect(typeof w.state.seed).toBe('string')
|
||||||
expect(Array.isArray(w.state.battles)).toBe(true)
|
expect(Array.isArray(w.state.battles)).toBe(true)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,18 +4,6 @@ import { normalizeGameState } from '../src/renderer/game/engine/runtime/World'
|
|||||||
import { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
|
import { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data'
|
||||||
|
|
||||||
describe('0.1.19 八方风雨', () => {
|
describe('0.1.19 八方风雨', () => {
|
||||||
it('兜底:空 worldSim 的旧档 normalize 后 tideTicks 有值(P0 修复)', () => {
|
|
||||||
const w = World.create({ seed: 'p0-1', surname: '甯', familyName: '甯家', motto: 'm', difficulty: 'normal' })
|
|
||||||
const state = w.state as never
|
|
||||||
const fixed = normalizeGameState({
|
|
||||||
...(state as Record<string, unknown>),
|
|
||||||
worldSim: { marketPool: {}, npcDyn: {}, secretQi: {}, newsFeed: [] }
|
|
||||||
} as never) as { worldSim: { tideTicks: number; era: string; calamityLeft: number } }
|
|
||||||
expect(fixed.worldSim.tideTicks).toBe(0)
|
|
||||||
expect(fixed.worldSim.era).toBe('pingshi')
|
|
||||||
expect(fixed.worldSim.calamityLeft).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('stance:四姿态值域 + 年首(约8年)可再评估', () => {
|
it('stance:四姿态值域 + 年首(约8年)可再评估', () => {
|
||||||
const w = World.create({ seed: 'st-1', surname: '冀', familyName: '冀家', motto: 'm', difficulty: 'normal' })
|
const w = World.create({ seed: 'st-1', surname: '冀', familyName: '冀家', motto: 'm', difficulty: 'normal' })
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>()
|
||||||
|
|||||||
Reference in New Issue
Block a user