diff --git a/package.json b/package.json index 9d44e97..1bef070 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.19", + "version": "0.1.20", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/engine/GameEngine.ts b/src/renderer/game/engine/GameEngine.ts index 3c0e0ec..2985d9c 100644 --- a/src/renderer/game/engine/GameEngine.ts +++ b/src/renderer/game/engine/GameEngine.ts @@ -3,6 +3,7 @@ import { World } from './runtime/World' import { GameFacade as ApiFacade, ActName, ActPayload, FacadeEvent } from './runtime/ApiFacade' import { GameState } from '../types/domain' import { GameClock } from './kernel/clock' +import { RngHub } from './kernel/rng' import { CotycPlugin } from './kernel/plugin' import { DataPack, PACK } from '../data/registry' import { createWorldState } from './runtime/creation' @@ -31,7 +32,7 @@ export class GameEngine { private tickSinceSave = 0 constructor(opts: GameEngineOptions = {}) { - this._seed = opts.seed ?? `cotyc-${Date.now().toString(36)}-${Math.floor(Math.random() * 1296).toString(36)}` + this._seed = opts.seed ?? RngHub.rollSeed() this.kernel = makeKernel(this._seed) this.datapack = PACK if (opts.datapack) this.datapack.override(opts.datapack) @@ -85,7 +86,7 @@ export class GameEngine { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { const a = this.facade.about() - return { ...a, version: '0.1.14' } + return { ...a, version: '0.1.20' } } installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } { diff --git a/src/renderer/game/engine/kernel/Kernel.ts b/src/renderer/game/engine/kernel/Kernel.ts index 5dd9f69..fcb3658 100644 --- a/src/renderer/game/engine/kernel/Kernel.ts +++ b/src/renderer/game/engine/kernel/Kernel.ts @@ -1,40 +1,17 @@ import { GameClock } from './clock' -import { Rng, seedToRng } from './rng' +import { Rng, seedToRng, RngHub } from './rng' import type { WorldEventBus } from '../runtime/World' -export interface KernelBus { - onLog: WorldEventBus['onLog'] - onChronicle: WorldEventBus['onChronicle'] - onBattle: WorldEventBus['onBattle'] - onPendingEvent: WorldEventBus['onPendingEvent'] - onGameOver: WorldEventBus['onGameOver'] - onYearPaper?: WorldEventBus['onYearPaper'] - onFx?: WorldEventBus['onFx'] - onSystemChange?: WorldEventBus['onSystemChange'] - onPluginChange?: WorldEventBus['onPluginChange'] -} - -/** 三合一内核:游戏时钟 + 种子随机 + 事件总线(单一实例,由 GameEngine 持有) */ +/** 三合一内核:游戏时钟 + 种子随机(单一实例,由 GameEngine 持有)。 + * 注:事件转发走 World.out(WorldEventBus[]);kernel 挂钩装由 attach 保留供插件层。 */ export class Kernel { readonly clock: GameClock readonly rng: Rng - readonly bus: KernelBus private busOut: WorldEventBus[] = [] constructor(seed: string) { this.clock = new GameClock() this.rng = new Rng(seedToRng(seed)) - this.bus = { - onLog: (kind, text) => this.busOut.forEach((o) => o.onLog(kind, text)), - onChronicle: (entry, important) => this.busOut.forEach((o) => o.onChronicle(entry, important)), - onBattle: (log) => this.busOut.forEach((o) => o.onBattle(log)), - onPendingEvent: (id) => this.busOut.forEach((o) => o.onPendingEvent(id)), - onGameOver: (reason, year) => this.busOut.forEach((o) => o.onGameOver(reason, year)), - onYearPaper: (r) => this.busOut.forEach((o) => o.onYearPaper?.(r)), - onSystemChange: (id, enabled) => this.busOut.forEach((o) => o.onSystemChange?.(id, enabled)), - onPluginChange: (id, action) => this.busOut.forEach((o) => o.onPluginChange?.(id, action)), - onFx: (em) => this.busOut.forEach((o) => o.onFx?.(em)) - } } /** 注册外部订阅(每引擎一个 World 共享 bus) */ @@ -54,5 +31,5 @@ export class Kernel { /** 引擎启动统一入口(含默认种子) */ export function makeKernel(seed?: string): Kernel { - return new Kernel(seed ?? `cotyc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + return new Kernel(seed ?? RngHub.rollSeed()) } diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index 41f08d3..0d08cea 100644 --- a/src/renderer/game/engine/runtime/ApiFacade.ts +++ b/src/renderer/game/engine/runtime/ApiFacade.ts @@ -137,7 +137,7 @@ export class GameFacade { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { return { title: '仙途家族志', - version: '0.1.19', + version: '0.1.20', modules: this.world.systemList().length, systems: this.world.systemList().filter((s) => s.enabled).length, plugins: this.world.pluginList().length, diff --git a/src/renderer/game/engine/runtime/Systems/cultivation.ts b/src/renderer/game/engine/runtime/Systems/cultivation.ts index a740fbd..20fe697 100644 --- a/src/renderer/game/engine/runtime/Systems/cultivation.ts +++ b/src/renderer/game/engine/runtime/Systems/cultivation.ts @@ -13,6 +13,7 @@ import { ASPIRATION_IDS } from '../../../data/aspirations' import { seasonMod } from '../../../data/season' import { bonusOf } from '../../../data/buildings' import { needsTribulation, tribulationEventId } from './tribulation' +import { fire } from './events' export function monthlyRate(w: World, c: Character, herbFactor = 1): number { const st = w.state @@ -125,8 +126,7 @@ export function cultivationTick(w: World): void { continue } c.tribDelayYear = undefined - w.pendingEvent(tribulationEventId(c)) - w.state.pendingEvent = tribulationEventId(c) + fire(w, tribulationEventId(c), 1) } else { resolveBreakthrough(w, c, 0) } diff --git a/src/renderer/game/engine/runtime/Systems/diplomacy.ts b/src/renderer/game/engine/runtime/Systems/diplomacy.ts index 1510b22..f0bb74b 100644 --- a/src/renderer/game/engine/runtime/Systems/diplomacy.ts +++ b/src/renderer/game/engine/runtime/Systems/diplomacy.ts @@ -1,6 +1,7 @@ import type { World } from '../World' import { npcById } from '../../../data/npcs' import { findEvent, fire } from './events' +import { WORLDSIM } from '../../sim/worldsim-data' export function diplomacyTick(w: World): void { const s = w.state @@ -14,16 +15,18 @@ export function diplomacyTick(w: World): void { const last = (w.state.family.flag[`raidCD-${npc.id}`] as number | undefined) ?? 0 // 姿态调制:扩张好勇、隐忍避战、结盟之势休兵 const stance = (w.state.worldSim?.npcDyn?.[npc.id] as { stance?: string } | undefined)?.stance - let raidMult = stance === 'expand' ? 2 : stance === 'endure' ? 0.4 : stance === 'ally' ? 0 : 1 - // 0.1.19 盟约连坐:与我结盟各家的世仇,更易扣边 + let raidP = 0.03 * (stance === 'expand' ? 2 : stance === 'endure' ? 0.4 : stance === 'ally' ? 0 : 1) + // 0.1.20 盟约连坐(加性):与我结盟各家的世仇,更易扣边——不再×2×盟数叠爆 const dyns = (w.state.worldSim?.npcDyn ?? {}) as Record }> for (const ally of Object.values(s.npcFamilies)) { if (!ally.allied) continue const rel = dyns[ally.id]?.relationsWithOthers?.[npc.id] ?? 0 - if (rel < -60) raidMult *= 2 + if (rel < -60) raidP += WORLDSIM.allianceGriefRaid } - if (s.year - last >= 2 && w.rng.chance(0.03 * raidMult)) { - fire(w, `ev-raid-${npc.id}`) + if (s.year - last >= 2 && w.rng.chance(Math.min(0.3, raidP))) { + // 高优投放;被顶替不丢失 + const ok = fire(w, `ev-raid-${npc.id}`, 1) + if (!ok) w.state.family.flag[`raidCD-${npc.id}`] = s.year } } } diff --git a/src/renderer/game/engine/runtime/Systems/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index 1121bb6..535d4bb 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -401,10 +401,22 @@ export function eventRoll(w: World): void { } } -export function fire(w: World, id: string): void { - if (w.state.pendingEvent) return - w.state.pendingEvent = id +/** + * 事件投放(唯一 pending 入口)。 + * priority=0 普通(被占则放弃,下次再来);priority=1 高优(raid/渡劫/大比——可顶替被占的普通事件, + * 被顶替者转存 eventQueue 下月兑现,不丢失)。 + * 返回 true=已在档(pending=id);false=被占未投放(调用方应自行冷却/重试)。 + */ +export function fire(w: World, id: string, priority: 0 | 1 = 0): boolean { + const s = w.state + if (s.pendingEvent) { + if (priority === 0) return false + const prev = s.pendingEvent + if (prev !== id && !s.eventQueue.includes(prev)) s.eventQueue.push(prev) + } + s.pendingEvent = id w.pendingEvent(id) + return true } export function applyEventChoice( @@ -416,6 +428,8 @@ export function applyEventChoice( formation?: string ): void { const s = w.state + // 2 次结算防线:事件已被自动压制/取代/清空时,UI 陈旧选择不得落盘 + if (s.pendingEvent && s.pendingEvent !== eventId) return const def = findEvent(eventId, w) if (!def) { s.pendingEvent = undefined diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index 7477955..35f4b6a 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -15,6 +15,7 @@ import { POSTS } from '../../data/posts' import { aspirationById as aspirationOf } from '../../data/aspirations' import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../narrative/legacy' import { needsTribulation, tribulationEventId } from './Systems/tribulation' +import { fire } from './Systems/events' import { createWorldState, findInheritor } from './creation' import { SYSTEM_DEFS, SystemDef } from './capabilities' import { emptyClock } from './clocks' @@ -95,7 +96,11 @@ export function normalizeGameState(state: GameState): GameState { 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 (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 @@ -281,11 +286,15 @@ export class World { important } this.state.chronicle.push(entry) + if (this.state.chronicle.length > 520) this.state.chronicle.splice(0, this.state.chronicle.length - 520) + if (this.state.chronicle.length > 520) this.state.chronicle.splice(0, this.state.chronicle.length - 520) this.out.forEach((o) => o.onChronicle(entry, important)) } battle(log: BattleLog): void { this.state.battles.push(log) + if (this.state.battles.length > 220) this.state.battles.splice(0, this.state.battles.length - 220) + if (this.state.battles.length > 220) this.state.battles.splice(0, this.state.battles.length - 220) this.out.forEach((o) => o.onBattle(log)) } @@ -596,8 +605,7 @@ export class World { // C13:大境界渡劫不可跳过——丹药转为天劫助益 c.tribBoost = (c.tribBoost ?? 0) + 0.12 c.realmProgress = 100 - this.pendingEvent(tribulationEventId(c)) - this.state.pendingEvent = tribulationEventId(c) + fire(this, tribulationEventId(c), 1) this.log('info', `${c.name} 服下破境丹,丹力浑厚——雷云受感而聚,九霄震动。`) } else { this.resolveBottleneck(c, 0.22) diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index 76429bb..fbe0d53 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -13,7 +13,7 @@ export class WorldSim { private s(): WorldSimState { const w = this.w - if (!w.state.worldSim) w.state.worldSim = initSim(w) as never + if (!w.state.worldSim) w.state.worldSim = initSim(w) const ws = w.state.worldSim as WorldSimState if (!ws.secretQi || Object.keys(ws.secretQi).length === 0) { const defs = pack().missions @@ -30,7 +30,11 @@ export class WorldSim { s.tideTicks++ const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2) - s.tide = Math.min(WORLDSIM.tideMax, WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + ERA_CONF[s.era ?? 'pingshi'].tideBias) + s.tide = clamp( + WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + ERA_CONF[s.era ?? 'pingshi'].tideBias, + WORLDSIM.tideMin - 0.05, + WORLDSIM.tideMax + 0.05 + ) // 秘境灵气(自动恢复 + 探索消耗由 missions 在探索时扣;灾年灵脉闭锁 0.5 恢复) const qiRecover = s.calamity ? WORLDSIM.secretRecover * 0.5 : s.tide > 0.85 ? WORLDSIM.secretRecoverHi : WORLDSIM.secretRecover @@ -47,6 +51,7 @@ export class WorldSim { const flow = ERA_CONF[curEra].flow const roll = rng.next() let acc = 0 + let moved = false for (const [next, wgt] of flow) { acc += wgt if (roll < acc) { @@ -58,9 +63,12 @@ export class WorldSim { year: this.w.state.year, month: 1, src: '天道', text: `世道更替:${conf.name}来临。${conf.desc}`, kind: 'calamity' }) + moved = true break } } + void moved + // roll 落在 flow 权重之外 → 延续本届(era 有厚度) } } @@ -112,6 +120,12 @@ export class WorldSim { // ---- B. NPC 演化(换代 + 关系网 + 互攻;B7) ---- evolveNpc(this.w, s, rng.next()) + // ---- D1.5 盟友求援时效(18 月自清) ---- + if (s.distress) { + const distAge = (this.w.state.year - s.distress.year) * 12 + (this.w.state.month - s.distress.month) + if (distAge > 18 && !this.w.state.npcFamilies[s.distress.id]?.allied) s.distress = undefined + } + // ---- D2. 天下十年一鉴(史官综述) ---- if (this.w.state.month === 1 && this.w.state.year % 10 === 0) { s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text: decadeChronicle(s, this.w.state.year), kind: 'annal' }) @@ -218,7 +232,8 @@ function empty(): WorldSimState { function worldBreath(w: World, s: WorldSimState): void { const tide = s.tide const era = ERA_CONF[s.era ?? 'pingshi'] - const span = 1 - (tide - 1) * WORLDSIM.tideSupplySpan + // 潮汐供给:灵涨万物丰(tide>1 供给×1.3、灵衰×0.7)——0.1.19 符号反转修复 + const span = 1 + (tide - 1) * WORLDSIM.tideSupplySpan const supplySpan = Math.max(0.75, Math.min(1.35, span)) const eraSupply = era.supplyMult const eraDemand = era.demandMult @@ -293,11 +308,11 @@ function driftMarket(s: WorldSimState, noise: number): void { const per = n; n += 0.13 const base = poolBase(id) const cur = s.marketPool[id] ?? base - // 向基准再平衡 + 噪声漂移(价格弹性) + // 向基准再平衡(弱化锚定:让供需流与 era 真正撬动价格)+ 噪声漂移(价格弹性) const rebalance = (base - cur) * WORLDSIM.marketRebalance const drift = ((per % 1) - 0.5) * WORLDSIM.marketDriftRate * base - s.marketPool[id] = Math.max(base * 0.3, cur + rebalance + drift) + s.marketPool[id] = Math.max(base * WORLDSIM.poolFloorPct, cur + rebalance + drift) } } @@ -494,7 +509,7 @@ function decadeChronicle(s: WorldSimState, year: number): string { const from = year - 9 const rows = s.newsFeed.filter((r) => r.year >= from && r.year < year) const calamities = rows.filter((r) => r.kind === 'calamity' && r.text.includes('灾')).length - const successions = rows.filter((r) => r.kind === 'npc').length + const successions = rows.filter((r) => r.kind === 'npc' && !r.text.includes('态度')).length const quotes = rows.filter((r) => r.kind === 'quote').length const era = ERA_CONF[s.era ?? 'pingshi'].name const tideAvg = s.tide diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index d4b77f6..965738a 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -89,10 +89,10 @@ export const ERA_CONF: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', { /** 转移概势表:[下一态, 权重] */ flow: Array<['shengshi' | 'pingshi' | 'luanshi' | 'mofa', number]> }> = { - shengshi: { name: '盛世', desc: '灵气鼎盛,百业兴旺,天下升平。', calamityMult: 0.5, supplyMult: 1.25, demandMult: 1.15, auctionMult: 1.3, tideBias: 0.08, flow: [['pingshi', 0.7], ['luanshi', 0.3]] }, - pingshi: { name: '平世', desc: '四海无波,仙凡相安。', calamityMult: 1.0, supplyMult: 1.0, demandMult: 1.0, auctionMult: 1.0, tideBias: 0, flow: [['shengshi', 0.35], ['luanshi', 0.45], ['mofa', 0.2]] }, - luanshi: { name: '乱世', desc: '群雄相逐,烽烟四起,灾祸连年。', calamityMult: 1.6, supplyMult: 0.85, demandMult: 1.05, auctionMult: 0.75, tideBias: -0.04, flow: [['pingshi', 0.4], ['mofa', 0.6]] }, - mofa: { name: '末法', desc: '灵机衰微,大能隐迹,仙路将绝。', calamityMult: 1.2, supplyMult: 0.7, demandMult: 0.85, auctionMult: 0.6, tideBias: -0.09, flow: [['pingshi', 0.6], ['shengshi', 0.4]] } + shengshi: { name: '盛世', desc: '灵气鼎盛,百业兴旺,天下升平。', calamityMult: 0.5, supplyMult: 1.25, demandMult: 1.15, auctionMult: 1.3, tideBias: 0.08, flow: [['pingshi', 0.45], ['luanshi', 0.15]] }, + pingshi: { name: '平世', desc: '四海无波,仙凡相安。', calamityMult: 1.0, supplyMult: 1.0, demandMult: 1.0, auctionMult: 1.0, tideBias: 0, flow: [['shengshi', 0.22], ['luanshi', 0.28], ['mofa', 0.1]] }, + luanshi: { name: '乱世', desc: '群雄相逐,烽烟四起,灾祸连年。', calamityMult: 1.6, supplyMult: 0.85, demandMult: 1.05, auctionMult: 0.75, tideBias: -0.04, flow: [['pingshi', 0.25], ['mofa', 0.35]] }, + mofa: { name: '末法', desc: '灵机衰微,大能隐迹,仙路将绝。', calamityMult: 1.2, supplyMult: 0.7, demandMult: 0.85, auctionMult: 0.6, tideBias: -0.09, flow: [['pingshi', 0.4], ['shengshi', 0.2]] } } /** era 持续年数区间(转移时机) */ @@ -106,14 +106,15 @@ export const ERA_DURA: Record<'shengshi' | 'pingshi' | 'luanshi' | 'mofa', [numb /** 世界演化参数表(全部可调) */ export const WORLDSIM = { marketDriftRate: 0.03, - marketRebalance: 0.1, + marketRebalance: 0.025, // 0.1.20 弱锚定:让供需/era/灾年真实驱动价格(0.1.19 为 0.1 硬锚死) priceFloor: 0.55, priceCeil: 2.3, // —— 0.1.17 真库存循环 —— worldSupplyRate: 0.02, // 世界侧月供给(base 比例;潮涨时 ×1.3) worldDemandRate: 0.015, // 世界侧月需求(坊市/宗门常驻消耗) npcTradeRate: 0.008, // 每 NPC 月贸易量(base 比例;上桌食量) - tradeFloorPct: 0.35, // 池低于此比例时玩家买入拒单(断供告急) + tradeFloorPct: 0.35, // 池低于此比例时玩家买入拒单(断供告急)——与 drift 池底一致 + poolFloorPct: 0.35, // driftMarket 下限(拒绝“断供后还能买到”悖论带) tradeCeilPct: 2.4, // 池上限(玩家大量卖出后价格封顶) tideSupplySpan: 0.3, // 潮汐对供给的浮动幅度(tide±0.35 时 ×(1∓0.3)) // —— 灾年持续 —— @@ -125,6 +126,8 @@ export const WORLDSIM = { secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi) secretRecoverHi: 5, secretConsume: 6, // 每次探索消耗(missions 默认) + // —— 盟约连坐(加性) —— + allianceGriefRaid: 0.012, // кажд 世仇盟连加性 raid 概率 // —— 天下事件 —— calamityChance: 0.18, calamities: ['旱灾', '涝灾', '蝗灾', '疫病', '兽潮', '寒潮'] as const, diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 690d0b7..7d062d1 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -61,18 +61,23 @@ export class SaveSlot { } async saveChronicle(chronicle: GameState['chronicle']): Promise { - const driver = this.driver - for (const e of chronicle) { - try { - const exists = await driver.all<{ id: string }>(`SELECT id FROM chronicle WHERE id = ?`, [e.id]) - if (exists.length > 0) continue - await 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 { - // chronicle table redundancy is best-effort + try { + // 增量写:一次读全部已存 id(SET 判重),仅插新行——O(N) → O(1) 次查询 + 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 兜底) } } diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index d344c68..3ffe55f 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -195,6 +195,8 @@ export interface GameState { calamityLeft?: number era?: string eraStartYear?: number + overfluxYear?: number + distress?: { id: string; year: number; month: number } newsFeed?: { year: number; month: number; src: string; text: string; itemId?: string; kind?: string }[] lastNewsMonth?: number npcSuccessions?: number diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 53e7319..d3211c0 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -210,7 +210,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。 -
版本 0.1.19 · Chronicle of the Immortal Clan
+
版本 0.1.20 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/panels/WorldPanel.tsx b/src/renderer/ui/panels/WorldPanel.tsx index 4636b95..dbaa3aa 100644 --- a/src/renderer/ui/panels/WorldPanel.tsx +++ b/src/renderer/ui/panels/WorldPanel.tsx @@ -122,8 +122,8 @@ export default function WorldPanel() {
{news.length === 0 &&
风平浪静,尚无消息。
} {news.map((row, i) => { - const canBuy = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5 - const canSell = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5 + const canBuy = row.kind === 'quote' && row.itemId && w.state.family.stones >= marketPrice(w, row.itemId) * 5 + const canSell = row.kind === 'quote' && row.itemId && (w.state.family.inventory[row.itemId] ?? 0) >= 5 const isCrisis = row.kind === 'calamity' return (
diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index 62b62c3..2205941 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -8,6 +8,9 @@ import { getSlotManager, getSaveSlot } from '../game/storage/db' import { metaFromState, updateSlotMeta } from './storeHelper' import { GameFacade, ActName } from '../game/engine/runtime/ApiFacade' import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound' +import { buyItem as marketBuy, sellItem as marketSell, buyTechnique as marketTechnique } from '../game/engine/sim/Market' +import { giftNpc as dipGift, makePeace as dipPeace, marryNpcFamily as dipMarry } from '../game/engine/runtime/Systems/diplomacy' +import { sendMission as sendMissionDirect, recallAll as recallAllDirect } from '../game/engine/runtime/Systems/missions' import { seasonOf } from '../game/data/season' export type Screen = 'boot' | 'newgame' | 'game' @@ -145,6 +148,7 @@ export const useGameStore = create((set, get) => ({ go: (screen) => set({ screen }), startNewGame: async (opts, slot) => { + stopTimer() const engine = new GameEngine({ seed: opts.seed }) const world = engine.world set({ world, engine, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined }) @@ -225,7 +229,11 @@ export const useGameStore = create((set, get) => ({ w.syncRng() sTick() set((s) => ({ revision: s.revision + 1 })) - await Stash.save(st, '自动') + // 自动速度节流落盘:疾进 12 tick/常速 6 tick/缓行 4 tick;手动单步即时落盘 + const saveEvery = st.speed >= 3 ? 12 : st.speed === 2 ? 6 : 4 + if (st.speed === 0 || w.state.totalTicks % saveEvery === 0) { + await Stash.save(st, '自动') + } } catch (e) { // 出险保护:先落一档"出险前"现场再提示回档 console.error('[advance-crash]', e) @@ -449,7 +457,10 @@ function makeBus(st: GameStore): WorldEventBus { onFx: (em) => { void import(/* @vite-ignore */ './fx').then((m) => m.ensureFx().emit(em.kind as never, em.source ? { src: em.source } : undefined)) // 音效语义补充:引擎语义点 → 声响(onLog kind 兜底仍保留) - void import(/* @vite-ignore */ './sound').then((snd) => { + void Promise.all([import('./sound'), import('./fx')]).then(([snd, fxM]) => { + // 声音与视效同闸:gate 屏蔽(std 无 blade)时无声;门内再播 + const g = fxM.ensureFx() + if (!g.allowed(em.kind as never)) return if (em.kind === 'blade') snd.sWar() else if (em.kind === 'pulse') snd.sGong() else if (em.kind === 'spark') snd.sBell() @@ -467,14 +478,32 @@ function makeBus(st: GameStore): WorldEventBus { } function actDirect(w: World, name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload): boolean { - if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true) - if (name === 'estate.rite') return w.ancestralRite() - if (name === 'estate.sutra') return w.seekSutra() - if (name === 'estate.build' && payload.building) return w.build(payload.building) - if (name === 'estate.upgrade' && payload.building) return w.upgrade(payload.building) - if (name === 'member.post' && payload.memberId) return w.assignPost(payload.memberId, payload.post) - if (name === 'member.marry' && payload.memberId && payload.targetId) return w.marryTo(payload.memberId, payload.targetId) - return false + switch (name) { + case 'legacy.resolve': return (w.resolveLegacyNow(), true) + case 'estate.rite': return w.ancestralRite() + case 'estate.sutra': return w.seekSutra() + case 'estate.build': return payload.building ? w.build(payload.building) : false + case 'estate.upgrade': return payload.building ? w.upgrade(payload.building) : false + case 'head.set': return payload.memberId ? (w.assignHead(payload.memberId), true) : false + case 'member.meditate': return payload.memberId ? (w.setMeditation(payload.memberId, !!payload.count), true) : false + case 'member.technique': return payload.memberId && payload.tech ? (w.giveTechnique(payload.memberId, payload.tech), true) : false + case 'member.equip': return payload.memberId && payload.item ? (w.equip(payload.memberId, payload.item), true) : false + case 'member.pill': return payload.memberId && payload.pill ? (w.takePill(payload.memberId, payload.pill), true) : false + case 'member.advance': return payload.memberId ? (w.assistedBreakthrough(payload.memberId), true) : false + case 'member.marry': return payload.memberId && payload.targetId ? w.marryTo(payload.memberId, payload.targetId) : false + case 'member.post': return payload.memberId ? w.assignPost(payload.memberId, payload.post) : false + case 'market.buy': return payload.item ? marketBuy(w, payload.item, payload.count ?? 1) : false + case 'market.sell': return payload.item ? marketSell(w, payload.item, payload.count ?? 1) : false + case 'market.tech': return payload.tech ? marketTechnique(w, payload.tech, payload.stones ?? 0) : false + case 'craft.pill': return payload.item === 'ningyuan' ? w.craftPill('ningyuan') : payload.item === 'pojing' ? w.craftPill('pojing') : payload.item === 'qiyuan' ? w.craftPill('qiyuan') : false + case 'diplomacy.gift': return payload.npcId ? dipGift(w, payload.npcId, payload.stones ?? 0) : false + case 'diplomacy.peace': return payload.npcId ? dipPeace(w, payload.npcId) : false + case 'diplomacy.taunt': return payload.npcId ? w.tauntNpc(payload.npcId) : false + case 'diplomacy.marry': return payload.npcId ? dipMarry(w, payload.npcId) : false + case 'expedition.send': return payload.mission ? sendMissionDirect(w, payload.mission, payload.squad ?? []) : false + case 'expedition.recall': return (recallAllDirect(w, payload.memberId ?? ''), true) + default: return false + } } const Stash = { diff --git a/tests/audit-0.1.20.test.ts b/tests/audit-0.1.20.test.ts new file mode 100644 index 0000000..aa395c5 --- /dev/null +++ b/tests/audit-0.1.20.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/runtime/World' +import { fire, applyEventChoice } from '../src/renderer/game/engine/runtime/Systems/events' +import { ERA_CONF, ERA_DURA, WORLDSIM, WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data' +import { stateFingerprint } from './fingerprint.helper' + +describe('0.1.20 万世无谬', () => { + it('fire 高优可顶替普通 pending,被顶替者入队不丢失', () => { + const w = World.create({ seed: 'a1', surname: '岳', familyName: '岳家', motto: 'm', difficulty: 'normal' }) + w.advanceMonth() + w.state.pendingEvent = undefined // 清月度事件闸(测试隔离) + fire(w, 'ev-legacypass') // 普通占位 + expect(w.state.pendingEvent).toBe('ev-legacypass') + const ok = fire(w, 'ev-raid-n-nulei', 1) // 高优顶替 + expect(ok).toBe(true) + expect(w.state.pendingEvent).toBe('ev-raid-n-nulei') + expect(w.state.eventQueue).toContain('ev-legacypass') // 未丢失 + }) + + it('fire 普通被占则 false(调用方可冷却)', () => { + const w = World.create({ seed: 'a2', surname: '满', familyName: '满家', motto: 'm', difficulty: 'normal' }) + w.advanceMonth() + w.state.pendingEvent = undefined + fire(w, 'ev-legacypass') + expect(fire(w, 'ev-legacypass')).toBe(false) + }) + + it('名宿传薪同一年仅一次(年锚 flag)', () => { + const w = World.create({ seed: 'a3', surname: '贺', familyName: '贺家', motto: 'm', difficulty: 'normal' }) + w.state.year = 4 // year%8===4 + w.state.completedEvents = [] + let n = 0 + for (let i = 0; i < 12; i++) { + if (w.state.year % 8 === 4 && !w.state.family.flag[`legacyDone-${w.state.year}`]) { + if (fire(w, 'ev-legacypass')) { + w.state.family.flag[`legacyDone-${w.state.year}`] = true + n++ + } + w.applyEventChoice(w.state.pendingEvent ?? 'ev-legacypass', 0) + } + w.state.month++ + if (w.state.month > 12) { w.state.month = 1; w.state.year++ } + } + expect(n).toBeLessThanOrEqual(1) + }) + + it('二次结算防线:陈旧 Modal 选择被忽略(pending 已清/易主)', () => { + const w = World.create({ seed: 'a4', surname: '冼', familyName: '冼家', motto: 'm', difficulty: 'normal' }) + w.advanceMonth() + fire(w, 'ev-legacypass') + const before = w.state.pendingEvent + // 恶意重复选择:pending 已易主(自动压制)时 apply 不得落盘 + w.state.pendingEvent = undefined + let threw = false + try { + applyEventChoice(w, before!, 0) + } catch { + threw = true + } + expect(threw).toBe(false) // 不崩 + expect(w.state.pendingEvent).toBeUndefined() // 未二次应用 + }) + + it('era 有延续档:flow 权重和 < 1(不再每届必换)', () => { + for (const k of Object.keys(ERA_CONF) as Array) { + const sum = ERA_CONF[k].flow.reduce((a, [, wgt]) => a + wgt, 0) + expect(sum).toBeLessThan(1) + } + expect(ERA_DURA.pingshi[0]).toBeLessThan(ERA_DURA.pingshi[1]) + }) + + it('潮汐供给正号:tide>1 → 供给乘 >1(灵涨必丰)', () => { + const multAt = (tide: number): number => Math.max(0.75, Math.min(1.35, 1 + (tide - 1) * WORLDSIM.tideSupplySpan)) + expect(multAt(1.3)).toBeGreaterThan(1) + expect(multAt(0.7)).toBeLessThan(1) + }) + + it('断供/池底单常量一致:拒单阈值 == poolFloorPct', () => { + expect(WORLDSIM.tradeFloorPct).toBe(WORLDSIM.poolFloorPct) + }) + + it('rebalance 弱锚定:供需可撬动价格(1200 月池有超过 ±25% 振幅的时机)', () => { + const w = World.create({ seed: 'a9', surname: '郄', familyName: '郄家', motto: 'm', difficulty: 'normal' }) + let min = Infinity + let max = -Infinity + for (let i = 0; i < 1200; i++) { + w.advanceMonth() + const pool = (w.state.worldSim as WorldSimState | undefined)?.marketPool?.['lingcao'] ?? 600 + min = Math.min(min, pool / 600) + max = Math.max(max, pool / 600) + } + expect(max - min).toBeGreaterThan(0.5) // 弱锚定下真实起伏 + }) + + it('指纹含世界轴(era/stance 变更会红)', () => { + const w = World.create({ seed: 'a10', surname: '苻', familyName: '苻家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < 240; i++) w.advanceMonth() + const f1 = stateFingerprint(w.state) + const ws = w.state.worldSim as WorldSimState + ws.era = ws.era === 'mofa' ? 'shengshi' : 'mofa' // 确保改成异值 + ws.npcDyn['n-xuanying']!.stance = ws.npcDyn['n-xuanying']!.stance === 'expand' ? 'endure' : 'expand' + const f2 = stateFingerprint(w.state) + expect(f1).not.toBe(f2) + }) +}) diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index 82b9ba0..6ade30b 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('aeed3b65') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('3b784f49') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0bf7ec62') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 2376d50..d4b7de7 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,12 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.19 八方风雨基线:NPC 战略姿态(stance 四态+年首重估+全行为调制) -// + 超卖潮(谷贱伤农产能回调)+ 盟约连坐/求援 + 调停/资助干预后固化。 +// 0.1.20 万世无谬基线(指纹含全世界轴:era/stance/prosperity/relationsWithOthers/pendingEvent): +// 事件闸优先级/era 延续档/潮汐正号/rebalance 弱锚定/断供单常量后固化。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: 'aeed3b65', 1200: '0cbd4079', 2160: '7d1efba9' }, - 'bell-seed-2': { 560: '6bb6ff89', 1200: 'a73b5655', 2160: 'f3537f46' }, - 'bell-seed-3': { 560: '39892725', 1200: '4b69c491', 2160: '950e8410' } + 'bell-seed-1': { 560: '3b784f49', 1200: 'b62fada2', 2160: '999782cc' }, + 'bell-seed-2': { 560: '0f73bffb', 1200: '454eedc9', 2160: '426c967f' }, + 'bell-seed-3': { 560: '0bf7ec62', 1200: '06cf0213', 2160: '51feb3ee' } +} + +/** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; + * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ +const GOLDEN_RESOLVED: Record> = { + 'bell-seed-1': { 560: 'b28b99f9', 1200: 'ffd53c37', 2160: 'd7eadca0' }, + 'bell-seed-2': { 560: 'c712ad0f', 1200: '2304c1c4', 2160: '76519353' }, + 'bell-seed-3': { 560: '6878460d', 1200: '6633cdde', 2160: 'f285188f' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 28fbd6e..438fca3 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -162,7 +162,7 @@ describe('GameFacade 门面', () => { const f = new GameFacade(w, 1) const info = f.about() expect(info.title).toBe('仙途家族志') - expect(info.version).toContain('0.1.19') + expect(info.version).toContain('0.1.20') expect(info.modules).toBeGreaterThanOrEqual(11) expect(info.systems).toBeGreaterThan(0) expect(info.plugins).toBeGreaterThanOrEqual(3) @@ -170,7 +170,7 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('3b784f49') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0bf7ec62') }) }) diff --git a/tests/fingerprint.helper.ts b/tests/fingerprint.helper.ts index 8c3e016..dd64fb0 100644 --- a/tests/fingerprint.helper.ts +++ b/tests/fingerprint.helper.ts @@ -25,10 +25,14 @@ export function stateFingerprint(s: GameState): string { 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}`) + const dyns = Object.entries(ws.npcDyn ?? {}) + .map(([k, d]) => `${k}:${(d as { stance?: string; prosperity?: number }).stance ?? '-'}:${Math.floor((d as { prosperity?: number }).prosperity ?? 0) * 7}:${JSON.stringify((d as { relationsWithOthers?: Record }).relationsWithOthers ?? {})}`) + .join(';') + parts.push(`ws:${JSON.stringify(ws.marketPool ?? {})}:${JSON.stringify(ws.secretQi ?? {})}:${ws.tide ?? 0}:${ws.npcSuccessions ?? 0}:${(ws.newsFeed ?? []).length}:${ws.era ?? '-'}:${ws.eraStartYear ?? 0}:${ws.calamity ?? '-'}:${ws.calamityLeft ?? 0}:${dyns}`) } else { parts.push('ws:none') } + parts.push(`pe:${s.pendingEvent ?? '-'}:${s.eventQueue ? s.eventQueue.length : 0}`) return hash32(parts.join('\u0001')) } @@ -67,3 +71,18 @@ export function longRun(seed: string, months = 560): World { } return w } + +/** 自动 resolve 长跑("现实"世界:每 tick 处理待决事件——与直调冻结世界对照) */ +export function longRunResolved(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() + let guard = 0 + while (w.state.pendingEvent && guard < 4) { + w.applyEventChoice(w.state.pendingEvent, 0) + guard++ + } + } + return w +} diff --git a/tests/gameengine.test.ts b/tests/gameengine.test.ts index c198f52..37f67de 100644 --- a/tests/gameengine.test.ts +++ b/tests/gameengine.test.ts @@ -47,21 +47,21 @@ describe('GameEngine 引擎门面', () => { expect(e.status()).toBe('running') }) - it('about 元数据含 plugins(≥3)与版本 0.1.14', () => { + it('about 元数据含 plugins(≥3)与版本 0.1.20', () => { const e = new GameEngine({ seed: 'engine-5' }) const a = e.about() expect(a.plugins).toBeGreaterThanOrEqual(3) - expect(a.version).toContain('0.1.14') + expect(a.version).toContain('0.1.20') }) }) describe('Kernel 三合一', () => { - it('时钟/随机/总线协同(同 seed 同随机序列)', () => { + it('时钟/随机协同(同 seed 同随机序列);bus 已并入 World.out', () => { const k1 = makeKernel('kernel-a') const k2 = makeKernel('kernel-a') expect(k1.rng.next()).toBe(k2.rng.next()) expect(k1.clock).toBeTruthy() - expect(typeof k1.bus.onLog).toBe('function') + expect((k1 as unknown as { bus?: unknown }).bus).toBeUndefined() }) }) diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index d3c7d37..6dfbf39 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -80,9 +80,9 @@ describe('PluginCore 插件协议', () => { }) it('默认管线金钟罩不受插件层影响', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('aeed3b65') - expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('6bb6ff89') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('39892725') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('3b784f49') + expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('0f73bffb') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('0bf7ec62') }) it('facade 插件查询与 about.plugins', () => {