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:
2026-08-23 22:35:17 +08:00
parent 1b1dfe8ac8
commit 9b0f36c70a
15 changed files with 44 additions and 285 deletions
+5 -5
View File
@@ -42,20 +42,20 @@ export class GameEngine {
this.kernel = makeKernel(this._seed)
this.datapack = PACK
if (opts.datapack) this.datapack.override(opts.datapack)
// 组装World(内核注入→时钟/随机/总线单源)
// 组装World(内核注入→时钟/随机/总线单源0.1.35 移除 __state 泄漏口——seed 恒用 _seed
if (opts.noAutoCreate) {
this.world = new World(createWorldState({
seed: this._seed, surname: '占位', familyName: '占位', motto: '', difficulty: 'normal'
}), [], this.kernel)
} else {
const state = opts.seed ? createWorldState({
const state = createWorldState({
seed: this._seed,
surname: opts.surname?.trim() || '林',
familyName: opts.familyName?.trim() || `${opts.surname?.trim() || '林'}`,
motto: opts.motto?.trim() || '耕读传家,术法继世',
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)
// Kernel 总线 → World.out 桥接(attached 后同步 feed
@@ -132,7 +132,7 @@ export class GameEngine {
}
}
/** 已用旧内核时钟注册的世界快照,反序列化时公用(便捷入口) */
/** 已用旧内核时钟注册的世界快照,反序列化时公用(测试/便捷入口——非兼容机器 */
export function engineFromSnapshot(state: GameState): GameEngine {
const eng = new GameEngine({ seed: state.seed, noAutoCreate: true })
eng.restore(state)
+2 -78
View File
@@ -78,90 +78,14 @@ export function normalizeGameState(state: GameState): GameState {
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) {
state.worldSim.marketPool = state.worldSim.marketPool ?? {}
state.worldSim.npcDyn = state.worldSim.npcDyn ?? {}
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
// 0.1.35 收敛:初始字段职责归 initSim(WorldSim.ts),此处仅防脏(异常落盘/测试注入)
// 防护一:era 非法值兜底(语义防护——主产物 initSim 造)
if (state.worldSim.era !== 'shengshi' && state.worldSim.era !== 'pingshi' && state.worldSim.era !== 'luanshi' && state.worldSim.era !== 'mofa') {
state.worldSim.era = 'pingshi'
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
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]) {
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 state: GameState = {
schemaVersion: 1,
seed: opts.seed,
worldGen: {
relations: worldGen.relations,
@@ -95,6 +95,9 @@ export function newCharacter(
state: 'idle',
health: 100,
alive: true,
techniqueRank: 0,
techniqueProgress: 0,
tribBoost: 0,
isHead: opts.isHead,
isFounder: opts.isFounder
}
@@ -81,7 +81,11 @@ export function makeWorldSimState(): WorldSimState {
era: 'pingshi',
eraStartYear: 1,
overfluxYear: -99,
worldAnnals: []
worldAnnals: [],
warFatigue: 0,
shortage: {},
shortageWarned: {},
wonders: {}
}
}
+13 -56
View File
@@ -1,75 +1,32 @@
import { GameState } from '../types/domain'
import { normalizeGameState } from '../engine/runtime/World'
export const CURRENT_SCHEMA = 2
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 逐级执行(任何一步失败即中断抛出,原状态不被写入)
* v1(≤0.1.8)→ v20.1.9):补 stats/finance/annals 字段并打上 schema 版本戳
* 0.1.35 荡涤:迁移链(CURRENT_SCHEMA/MIGRATIONS/migrateIfNeeded)整体铲除
* 未发布游戏不做版本迁移——读档只需结构粗检 + World 构造器 normalize 兜底/防护
*/
const MIGRATIONS: Array<{ from: number; to: number; fn: (s: GameState) => GameState }> = [
{
from: 1,
to: 2,
fn: (s) => {
normalizeGameState(s)
s.schemaVersion = 2
return s
}
export function validateLoadedState(state: unknown): { ok: true; state: GameState } | { ok: false; message: string } {
const s = state as GameState | null
if (!s || typeof s !== 'object') return { ok: false, message: '存档内容为空或损坏。' }
if (!s.family || !s.members || typeof s.seed !== 'string') {
return { ok: false, message: '存档结构缺失(family/members/seed)——本作未发布变更频繁,旧档不可读。' }
}
]
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 }
return { ok: true, state: s }
}
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 } {
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 标识不符)。' }
const state = raw.payload ?? raw.state
if (!state || typeof state !== 'object') return { ok: false, error: '存档内容为空或损坏。' }
const migrated = migrateIfNeeded(state as GameState)
if (migrated.ok) return { ok: true, state: migrated.state }
return { ok: false, error: migrated.error.message }
const checked = validateLoadedState(state)
if (checked.ok) return { ok: true, state: checked.state }
return { ok: false, error: checked.message }
} catch (e) {
return { ok: false, error: `解析失败:${String(e)}` }
}
+8 -33
View File
@@ -1,5 +1,7 @@
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-'
let seqCounter = 0
@@ -36,12 +38,10 @@ export class SaveSlot {
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)}-${(seqCounter++ % 1296).toString(36)}`
state.schemaVersion = CURRENT_SCHEMA
const json = JSON.stringify(state)
await this.driver.run(
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
@@ -60,29 +60,6 @@ export class SaveSlot {
return id
}
/** P1-T chronicle 表已废(write-only 无读取者;史书数据随快照 data 保存)——保持 no-op 接口 */
async saveChronicle(_chronicle: GameState['chronicle']): Promise<void> {
return
/* eslint-disable no-unreachable */
try {
// legacy0.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[]> {
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 }))
@@ -97,8 +74,8 @@ export class SaveSlot {
)
if (!rows || rows.length === 0) return null
const state = JSON.parse(rows[0]!.data) as GameState
const r = migrateIfNeeded(state)
if (!r.ok) throw new MigrationError(r.error.code, r.error.version, r.error.message)
const r = validateLoadedState(state)
if (!r.ok) throw new Error(r.message)
return r.state
}
@@ -126,8 +103,8 @@ export class SaveSlot {
const state = await this.loadState()
if (!state) throw new Error('无可导出存档')
const meta = await this.getMeta()
const envelope = JSON.parse(exportEnvelope(state)) as { schemaVersion: number; payload: GameState }
return JSON.stringify({ app: APP_ID, schemaVersion: envelope.schemaVersion, meta, state: envelope.payload })
const envelope = JSON.parse(exportEnvelope(state)) as { payload: GameState }
return JSON.stringify({ app: 'cotyc', meta, payload: envelope.payload })
}
async importAll(data: string): Promise<boolean> {
@@ -135,10 +112,8 @@ export class SaveSlot {
const parsed = parseImportEnvelope(data)
if (!parsed.ok) return false
const state = parsed.state
state.schemaVersion = CURRENT_SCHEMA
await this.driver.exec(`DELETE FROM snapshot`)
await this.driver.exec(`DELETE FROM meta`)
await this.driver.exec(`DELETE FROM chronicle`)
await this.saveState(state, 'import')
const aliveCount = Object.values(state.members).filter((c) => c.alive).length
await this.setMeta(buildSimpleMeta(state, this.slot, aliveCount))
@@ -167,7 +142,7 @@ export function buildSimpleMeta(state: GameState, slot: number, memberCount: num
members: memberCount,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: state.schemaVersion
version: GAME_VERSION
}
}
+5 -6
View File
@@ -42,8 +42,8 @@ export interface Character {
fortune: number
traits: string[]
techniqueId?: string
techniqueRank?: number
techniqueProgress?: number
techniqueRank: number // 0.1.35 必填(pcgen 必写——归一化兼容批已删)
techniqueProgress: number
post?: string
equipment?: string
state: CharState
@@ -58,7 +58,7 @@ export interface Character {
tribDelayYear?: number
tribPendingMonths?: number
/** 渡劫药力加成(破境丹等),渡劫结算后清零 */
tribBoost?: number
tribBoost: number
}
export interface FamilyState {
@@ -164,7 +164,6 @@ export interface YearlyReport {
}
export interface GameState {
schemaVersion: number
seed: string
rng: RngState
year: number
@@ -190,7 +189,7 @@ export interface GameState {
/** 读档时缺失的插件(工厂未注册/版本不符)——UI 提示:MOD 文件可能需重新放入 mods 目录 */
pluginsMissing?: string[]
/** 天下轮廓(种子生成器产物:关系网/开局 era/市场偏移/世仇盟约) */
worldGen?: {
worldGen: {
relations: Record<string, Record<string, number>>
eras: 'shengshi' | 'pingshi' | 'luanshi'
marketOffset: Record<string, number>
@@ -258,7 +257,7 @@ export interface SaveMeta {
members: number
reputation: number
updatedAt: string
version: number
version: string // 0.1.35 记录游戏版本字符串(非 schema 戳)
}
export interface SnapshotMeta {
-1
View File
@@ -576,7 +576,6 @@ const Stash = {
await file.open()
st.world.syncRng()
await file.saveState(st.world.state, label)
await file.saveChronicle(st.world.state.chronicle)
const meta = metaFromState(st.world.state, st.slot)
await file.setMeta(meta)
await updateSlotMeta(st.slot, meta)
+1 -1
View File
@@ -14,7 +14,7 @@ export function metaFromState(state: GameState, slot: number): SaveMeta {
members: alive,
reputation: state.family.reputation,
updatedAt: new Date().toISOString(),
version: state.schemaVersion
version: '0.1.35'
}
}