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.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)
|
||||
|
||||
@@ -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: {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)→ v2(0.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)}` }
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
// 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[]> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,18 +10,6 @@ import { GameState } from '../src/renderer/game/types/domain'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
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)', () => {
|
||||
const w = World.create({ seed: 'hy', surname: '卫', familyName: '卫家', motto: 'm', difficulty: 'normal' })
|
||||
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('大比寄读渡劫动态事件矩阵', () => {
|
||||
it.each([
|
||||
['ev-tournament-10', '称病不出'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/runtime/World'
|
||||
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 { resolveEncounter } from '../src/renderer/game/engine/runtime/Systems/combat'
|
||||
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('战斗阵型', () => {
|
||||
it.each(Object.keys(FORMATIONS).map((k) => [k as FormationId] as const))('%s 阵型定义合法', (id) => {
|
||||
const f = FORMATIONS[id]
|
||||
|
||||
@@ -67,7 +67,7 @@ describe('0.1.30 矩阵:月末大圈(600 例级)', () => {
|
||||
expect(w.state.seq).toBeGreaterThan(0)
|
||||
expect(Array.isArray(w.state.completedEvents)).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(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'
|
||||
|
||||
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年)可再评估', () => {
|
||||
const w = World.create({ seed: 'st-1', surname: '冀', familyName: '冀家', motto: 'm', difficulty: 'normal' })
|
||||
const seen = new Set<string>()
|
||||
|
||||
Reference in New Issue
Block a user