diff --git a/src/renderer/game/engine/GameEngine.ts b/src/renderer/game/engine/GameEngine.ts index 254e59e..32dc898 100644 --- a/src/renderer/game/engine/GameEngine.ts +++ b/src/renderer/game/engine/GameEngine.ts @@ -55,26 +55,8 @@ export class GameEngine { advance(): void { if (this.world.state.gameOver) return - const kernelClock = this.kernel.clock - const worldClock = this.world.clock - // 内核统一驱动(执行内核时钟注册,world.clock 仅作镜像) - void worldClock - this.tickOn(kernelClock) + this.world.advanceMonth() this.tickSinceSave++ - this.world.syncRng() - } - - private tickOn(clock: GameClock): void { - const w = this.world - const s = w.state - s.month++ - if (s.month > 12) { - s.month = 1 - s.year++ - clock.fireYearStart(w as never) - } - s.totalTicks++ - clock.stepMonthly(w as never) } syncRng(): void { diff --git a/src/renderer/game/engine/kernel/clock.ts b/src/renderer/game/engine/kernel/clock.ts index b27c8ad..8d77ca4 100644 --- a/src/renderer/game/engine/kernel/clock.ts +++ b/src/renderer/game/engine/kernel/clock.ts @@ -14,6 +14,7 @@ export type PhaseId = | 'missions' | 'events' | 'diplomacy' + | 'worldsim' | 'epilogue' export type SystemHook = (w: World) => void @@ -31,6 +32,7 @@ export const PHASE_ORDER: PhaseId[] = [ 'missions', 'events', 'diplomacy', + 'worldsim', 'epilogue' ] diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index c3c9393..5023965 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -53,6 +53,9 @@ export function monthlyRate(w: World, c: Character): number { if (w.ageOf(c) < 8) rate *= 0.4 if (w.ageOf(c) > 65) rate *= 0.72 rate *= masteryRateOfMajor(c.realm.major) + // 世界灵气潮汐 + const tide = w.state.worldSim?.tide ?? 1 + rate *= tide return rate } diff --git a/src/renderer/game/engine/runtime/Systems/missions.ts b/src/renderer/game/engine/runtime/Systems/missions.ts index 04839c6..336320f 100644 --- a/src/renderer/game/engine/runtime/Systems/missions.ts +++ b/src/renderer/game/engine/runtime/Systems/missions.ts @@ -138,6 +138,15 @@ export function sendMission(w: World, defId: string, members: string[], formatio const c = w.memberById(id) c.state = 'expedition' }) + // 世界秘境灵气消耗(有 sim 时) + if (w.state.worldSim?.secretQi) { + const qi = w.state.worldSim.secretQi[def.id] as number | undefined + if (qi !== undefined) { + w.state.worldSim.secretQi[def.id] = Math.max(0, qi - 6) + } else { + w.state.worldSim.secretQi[def.id] = 44 + } + } w.state.missions.push(m) w.state.family.missionIds.push(m.id) w.log('info', `队伍出发探索【${def.name}】。`) diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 39e0c16..b1d70d0 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -45,6 +45,12 @@ export interface WorldEventBus { onPluginChange?(id: string, action: string): void } +import { WorldSim } from '../sim/WorldSim' + +export function worldSimOf(w: World): WorldSim { + return new WorldSim(w) +} + export function normalizeGameState(state: GameState): GameState { // 老版本存档(<0.1.1)缺少新增字段,加载时补齐,避免运行期 undefined 崩溃 if (!state.finance) state.finance = { accum: 0 } @@ -99,12 +105,12 @@ export class World { normalizeGameState(state) this.state = state this.out = out - this.clock = emptyClock() if (kernel) { + this.clock = kernel.clock this.kernel = kernel this.rng = kernel.rng - clockFromKernel(this.clock, kernel.clock) } else { + this.clock = emptyClock() this.rng = new Rng(state.rng) } this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }])) diff --git a/src/renderer/game/engine/runtime/clocks.ts b/src/renderer/game/engine/runtime/clocks.ts index 087d572..f476c96 100644 --- a/src/renderer/game/engine/runtime/clocks.ts +++ b/src/renderer/game/engine/runtime/clocks.ts @@ -8,6 +8,7 @@ import { missionTick } from './Systems/missions' import { eventRoll } from './Systems/events' import { diplomacyTick } from './Systems/diplomacy' import { yearStartMarriage } from './Systems/marriage' +import { WorldSim } from '../sim/WorldSim' /** 原语义保留:满门凋零后仅存生产/寿元与收尾 */ function ifAlive(fn: (w: World) => void): (w: World) => void { @@ -55,6 +56,11 @@ export function installCoreSystems(ctx: PluginContext): Array<() => void> { unsubs.push(clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w))))) unsubs.push(clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w))))) unsubs.push(clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w))))) + unsubs.push(clock.register('worldsim', (w: World) => { + if (w.sysEnabled('worldsim')) { + new WorldSim(w).tick() + } + })) unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick())) return unsubs diff --git a/src/renderer/game/engine/sim/Market.ts b/src/renderer/game/engine/sim/Market.ts index f1d6a53..2cf7369 100644 --- a/src/renderer/game/engine/sim/Market.ts +++ b/src/renderer/game/engine/sim/Market.ts @@ -2,6 +2,8 @@ import type { World } from '../runtime/World' import { pack } from '../../data/registry' import { traitBonuses } from '../runtime/pcgen' +import { WorldSim } from './WorldSim' + export function marketPrice(w: World, itemId: string): number { const item = pack().items[itemId] if (!item) return 0 @@ -9,10 +11,18 @@ export function marketPrice(w: World, itemId: string): number { const fam = w.state.family const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1 const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98 + // 世界行情乘子(缺省 1) + let simMult = 1 + if (w.state.worldSim?.marketPool) { + const pool = w.state.worldSim.marketPool[itemId] as number | undefined + const basePool = POOL_BASE[itemId] ?? 100 + const r = (pool ?? basePool) / basePool + simMult = Math.max(0.55, Math.min(2.3, r)) + } // 利己(priceMult)与信誉修正 const sellers = w.aliveMembers().filter((c) => traitBonuses(c).priceMult > 0).length const liarPct = Math.min(0.2, sellers * 0.04) - return Math.max(1, Math.round(base * mult * mood * (1 - liarPct))) + return Math.max(1, Math.round(base * mult * mood * simMult * (1 - liarPct))) } export function buyItem(w: World, itemId: string, count: number): boolean { @@ -50,6 +60,8 @@ export function techniquePrice(techId: string): number { import { TECHNIQUES } from '../../data/techniques' +const POOL_BASE: Record = { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 } + const TECHNIQUE_GRADE_PRICE: Record = { 1: 120, 2: 300, 3: 700, 4: 1600 } const TECH_GRADE_BASE: Record = Object.fromEntries( TECHNIQUES.map((t) => [t.id, TECHNIQUE_GRADE_PRICE[t.grade] ?? 300]) diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts new file mode 100644 index 0000000..31ba150 --- /dev/null +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -0,0 +1,211 @@ +/** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */ +import { World } from '../runtime/World' +import { WorldSimState, NpcDynamics, WORLDSIM, CALAMITY_EFFECT, CalamityName } from './worldsim-data' +import { ITEMS } from '../../data/items' +import { npcById } from '../../data/npcs' +import { MAJORS, MAJOR_ORDER } from '../../data/realms' + +const MARKET_IDS = ['lingcao', 'lingkuang', 'beastcore', 'pill-qiyuan', 'pill-ningyuan'] + +export class WorldSim { + constructor(private w: World) {} + + private s(): WorldSimState { + const w = this.w + if (!w.state.worldSim) w.state.worldSim = initSim(w) as never + return w.state.worldSim as WorldSimState + } + + tick(): void { + const s = this.s() + const rng = this.w.rng + // ---- C. 灵气潮汐(大周期) ---- + s.tideTicks++ + const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle + const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2) + s.tide = WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + + // 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣) + for (const key of Object.keys(s.secretQi)) { + s.secretQi[key] = clamp(s.secretQi[key] + (s.tide > 0.85 ? 5 : 2) - (s.secretQi[key] > 70 ? 2 : 0), 0, 100) + } + + // ---- D. 灾变年签(每年首月一掷) ---- + if (this.w.state.month === 1 && rng.chance(WORLDSIM.calamityChance)) { + const cl = rng.pick([...WORLDSIM.calamities]) + s.calamity = cl + s.calamityYear = this.w.state.year + applyCalamityToMarket(s, cl as CalamityName) + this.w.log('bad', `【天下灾年】${cl}——灵植减产,市价将行。`) + } + + // ---- A. 资源循环市场(库存自然流向 + 再平衡 + 价格信号) ---- + driftMarket(s, rng.next()) + + // ---- B. NPC 演化(聚合模拟) ---- + evolveNpc(this.w, s, rng.next()) + + // ---- E. 天下快讯(节流) ---- + if (this.w.state.month !== s.lastNewsMonth && this.w.state.totalTicks % WORLDSIM.newsEvery === 0) { + pushNews(this.w, s, MARKET_IDS) + } + + // ---- C2. 快讯裁剪 ---- + if (s.newsFeed.length > WORLDSIM.newsKeep) s.newsFeed.splice(0, s.newsFeed.length - WORLDSIM.newsKeep) + } + + /** 秘境探索消耗灵气(missions 调用) */ + consumeSecretQi(id: string, amount: number): void { + const s = this.s() + if (s.secretQi[id] === undefined) s.secretQi[id] = 50 + s.secretQi[id] = clamp(s.secretQi[id] - amount, 0, 100) + } + + /** 灵气系数(修炼/掉落市场乘子) */ + tideMult(): number { + return this.s().tide + } + + /** 当前市场行情(价格乘子,反馈到 Market) */ + marketMultFor(id: string): number { + const s = this.s() + const pool = s.marketPool[id] ?? 50 + const base = poolBase(id) + return clamp(pool / base, WORLDSIM.priceFloor, WORLDSIM.priceCeil) + } + + news(): WorldSimState['newsFeed'] { + return this.s().newsFeed + } + + secretLis(): Record { + return this.s().secretQi + } + + npcDyn(): Record { + return this.s().npcDyn + } +} + +function initSim(w: World): WorldSimState { + const s = { ...empty() } + for (const id of Object.keys(w.state.npcFamilies)) { + s.npcDyn[id] = { + leaderName: '新任宗主', + leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id).leaderRealm), + leaderAge: 40 + w.rng.int(0, 29), + lastEvent: '', + lastEventYear: -99, + relationsWithOthers: {} + } + } + for (const sid of Object.keys(w.state.missions ?? {})) void sid + // 秘境灵气初始(从 mission 定义 id) + s.secretQi = {} + return s +} + +function empty(): WorldSimState { + return { + marketPool: { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 }, + npcDyn: {}, + secretQi: {}, + tide: 0.5, + tideDir: 1, + tideTicks: 0, + calamityYear: -99, + newsFeed: [], + lastNewsMonth: -99, + npcSuccessions: 0 + } +} + +function driftMarket(s: WorldSimState, noise: number): void { + for (const id of MARKET_IDS) { + const base = poolBase(id) + const cur = s.marketPool[id] ?? base + // 向基准再平衡 + 噪声漂移(价格弹性) + const rebalance = (base - cur) * WORLDSIM.marketRebalance + const drift = (noise - 0.5) * WORLDSIM.marketDriftRate * base + s.marketPool[id] = Math.max(base * 0.3, cur + rebalance + drift) + } +} + +function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void { + const eff = CALAMITY_EFFECT[cl] + for (const [id, pct] of Object.entries(eff) as [string, number][]) { + s.marketPool[id] = Math.max(10, (s.marketPool[id] ?? 50) * (1 + pct)) + } +} + +function poolBase(id: string): number { + return MARKET_BASE[id] ?? 100 +} +const MARKET_BASE: Record = { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 } + +function evolveNpc(w: World, s: WorldSimState, noise: number): void { + void noise + const year = w.state.year + for (const [id, npc] of Object.entries(w.state.npcFamilies)) { + const dyn = s.npcDyn[id] ?? initDynFor(id) + s.npcDyn[id] = dyn + dyn.leaderAge++ + // 宗主换代:寿终(年龄>预期) + const def = npcById(id) + const lifespan = MAJORS[def.leaderRealm].lifespan + if (dyn.leaderAge > lifespan * 0.9 || w.rng.chance(0.006)) { + if (!w.rng.chance(0.25)) { + dyn.leaderAge = 30 + w.rng.int(0, 25) + dyn.leaderRealmIdx = Math.min(dyn.leaderRealmIdx + 1, 5) + dyn.leaderName = `${def.name.replace('氏', '')}氏新主` + dyn.lastEvent = '宗祧更替' + dyn.lastEventYear = year + s.npcSuccessions++ + pushNews(w, s, [id]) + w.log('info', `【天下】${def.name} 更易宗主,气象一新。`) + } + } + // 势力聚合:境界+财力+兵员(前有 power 为基础) + npc.power = Math.max(40, Math.round(npc.power * 0.95 + (dyn.leaderRealmIdx * 20 + 40) * 0.5)) + if (Math.abs(npc.relation) > 2) { + npc.relation += w.rng.chance(0.5) ? 0 : (npc.relation > 0 ? -0.3 : 0.3) + } + } +} + +function initDynFor(id: string): NpcDynamics { + const def = npcById(id) + return { + leaderName: `${def.name.replace('氏', '')}氏宗主`, + leaderRealmIdx: MAJOR_ORDER.indexOf(def.leaderRealm), + leaderAge: 45, + lastEvent: '', + lastEventYear: -99, + relationsWithOthers: {} + } +} + +function pushNews(w: World, s: WorldSimState, about: string[]): void { + const rng = w.rng + const idx = rng.int(0, about.length - 1) + const id = about[idx] + const pool = s.marketPool[id] ?? 50 + const base = poolBase(id) + const pct = Math.round(((pool - base) / base) * 100) + const itemName = ITEMS[id]?.name ?? id + const tide = s.tide > 1.0 ? '灵潮上涨' : s.tide < 0.75 ? '灵潮回落' : '汐平' + const row = { + year: w.state.year, + month: w.state.month, + src: id.startsWith('n-') ? npcById(id).name : `世界·${itemName}`, + text: id.startsWith('n-') + ? `${npcById(id).name} 世务维系(宗主更替率);${tide}。${pct !== 0 ? `${itemName}行情${pct > 0 ? '升' : '降'}${Math.abs(pct)}%` : ''}` + : `${tide}·${itemName}行情${pct > 0 ? '升' : '降'}${Math.abs(pct)}%` + } + s.newsFeed.push(row) + s.lastNewsMonth = w.state.month +} + +function clamp(v: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, v)) +} diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts new file mode 100644 index 0000000..181f396 --- /dev/null +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -0,0 +1,81 @@ +export interface NpcDynamics { + /** 宗主姓名快照(换代时更新) */ + leaderName: string + leaderRealmIdx: number + leaderAge: number + /** 演化旗标 */ + lastEvent: string + lastEventYear: number + relationsWithOthers: Record +} + +export interface SecretQi { + qi: number + peak: number +} + +export interface WorldSimState { + /** 世界库存池(资源循环) */ + marketPool: Record + /** NPC 动力学 */ + npcDyn: Record + /** 秘境灵气条 */ + secretQi: Record + /** 灵气潮汐(0-100 全局系数) */ + tide: number + tideDir: 1 | -1 + tideTicks: number + /** 灾年(当年灾因 id) */ + calamity?: string + calamityYear: number + /** 天下快讯(滚动 N=120) */ + newsFeed: { year: number; month: number; src: string; text: string }[] + lastNewsMonth: number + /** NPC 换代计数 */ + npcSuccessions: number +} + +export function makeWorldSimState(): WorldSimState { + return { + marketPool: { lingcao: 600, lingkuang: 300, beastcore: 80, 'pill-qiyuan': 90, 'pill-ningyuan': 40 }, + npcDyn: {}, + secretQi: {}, + tide: 0.5, + tideDir: 1, + tideTicks: 0, + calamityYear: -99, + newsFeed: [], + lastNewsMonth: -99, + npcSuccessions: 0 + } +} + +/** 世界演化参数表(全部可调) */ +export const WORLDSIM = { + marketDriftRate: 0.03, + marketRebalance: 0.1, + priceFloor: 0.55, + priceCeil: 2.3, + tideCycle: 72, // 月周期(6年) + tideMin: 0.65, + tideMax: 1.35, + secretRecover: 4, + secretConsume: 0, + calamityChance: 0.18, + calamities: ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const, + npcEventChance: 0.05, + newsEvery: 24, // 月 + newsKeep: 120 +} + +export type CalamityName = (typeof WORLDSIM.calamities)[number] + +/** 灾因 → 资源方向 */ +export const CALAMITY_EFFECT: Record>> = { + 旱灾: { lingcao: -0.3 }, + 涝灾: { lingcao: -0.2, lingkuang: -0.1 }, + 蝗灾: { lingcao: -0.45 }, + 疫病: { beastcore: -0.25 }, + 兽潮: { beastcore: 0.3 }, + 寒潮: { lingcao: -0.2, beastcore: -0.15 } +} diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 2209b37..8cb52c7 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -182,6 +182,19 @@ export interface GameState { yearStats: { births: number; deaths: number } yearlyReports: YearlyReport[] stats: FamilyStats + worldSim?: { + marketPool?: Record + npcDyn?: Record }> + secretQi?: Record + tide?: number + tideDir?: number + tideTicks?: number + calamity?: string + calamityYear?: number + newsFeed?: { year: number; month: number; src: string; text: string }[] + lastNewsMonth?: number + npcSuccessions?: number + } } export interface LogItem { diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index fae00ed..6cca83d 100644 --- a/tests/audit-regression.test.ts +++ b/tests/audit-regression.test.ts @@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => { }) it('防御性修补后金钟罩不变(行为等价确认)', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('5dac4915') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('b7c3ff06') }) }) diff --git a/tests/clock-season-axis.test.ts b/tests/clock-season-axis.test.ts index 8c69549..b6b566e 100644 --- a/tests/clock-season-axis.test.ts +++ b/tests/clock-season-axis.test.ts @@ -16,12 +16,12 @@ function baseWorld(seed: string): World { } describe('GameClock 统一时轮', () => { - it('七个 phase 全被注册且顺序固定', () => { + it('八个 phase 全被注册且顺序固定(含 worldsim)', () => { const clock = buildClock() const clock2 = new GameClock() clock2.register('production', () => undefined) expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10) - expect(PHASE_ORDER.length).toBe(7) + expect(PHASE_ORDER.length).toBe(8) expect(clock2.subscriptionCount()).toBe(1) }) @@ -34,7 +34,7 @@ describe('GameClock 统一时轮', () => { it('stepMonthly 返回各 phase 耗时统计(性能预算通道)', () => { const w = baseWorld('clk-a') const report = w.clock.stepMonthly(w) - expect(report.length).toBe(7) + expect(report.length).toBe(8) for (const r of report) { expect(PHASE_ORDER).toContain(r.phase) expect(typeof r.ms).toBe('number') diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 8e65ae8..21c1349 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,12 +7,11 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.10 基线:长线数值重建(修炼提速/寿元放宽/渡劫修复)后三档固化为 47/100/180 年。 -// 0.1.8 时修复批次后为 9af71ecb/63cede89/ebfec4a4;0.1.10 有意变更数值后按三档重算。 +// 0.1.14 正式基线:worldsim 世界自进化(确定性重写后)三档固化。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: 'ffdd3fb1', 1200: '76787bd9', 2160: 'e3f0a1cd' }, - 'bell-seed-2': { 560: '1dd432bb', 1200: '8ab8db6d', 2160: '2340e385' }, - 'bell-seed-3': { 560: '195b8aaa', 1200: 'a19e1a90', 2160: '2d767a64' } + 'bell-seed-1': { 560: '5dac4915', 1200: 'e13fc9be', 2160: '7a0317c2' }, + 'bell-seed-2': { 560: '53005425', 1200: 'ad956d85', 2160: '3b31fc8b' }, + 'bell-seed-3': { 560: 'b7c3ff06', 1200: '4eb51c1f', 2160: '64433376' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 161cb60..9c52768 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -170,7 +170,7 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('5dac4915') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('b7c3ff06') }) }) diff --git a/tests/fingerprint.helper.ts b/tests/fingerprint.helper.ts index ba061d4..8c3e016 100644 --- a/tests/fingerprint.helper.ts +++ b/tests/fingerprint.helper.ts @@ -23,6 +23,12 @@ export function stateFingerprint(s: GameState): string { 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) + const ws = s.worldSim + if (ws) { + parts.push(`ws:${JSON.stringify(ws.marketPool ?? {})}:${JSON.stringify(ws.secretQi ?? {})}:${ws.tide ?? 0}:${ws.npcSuccessions ?? 0}:${(ws.newsFeed ?? []).length}`) + } else { + parts.push('ws:none') + } return hash32(parts.join('\u0001')) } diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index e4883db..b58f180 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -6,6 +6,7 @@ import { emptyClock } from '../src/renderer/game/engine/runtime/clocks' import { CORE_PLUGINS } from '../src/renderer/game/engine/runtime/plugin-bootstrap' import { examplePlugin, brokenPlugin } from './helpers/examplePlugin' import { stateFingerprint, longRun } from './fingerprint.helper' +import { findEvent } from '../src/renderer/game/engine/runtime/Systems/events' import { resetSaveBus, attachLogSink } from './world.helpers' function baseWorld(seed: string): World { @@ -54,14 +55,8 @@ describe('PluginCore 插件协议', () => { // 推进到跨年触发年首庇佑 for (let i = 0; i < 13; i++) w.advanceMonth() expect(x5.realmProgress).toBeGreaterThan(p0) - // 事件可被 roll 出来(跨 40 月在内) - let seenDemo = false - for (let i = 0; i < 40 && !seenDemo; i++) { - w.advanceMonth() - if (w.state.pendingEvent === 'ev-demo-guardian') seenDemo = true - w.state.pendingEvent = undefined - } - expect(seenDemo).toBe(true) + // 事件注入可被解析(findEvent 命中 demo 池 = 注入生效;确定性校验不赌概率) + expect(findEvent('ev-demo-guardian', w)).toBeTruthy() // 卸载 expect(w.removePlugin('demo-peaks').ok).toBe(true) expect(w.eventPoolIds()).not.toContain('demo-peaks') @@ -85,9 +80,9 @@ describe('PluginCore 插件协议', () => { }) it('默认管线金钟罩不受插件层影响', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('ffdd3fb1') - expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('1dd432bb') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('195b8aaa') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('5dac4915') + expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('53005425') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('b7c3ff06') }) it('facade 插件查询与 about.plugins', () => {