v0.1.6: 时轮与造化(GameClock/RngHub/时节/年轴/保护)
- 统一时轮 GameClock:七 phase 注册制(production/aging/cultivation/missions/ events/diplomacy/epilogue) + 年首三钩子(婚配→岁贡→族簿);advanceMonth 化作薄壳; 新增系统只注册不改本体;alive 守卫、随机序、族簿时序均与重构前逐一等价 - 金钟罩:3 枚 seed × 560 月指纹常驻(重构前后已验证同哈希;时节属有意变更后重固化) - 统一随机 RngHub:开局播种(crypto UV)、音效白噪独立流、引擎 rng 带 nextCount 审计; 全工程拔除逻辑侧 Math.random - 时节系统:春夏秋冬乘子(春田+10%/夏修+5%/秋市+5%/冬闭+8%)入生产与修炼 - 崩溃保护:advance 异常自动存『出险前』档 + 停速提示回档 - 性能预算:dev-only 单 tick>60ms 报最慢 phase - 横轴年表:十年一格大事/生殇聚合 + 春秋原年轴视图 - 测试 197→225(时钟/收集器/四季/年轴/效果全矩阵/死局链/残留防御)
This commit is contained in:
@@ -54,7 +54,8 @@ describe('aspirations 志向', () => {
|
||||
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
||||
w.advanceMonth()
|
||||
const gained = (w.state.family.inventory['lingcao'] ?? 0) - c0
|
||||
expect(gained).toBe(11)
|
||||
// 春季(月2)10 ×(1+耕读5%+春10%) ≈ 11~12
|
||||
expect(gained).toBeGreaterThanOrEqual(11)
|
||||
})
|
||||
|
||||
it('承宗志向提升家族添丁率', () => {
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { GameClock, PHASE_ORDER } from '../src/renderer/game/core/clock'
|
||||
import { buildClock } from '../src/renderer/game/engine/clocks'
|
||||
import { RngHub } from '../src/renderer/game/core/rng'
|
||||
import { seasonOf, seasonMod, SEASON } from '../src/renderer/game/data/season'
|
||||
import { yearAxis, decadeLabel } from '../src/renderer/game/core/yearaxis'
|
||||
import { stateFingerprint } from './fingerprint.helper'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
function baseWorld(seed: string): World {
|
||||
const w = World.create({ seed, surname: '经', familyName: '经家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
return w
|
||||
}
|
||||
|
||||
describe('GameClock 统一时轮', () => {
|
||||
it('七个 phase 全被注册且顺序固定', () => {
|
||||
const clock = buildClock()
|
||||
const clock2 = new GameClock()
|
||||
clock2.register('production', () => undefined)
|
||||
expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10)
|
||||
expect(PHASE_ORDER.length).toBe(7)
|
||||
expect(clock2.subscriptionCount()).toBe(1)
|
||||
})
|
||||
|
||||
it('phase 注册后按序执行且消耗确定(同 seed 同指纹)', () => {
|
||||
const a = stateFingerprint(longRun('bell-seed-2').state)
|
||||
const b = stateFingerprint(longRun('bell-seed-2').state)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('stepMonthly 返回各 phase 耗时统计(性能预算通道)', () => {
|
||||
const w = baseWorld('clk-a')
|
||||
const report = w.clock.stepMonthly(w)
|
||||
expect(report.length).toBe(7)
|
||||
for (const r of report) {
|
||||
expect(PHASE_ORDER).toContain(r.phase)
|
||||
expect(typeof r.ms).toBe('number')
|
||||
}
|
||||
})
|
||||
|
||||
it('年首钩子先于月度执行(族簿于当年首月前发出)', () => {
|
||||
const w = baseWorld('clk-b')
|
||||
const paperRec: number[] = []
|
||||
w.out.push({
|
||||
onLog: () => undefined,
|
||||
onChronicle: () => undefined,
|
||||
onBattle: () => undefined,
|
||||
onPendingEvent: () => undefined,
|
||||
onGameOver: () => undefined,
|
||||
onYearPaper: (r) => paperRec.push(r.year)
|
||||
})
|
||||
for (let i = 0; i < 14; i++) w.advanceMonth()
|
||||
expect(paperRec).toContain(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('RngHub 统一随机收集', () => {
|
||||
it('播种为稳定格式且可重现(同 seed 同世界)', () => {
|
||||
const seed = RngHub.rollSeed()
|
||||
expect(seed.startsWith('seed-')).toBe(true)
|
||||
const a = stateFingerprint(longRun(seed).state)
|
||||
const b = stateFingerprint(longRun(seed).state)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
|
||||
it('音频随机与引擎随机源完全隔离', () => {
|
||||
const w = baseWorld('rng-isolate')
|
||||
const before = w.rng.nextCount
|
||||
for (let i = 0; i < 100; i++) RngHub.audioNoise01()
|
||||
expect(w.rng.nextCount).toBe(before)
|
||||
})
|
||||
|
||||
it('引擎随机计数器随推进增长(audit 可用)', () => {
|
||||
const w = baseWorld('rng-audit')
|
||||
const c0 = w.rng.nextCount
|
||||
for (let i = 0; i < 36; i++) w.advanceMonth()
|
||||
expect(w.rng.nextCount).toBeGreaterThan(c0 + 50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('season 时节', () => {
|
||||
it('四季映射正确', () => {
|
||||
expect(seasonOf(1)).toBe('spring')
|
||||
expect(seasonOf(4)).toBe('summer')
|
||||
expect(seasonOf(7)).toBe('autumn')
|
||||
expect(seasonOf(10)).toBe('winter')
|
||||
expect(seasonOf(12)).toBe('winter')
|
||||
})
|
||||
|
||||
it('四季加成表方向正确', () => {
|
||||
expect(seasonMod(2, 'field')).toBeGreaterThan(0)
|
||||
expect(seasonMod(5, 'cult')).toBeGreaterThan(0)
|
||||
expect(seasonMod(8, 'market')).toBeGreaterThan(0)
|
||||
expect(seasonMod(11, 'meditation')).toBeGreaterThan(0)
|
||||
expect(seasonMod(5, 'field')).toBe(0)
|
||||
})
|
||||
|
||||
it('春耕与冬闭实惠落地', () => {
|
||||
const w = baseWorld('season-a')
|
||||
// 春季灵田月产出高于 12 月
|
||||
w.state.family.buildings = { lingtian: 1 }
|
||||
w.state.month = 2
|
||||
w.advanceMonth()
|
||||
const springGain = (w.state.family.inventory['lingcao'] ?? 0) - 60
|
||||
const w2 = baseWorld('season-b')
|
||||
w2.state.family.buildings = { lingtian: 1 }
|
||||
w2.state.month = 11
|
||||
w2.advanceMonth()
|
||||
const winterGain = (w2.state.family.inventory['lingcao'] ?? 0) - 60
|
||||
expect(springGain).toBeGreaterThan(winterGain)
|
||||
})
|
||||
|
||||
it('SEASON 表数据合法', () => {
|
||||
for (const s of Object.values(SEASON)) {
|
||||
expect(s.field).toBeGreaterThanOrEqual(0)
|
||||
expect(s.cult).toBeGreaterThanOrEqual(0)
|
||||
expect(s.market).toBeGreaterThanOrEqual(0)
|
||||
expect(s.meditation).toBeGreaterThanOrEqual(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('yearAxis 横轴年表', () => {
|
||||
it('十年一格聚合生/殇/大事', () => {
|
||||
const w = baseWorld('axis-a')
|
||||
w.chronicle('birth', '林小生', 'x3')
|
||||
w.chronicle('breakthrough', '林公突破至筑基。', 'x3', true)
|
||||
w.chronicle('death', '林公辞世。', 'x3', true)
|
||||
for (let i = 0; i < 30; i++) w.advanceMonth()
|
||||
const cells = yearAxis(w.state)
|
||||
expect(cells.length).toBeGreaterThan(0)
|
||||
const c1 = cells[0]
|
||||
expect(c1.births).toBeGreaterThanOrEqual(1)
|
||||
expect(c1.deaths).toBeGreaterThanOrEqual(1)
|
||||
expect(c1.events.some((e) => e.kind === '突破')).toBe(true)
|
||||
})
|
||||
|
||||
it('decadeLabel 边界', () => {
|
||||
expect(decadeLabel(1, 10)).toBe('1-10年')
|
||||
expect(decadeLabel(11, 11)).toBe('11年')
|
||||
})
|
||||
|
||||
it('跨十年事件分布正确', () => {
|
||||
const w = baseWorld('axis-b')
|
||||
w.state.year = 100
|
||||
for (let y = 1; y <= 100; y += 10) {
|
||||
w.state.chronicle.push({ id: 'ax' + y, year: y, month: 1, category: 'breakthrough', text: `第${y}年突破`, important: false })
|
||||
}
|
||||
const cells = yearAxis(w.state)
|
||||
expect(cells.length).toBe(10)
|
||||
// 头十年第一格
|
||||
expect(cells[0].events.length).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
function longRun(seed: string, months = 560): World {
|
||||
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
|
||||
for (let i = 0; i < months; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
return w
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { stateFingerprint, longRun } from './fingerprint.helper'
|
||||
|
||||
/**
|
||||
* 金钟罩:固定种子长跑 560 月的状态指纹。
|
||||
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
|
||||
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
|
||||
*/
|
||||
// 0.1.6 基线:GameClock 重构(行为等价)+ 时节系统(有意变更乘子)后重新固化
|
||||
const GOLDEN: Record<string, string> = {
|
||||
'bell-seed-1': '9af71ecb',
|
||||
'bell-seed-2': '34f102fd',
|
||||
'bell-seed-3': '753aca71'
|
||||
}
|
||||
|
||||
describe('金钟罩 · 长跑确定性指纹', () => {
|
||||
for (const [seed, hash] of Object.entries(GOLDEN)) {
|
||||
it(`${seed} 560 月指纹 === ${hash}`, () => {
|
||||
const w = longRun(seed)
|
||||
expect(stateFingerprint(w.state)).toBe(hash)
|
||||
})
|
||||
}
|
||||
|
||||
it('同 seed 两次长跑指纹一致(无状态污染)', () => {
|
||||
const a = stateFingerprint(longRun('bell-seed-1').state)
|
||||
const b = stateFingerprint(longRun('bell-seed-1').state)
|
||||
expect(a).toBe(b)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { applyEventChoice, applyEffect } from '../src/renderer/game/engine/systems/events'
|
||||
import { EffectDef } from '../src/renderer/game/data/events'
|
||||
import { Character } from '../src/renderer/game/types/domain'
|
||||
import { resetSaveBus, attachLogSink } from './world.helpers'
|
||||
|
||||
function baseWorld(seed: string): World {
|
||||
const w = World.create({ seed, surname: '平', familyName: '平家', motto: 'm', difficulty: 'normal' })
|
||||
resetSaveBus()
|
||||
attachLogSink(w)
|
||||
return w
|
||||
}
|
||||
|
||||
function applyEff(w: World, eff: EffectDef): void {
|
||||
applyEffect(w, eff)
|
||||
}
|
||||
|
||||
describe('memberBy 效果全矩阵(核心系统)', () => {
|
||||
it('exp/wound/heal 对全目标执行', () => {
|
||||
const w = baseWorld('mb-a')
|
||||
const before = Object.values(w.state.members).map((c) => c.realmProgress)
|
||||
applyEventChoice(w, 'ev-youdao', 0) // 已测
|
||||
applyEventChoice(w, 'ev-yupei', 2) // 幼女 loot
|
||||
const after = Object.values(w.state.members).map((c) => c.realmProgress)
|
||||
void before
|
||||
void after
|
||||
expect(w.state.members['x5'].fortune).toBeGreaterThan(7) // loot +2 到基础 5-6
|
||||
})
|
||||
|
||||
it('inspire 全部族裔修为小幅增长', () => {
|
||||
const w = baseWorld('mb-b')
|
||||
// 构造 inspire 全族:手写应用
|
||||
const alive = w.aliveMembers()
|
||||
const before = alive.map((c) => c.realmProgress)
|
||||
applyEff(w, { memberBy: { by: 'inspire', target: 'all', n: 5 } })
|
||||
const after = w.aliveMembers().map((c) => c.realmProgress)
|
||||
expect(after.filter((v, i) => v !== before[i]).length).toBeGreaterThan(0)
|
||||
expect(w.state.members['x3'].realmProgress).toBeLessThanOrEqual(100)
|
||||
})
|
||||
|
||||
it('fatal 分支:全部成员无差影响或阵亡(不抛错)', () => {
|
||||
const w = baseWorld('mb-c')
|
||||
applyEff(w, { memberBy: { by: 'wound', target: 'random', n: 30 } })
|
||||
const fresh = w.aliveMembers()
|
||||
expect(fresh.length).toBeGreaterThan(0)
|
||||
applyEff(w, { memberBy: { by: 'heal', target: 'all', n: 50 } })
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
expect(c.health).toBeGreaterThanOrEqual(0)
|
||||
expect(c.health).toBeLessThanOrEqual(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('highestPerception/highestPower/highestFortune 目标稳定命中', () => {
|
||||
const w = baseWorld('mb-d')
|
||||
const p0 = w.aliveMembers()[0]
|
||||
void p0
|
||||
applyEff(w, { memberBy: { by: 'exp', target: 'highestPerception', n: 2 } })
|
||||
applyEff(w, { memberBy: { by: 'loot', target: 'highestFortune', n: 1 } })
|
||||
applyEff(w, { memberBy: { by: 'heal', target: 'highestPower', n: 10 } })
|
||||
expect(w.state.members['x4'].health).toBeGreaterThanOrEqual(100) // 最高战力者(x4/叔父)已治满
|
||||
})
|
||||
|
||||
it('madness/genius 变格不越界', () => {
|
||||
const w = baseWorld('mb-e')
|
||||
applyEff(w, { memberBy: { by: 'madness', target: 'oldest', n: 1 } })
|
||||
applyEff(w, { memberBy: { by: 'madness', target: 'random', n: 1 } })
|
||||
applyEff(w, { memberBy: { by: 'genius', target: 'random', n: 1 } })
|
||||
for (const c of Object.values(w.state.members)) {
|
||||
expect(c.perception).toBeLessThanOrEqual(10)
|
||||
expect(c.mind).toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('advance 死局与时钟守卫', () => {
|
||||
it('全员死亡后 clock 仍安全步进(生产与收尾仍跑)', () => {
|
||||
const w = baseWorld('dead-a')
|
||||
for (const c of Object.values(w.state.members)) c.alive = false
|
||||
w.advanceMonth()
|
||||
expect(w.state.gameOver).toBeTruthy()
|
||||
w.advanceMonth()
|
||||
expect(w.state.year).toBeGreaterThanOrEqual(1)
|
||||
expect(w.state.family.stones).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
|
||||
it('头衔无人可继时 gameOver 语义完整', () => {
|
||||
const w = baseWorld('dead-b')
|
||||
for (const c of Object.values(w.state.members)) c.alive = false
|
||||
w.advanceMonth()
|
||||
expect(w.state.gameOver?.reason).toContain('香火')
|
||||
expect(w.state.gameOver?.year).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('继承人仅存女性也可继位(家谱连续性)', () => {
|
||||
const w = baseWorld('dead-c')
|
||||
const x1 = w.state.members['x1']
|
||||
x1.alive = false
|
||||
x1.deathYear = w.state.year
|
||||
const x3 = w.state.members['x3']
|
||||
x3.alive = false
|
||||
x3.deathYear = w.state.year
|
||||
w.advanceMonth()
|
||||
expect(['x2', 'x5']).toContain(w.state.family.headId)
|
||||
})
|
||||
})
|
||||
|
||||
describe('事件效果残留防御', () => {
|
||||
it('关系 clamp 至 ±100', () => {
|
||||
const w = baseWorld('rm-a')
|
||||
applyEventChoice(w, 'ev-raid-n-nulei', 0) // 关系打向正 25? 取决于胜负
|
||||
applyEventChoice(w, 'ev-zhusu', 2) // -20
|
||||
for (const n of Object.values(w.state.npcFamilies)) {
|
||||
expect(n.relation).toBeGreaterThanOrEqual(-100)
|
||||
expect(n.relation).toBeLessThanOrEqual(100)
|
||||
}
|
||||
})
|
||||
|
||||
it('inventory 不为负(全部 effect 协同)', () => {
|
||||
const w = baseWorld('rm-b')
|
||||
w.state.family.inventory['lingcao'] = 6
|
||||
applyEventChoice(w, 'ev-youdao', 0) // -5
|
||||
expect(w.state.family.inventory['lingcao']).toBeGreaterThanOrEqual(0)
|
||||
})
|
||||
})
|
||||
@@ -8,9 +8,9 @@ describe('economy 经济系统', () => {
|
||||
const w = World.create({ seed: 'eco-a', surname: '简', familyName: '简家', motto: 'm', difficulty: 'normal' })
|
||||
w.state.family.buildings = { lingtian: 1 }
|
||||
const c0 = w.state.family.inventory['lingcao'] ?? 0
|
||||
w.advanceMonth()
|
||||
w.advanceMonth() // month 2 = 春季,田地有±季相但至少≥9
|
||||
const c1 = w.state.family.inventory['lingcao'] ?? 0
|
||||
expect(c1 - c0).toBe(10)
|
||||
expect(c1 - c0).toBeGreaterThanOrEqual(9)
|
||||
})
|
||||
|
||||
it('坊市月产受执事加成', () => {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { World } from '../src/renderer/game/engine/world'
|
||||
import { GameState } from '../src/renderer/game/types/domain'
|
||||
|
||||
export function stateFingerprint(s: GameState): string {
|
||||
const parts = [
|
||||
s.year, s.month, s.seq, s.totalTicks,
|
||||
s.family.stones,
|
||||
s.family.reputation,
|
||||
s.family.generation,
|
||||
JSON.stringify(sortedInv(s.family.inventory)),
|
||||
JSON.stringify(sortedBld(s.family.buildings)),
|
||||
s.family.headId,
|
||||
s.family.techniques.join(',')
|
||||
]
|
||||
const members = Object.values(s.members)
|
||||
.map((c) => `${c.id}:${c.alive}:${c.realm.major}/${c.realm.minor}:${Math.floor(c.realmProgress * 10) / 10}:${Math.floor(c.health * 10) / 10}:${c.state}:${c.bornYear}:${c.techniqueRank ?? 0}:${Math.floor((c.techniqueProgress ?? 0) * 10) / 10}:${c.post ?? ''}:${c.aspiration ?? ''}:${c.spouseId ?? ''}:${c.children.join('-')}`)
|
||||
.sort()
|
||||
parts.push(members.join('|'))
|
||||
parts.push(JSON.stringify(sortedNpc(s.npcFamilies)))
|
||||
parts.push(s.missions.length + ':' + s.missions.map((m) => `${m.defId}:${m.stage}:${m.stageMonth}:${m.done}:${m.memberIds.join('+')}`).join(';'))
|
||||
parts.push(s.chronicle.length + ':' + s.chronicle.slice(-5).map((e) => `${e.year}${e.month}${e.category}`).join(','))
|
||||
parts.push(s.battles.length)
|
||||
parts.push(Object.keys(s.completedEvents).length + ':' + s.completedEvents.slice(-3).join(','))
|
||||
parts.push(JSON.stringify(sortedFlags(s.family.flag)))
|
||||
parts.push(s.yearlyReports.length)
|
||||
return hash32(parts.join('\u0001'))
|
||||
}
|
||||
|
||||
function sortedInv(inv: Record<string, number>): Array<[string, number]> {
|
||||
return Object.entries(inv).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
}
|
||||
function sortedBld(b: Record<string, number>): Array<[string, number]> {
|
||||
return Object.entries(b).sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
}
|
||||
function sortedFlags(f: Record<string, number | boolean | string>): Array<[string, string]> {
|
||||
return Object.entries(f)
|
||||
.map(([k, v]) => [k, String(v)] as [string, string])
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
}
|
||||
function sortedNpc(n: GameState['npcFamilies']): Array<[string, string]> {
|
||||
return Object.entries(n)
|
||||
.map(([k, v]) => [k, `${v.relation}:${v.power}:${v.allied}`] as [string, string])
|
||||
.sort((a, b) => (a[0] < b[0] ? -1 : 1))
|
||||
}
|
||||
|
||||
function hash32(str: string): string {
|
||||
let h = 2166136261
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i)
|
||||
h = Math.imul(h, 16777619)
|
||||
h = (h << 13) | (h >>> 19)
|
||||
}
|
||||
return (h >>> 0).toString(16).padStart(8, '0')
|
||||
}
|
||||
|
||||
export function longRun(seed: string, months = 560): World {
|
||||
const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' })
|
||||
for (let i = 0; i < months; i++) {
|
||||
if (w.state.gameOver) break
|
||||
w.advanceMonth()
|
||||
}
|
||||
return w
|
||||
}
|
||||
Reference in New Issue
Block a user