diff --git a/p b/p deleted file mode 100644 index c39741a..0000000 --- a/p +++ /dev/null @@ -1,94 +0,0 @@ -# AGENTS.md - -仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 - -## 命令 - -```bash -npm test # vitest 引擎测试(tests/*.test.ts,全部纯 Node,无 DOM/DB 依赖) -npx vitest run tests/world.test.ts # 单文件测试 -npm run typecheck # tsc --noEmit(npm 会打 warn 噪音,用 npx tsc --noEmit 更干净) -npm run dev # electron-vite 热更新(开发期 origin 为 http://localhost:5173) -npm run build # 产物到 out/ -npm run package # build + electron-builder(Linux 打 win 包可出产物,但未签名,Windows 上可能被 SmartScreen 静默拦截;正规发布在 Windows 侧重跑此命令) -``` - -端到端冒烟(渲染 + OPFS 数据库 + 开局推进 + 可选截图): - -```bash -npm run build -SMOKE_TEST=1 npx electron out/main/index.js --no-sandbox --disable-gpu # 退出码 0 = 全通 -SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附带 UI 截图 -``` - -冒烟会写真实用户数据目录(Linux 下 `~/.config/Electron`),跑完删掉该目录以免脏数据。 -无显示环境(WSLg 掉线)会打印 `[SMOKE-TIMEOUT]` 退出码 2 而非卡死;GUI 冒烟必须在 X11/WSLg 在线时跑(引擎与存储逻辑的回归请靠 vitest,不要依赖 GUI)。另注意:`npx electron` 可能拉取**新版** electron 缓存版(与本仓库 33.x 不同),二进制安装以 `node_modules/electron` 为准。 - -## 环境坑 - -- npm 12 拦截 postinstall:首次安装后须 `npm install-scripts approve electron esbuild`,否则 Electron 二进制缺失。 -- **`npm rebuild electron esbuild` 会删掉 `node_modules/electron/dist/electron`**(Linux 二进制),修复: - `ELECTRON_MIRROR=https://npmmirror.com/mirrors/electron/ node node_modules/electron/install.js` -- 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。 -- Linux 无 emoji 字体:**所有图标一律用汉字印章字符**(renderer/ui/styles.css 的 `.s-icon/.res-icon/.bld-icon`),不要引入 emoji。 - -## 插件架构(0.1.8 起) - -- **万物皆是插件**:`core/plugin.ts` 协议(Manifest/dependencies/conflicts/install/uninstall);`engine/pluginManager.ts` 安装管线(依赖校验、异常回滚、追踪型上下文——插件注册的时轮钩子卸载时全摘);`engine/plugin-bootstrap.ts` 三枚常驻核心插件(core-systems/core-data/core-events,protected 不可卸)。 -- **事件多池**:`world.eventPools`(core 池受保护不可删)+ `eventRoll` 聚合抽样;第三方插件 `ctx.addEventPool(id, events)` 即注入内容。**注意 findEvent 必须带 world 参数查池**(`events.ts`),否则未知事件直接软锁。 -- **能力卡**:`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。 -- **门面**:`engine/api.ts` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。 -- **金钟罩**:0.1.11 审计修复批次后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts;0.1.8 基线已作废、0.1.10 重建后再次漂移、0.1.11 大比 once/prune 后重固化)。 - -## 引擎系统(0.1.14 GameEngine) - -- **入口单点** `engine/GameEngine.ts`:new GameEngine({seed,datapack}) → world/facade/kernel/datapack/sim; - advance/act/query/subscribe/about/installPlugin/snapshot/restore。/ UI store 只认识 engine(world 为兼容仍暴露)。 -- **内核三合一** `engine/kernel/Kernel.ts`:Clock + Rng + 事件总线——单实例;World 的 clock/rng 注入自内核(双时钟已合一,勿再造)。 -- **世界自进化** `engine/sim/WorldSim.ts`(worldsim 相位 +「天下演序」能力卡可停用): - 资源循环市场(marketPool 供需→Market 行情乘子)/ NPC 演化(换代/势力=境界+财+兵)/ 秘境灵气(探索消耗+恢复)/ - 灵气潮汐(天雨期×修炼)/ 灾年签(联动市场池)/ 天下快讯(newsFeed 滚动 N)。**所有随机走 w.rng**(禁 Math.random)。 -- **目录**:engine/kernel(底层原子)/ engine/runtime(World/插件/门面/Systems×13)/ engine/sim(经济+世界演化)/ engine/narrative(史书族谱维度)。 -- 旧目录(game/core、engine/systems、engine/world.ts、engine/api.ts)**全部废弃**,勿再引用。 - -## 架构要点 - -- `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。 -- **统一时轮 `core/clock.ts`**:月度 phase(production/aging/cultivation/missions/events/diplomacy/epilogue)+ 年首钩子均注册于 `engine/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**。 -- **统一随机 `core/rng.ts`**:引擎只经 `World.rng`(含 `nextCount` 审计);UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`,**不要**在逻辑里引入 Math.random()。 -- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed × 三档月数(560/1200/2160 = 47/100/180 年)指纹常驻(0.1.11 基线:见注释 GOLDEN)。任何改动若破坏确定性立即红;**有意变更时序/数值时**指纹一并重算并在注释注明原因。 -- `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。 -- 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。 -- 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。 - -## MetonaSqlark(@metona-team/metona-sqlark 0.7.4)陷阱 - -- SQL 方言为自有实现(不是 SQLite):建表列类型写 `string|number|boolean|date|json`,**不是** `INTEGER/TEXT`;不支持 `INSERT OR REPLACE`、`ON CONFLICT`、`rowid`——用「SELECT 判断 → UPDATE/INSERT」或直接 `DELETE+INSERT`。 -- aria 引擎同库一个 tab 只能建一次连接(Web Locks → `ARIA_LOCKED`):存取必须走 `game/storage/db.ts` 的单例 `getSaveSlot(slot)` / `getSlotManager()`,**禁止**每次 `new SaveSlot/new SlotManager`。 -- 数据在浏览器 OPFS(renderer 内),main 进程无 DB 逻辑;开发与打包后的 origin 不同,两环境存档不互通。 -- 存储层通过 `SaveDbDriver` 抽象(`storage/slots.ts`),node 环境下无 OPFS,测试只覆盖引擎不覆盖 DB。 - -## 数值基调(0.1.10 重建后,勿当 bug 调回去) - -- **pacing.ts 的 MAJOR_RATE 是进度唯一生效因子**(qi 2.2、foundation 1.4、core 0.95、nascent 0.68、spirit 0.48);`realms.ts` 的 `expBase/expGrowth/maxRealmExp` 曲线**未参与计算**(历史摆设,勿以它反推)。 -- 月率 base = `1.0 + perception*0.18`;65 岁起 ×0.72 衰减、8 岁以下 ×0.65(圣者护族 60+ 高境界者存在时乘 1.3)。 -- 寿元:75/150/220/340/520/850(凡人→化神,0.1.10 放宽的梯度)。 -- 大境界晋升走**渡劫事件三选**(硬渡/护法/压制);玩家 12 月不应会**自动压制一年**(防挂机软锁,events.ts 计数 `tribPendingMonths`)。 -- 周期事件优先级:命运(百年/飞升)> 大比 > 传薪 > 拍卖 > 岁祷 > 回声;大比有 `once`,一年一届。 -- `family.flag` 年份键(auction-/prayerDone-/echoDone-/recruitDone-)每 tick 剪 3 年前旧键。 - -## 修改纪律(防覆盖——血泪教训,务必遵守) - -**背景**:曾多次出现「大改后我之前的修复被覆盖」:① 批量 python 替换命中旧文本、② `git checkout` 回滚临时参数时误恢复了已修复文件、③ 编辑器链式 replace 生成重复属性/孤行。均靠 tsc/vitest 事后兜住,但应防于未然。 - -**硬性规则**: -1. **改动前**:`git status` 确认工作区干净(或先提交当前成果,原子提交,一功一提交)。 -2. **改动中**:批量替换一律用「先 assert 旧文本存在 → 替换 → 再全局 grep 断言新文本唯一」三步;禁止无检查的 sed/python 盲替。 -3. **禁止 `git checkout -- ` / 整文件回滚**(这是历史事故元凶)。确需回滚时:`git show HEAD: > /tmp/bak` 手动 diff 恢复,恢复后跑全量测试确认无伤。 -4. **优先小步快跑**:每个功能块改完立即 `npm test`(引擎)或 `tsc`(UI 类型),绿了再动下一处——绝不在错误状态上加新修改。 -5. **高危文件白名单**(world.ts / events.ts / store.ts / styles.css / cultivation.ts)改动后必须 `git diff --stat` 核对改动面与预期一致(±文件行数应与你改的块吻合)。 -6. 每次迭代收尾:`git status` 应干净 + `git log --oneline` 确认每步都有提交记录;凡 "git stash/checkout/rm" 类命令,执行前后都 `git status` 留痕。 - -- 提交前:`npm test` + `npm run typecheck` 必须绿(改存储/SQL 后再跑一次冒烟)。 -- 平衡数值集中在 `game/data/`(realms/pacing/buildings/events...),调平衡不改引擎流程。 -- 发布产物 `release/`、`out/` 均已 gitignore,勿入库。 diff --git a/package.json b/package.json index 8425dea..d6f113a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.14", + "version": "0.1.15", "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 32dc898..3c0e0ec 100644 --- a/src/renderer/game/engine/GameEngine.ts +++ b/src/renderer/game/engine/GameEngine.ts @@ -22,7 +22,7 @@ export type EngineStatus = 'idle' | 'running' | 'gameover' * UI/测试/未来工具仅需认识 GameEngine。 */ export class GameEngine { - readonly kernel: Kernel + kernel: Kernel readonly datapack: typeof PACK world: World facade: ApiFacade @@ -31,7 +31,7 @@ export class GameEngine { private tickSinceSave = 0 constructor(opts: GameEngineOptions = {}) { - this._seed = opts.seed ?? `cotyc-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + this._seed = opts.seed ?? `cotyc-${Date.now().toString(36)}-${Math.floor(Math.random() * 1296).toString(36)}` this.kernel = makeKernel(this._seed) this.datapack = PACK if (opts.datapack) this.datapack.override(opts.datapack) @@ -55,8 +55,16 @@ export class GameEngine { advance(): void { if (this.world.state.gameOver) return + if (this.world.state.pendingEvent) return // 事件待决:停下(用户/自动化应 resolvePending) this.world.advanceMonth() - this.tickSinceSave++ + } + + /** 处理当前待决事件(默认选第一项);无待决则 no-op */ + resolvePending(idx = 0): boolean { + const pid = this.world.state.pendingEvent + if (!pid) return false + // 直接走门面 apply 通路的引擎包装(不依赖 UI) + return this.world.applyEventChoice(pid, idx) } syncRng(): void { @@ -90,9 +98,12 @@ export class GameEngine { return JSON.parse(JSON.stringify(this.world.state)) as GameState } - /** 从快照恢复(可回放) */ + /** 从快照恢复(可回放)——重建内核以避免时钟双注册,并回置 rng 保真 */ restore(snap: GameState): void { normalizeGameState(snap) + // 新内核(同一 seed 派生)+ 回写存档 rng 快照 —— 回放保真 + this.kernel = makeKernel(snap.seed) + this.kernel.rng.state = { ...snap.rng } const newWorld = new World(snap, [], this.kernel) newWorld.out = this.world.out this.world = newWorld @@ -110,7 +121,7 @@ export class GameEngine { /** 已用旧内核时钟注册的世界快照,反序列化时公用(便捷入口) */ export function engineFromSnapshot(state: GameState): GameEngine { - const eng = new GameEngine({ noAutoCreate: true }) + const eng = new GameEngine({ seed: state.seed, noAutoCreate: true }) eng.restore(state) return eng } diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index c8b1d60..cd8fe9a 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.11', + version: '0.1.15', 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/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index 4a96f54..f62289f 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -421,8 +421,7 @@ export function applyEventChoice( } const opt = def.options[optionIdx] if (opt) { - if (formation) opt.eff.formation = formation - applyEffect(w, opt.eff, squad) + applyEffect(w, opt.eff, squad, undefined, formation) if (def.once && !s.completedEvents.includes(def.id)) s.completedEvents.push(def.id) } s.pendingEvent = undefined @@ -468,7 +467,7 @@ export function rankPower(w: World, c: Character): number { return order.indexOf(c.realm.major) * 10 + c.realm.minor } -export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { +export function applyEffect(w: World, eff: EffectDef, squad?: string[], _extra?: unknown, formation?: string): void { const s = w.state const fam = s.family @@ -513,7 +512,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void { if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech) } if (eff.tournament) { - runTournament(w, squad, eff.formation as never) + runTournament(w, squad, formation as never) } if (eff.apprentice?.build) { buildApprentice(w) diff --git a/src/renderer/game/engine/runtime/Systems/missions.ts b/src/renderer/game/engine/runtime/Systems/missions.ts index 336320f..2152c60 100644 --- a/src/renderer/game/engine/runtime/Systems/missions.ts +++ b/src/renderer/game/engine/runtime/Systems/missions.ts @@ -3,6 +3,7 @@ import { MissionState } from '../../../types/domain' import { FormationId } from '../../../data/formations' import { missionById, MissionDef, ENEMIES } from '../../../data/secrets' import { resolveEncounter, rollWarbooty } from './combat' +import { WorldSim } from '../../sim/WorldSim' import { techniqueById } from '../../../data/techniques' import { describeRealm } from '../../../data/realms' @@ -138,14 +139,9 @@ 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 - } + // 世界秘境灵气消耗(统一走 WorldSim 入口) + if (w.state.worldSim) { + new WorldSim(w).consumeSecretQi(def.id) } w.state.missions.push(m) w.state.family.missionIds.push(m.id) diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index b1d70d0..1398bb1 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -17,10 +17,6 @@ import { createWorldState, findInheritor } from './creation' import { SYSTEM_DEFS, SystemDef } from './capabilities' import { emptyClock } from './clocks' import { Rng } from '../kernel/rng' - -function clockFromKernel(_target: GameClock, source: GameClock): void { - void source -} import { CotycPlugin, PluginContext, PluginStatus } from '../kernel/plugin' import { PluginManager } from './pluginManager' import { EventDef } from '../../data/events' @@ -79,6 +75,15 @@ export function normalizeGameState(state: GameState): GameState { 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 (state.family.headId && !state.members[state.family.headId]) { const firstAlive = Object.values(state.members).find((c) => c.alive) if (firstAlive) state.family.headId = firstAlive.id @@ -275,10 +280,6 @@ export class World { this.out.forEach((o) => o.onGameOver(reason, year)) } - gameOver___placeholder(): void { - void 0 - } - memberById(id: Id): Character { const c = this.state.members[id] if (!c) throw new Error(`member not found ${id}`) @@ -316,6 +317,13 @@ export class World { this.pruneYearFlags() } + applyEventChoice(eventId: string, idx: number): boolean { + const pid = this.state.pendingEvent + if (!pid) return false + applyEventChoice(this, pid, idx) + return true + } + requeueEvent(eventId: string): void { const s = this.state if (!s.eventQueue.includes(eventId)) s.eventQueue.push(eventId) diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index 31ba150..ab89785 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -1,6 +1,7 @@ /** WorldSim —— 世界自进化引擎(game/engine/sim/WorldSim.ts) */ import { World } from '../runtime/World' -import { WorldSimState, NpcDynamics, WORLDSIM, CALAMITY_EFFECT, CalamityName } from './worldsim-data' +import { WorldSimState, NpcDynamics, WORLDSIM, CALAMITY_EFFECT, CalamityName, POOL_BASE } from './worldsim-data' +import { pack } from '../../data/registry' import { ITEMS } from '../../data/items' import { npcById } from '../../data/npcs' import { MAJORS, MAJOR_ORDER } from '../../data/realms' @@ -13,7 +14,13 @@ export class WorldSim { private s(): WorldSimState { const w = this.w if (!w.state.worldSim) w.state.worldSim = initSim(w) as never - return w.state.worldSim as WorldSimState + const ws = w.state.worldSim as WorldSimState + if (!ws.secretQi || Object.keys(ws.secretQi).length === 0) { + const defs = pack().missions + ws.secretQi = {} + for (const m of defs) ws.secretQi[m.id] = 50 + } + return ws } tick(): void { @@ -37,6 +44,8 @@ export class WorldSim { s.calamityYear = this.w.state.year applyCalamityToMarket(s, cl as CalamityName) this.w.log('bad', `【天下灾年】${cl}——灵植减产,市价将行。`) + } else { + s.calamity = undefined } // ---- A. 资源循环市场(库存自然流向 + 再平衡 + 价格信号) ---- @@ -46,7 +55,7 @@ export class WorldSim { evolveNpc(this.w, s, rng.next()) // ---- E. 天下快讯(节流) ---- - if (this.w.state.month !== s.lastNewsMonth && this.w.state.totalTicks % WORLDSIM.newsEvery === 0) { + if ((this.w.state.totalTicks % WORLDSIM.newsEvery) === 0) { pushNews(this.w, s, MARKET_IDS) } @@ -54,13 +63,19 @@ export class WorldSim { if (s.newsFeed.length > WORLDSIM.newsKeep) s.newsFeed.splice(0, s.newsFeed.length - WORLDSIM.newsKeep) } - /** 秘境探索消耗灵气(missions 调用) */ - consumeSecretQi(id: string, amount: number): void { + /** 秘境探索消耗灵气(missions 调用;统一入口,默认 6 点) */ + consumeSecretQi(id: string, amount = 6): void { const s = this.s() if (s.secretQi[id] === undefined) s.secretQi[id] = 50 s.secretQi[id] = clamp(s.secretQi[id] - amount, 0, 100) } + /** 秘境灵气对探索收益的门槛(低灵气收益衰减提示) */ + secretQiOf(id: string): number { + const s = this.s() + return s.secretQi[id] ?? 50 + } + /** 灵气系数(修炼/掉落市场乘子) */ tideMult(): number { return this.s().tide @@ -99,8 +114,6 @@ function initSim(w: World): WorldSimState { relationsWithOthers: {} } } - for (const sid of Object.keys(w.state.missions ?? {})) void sid - // 秘境灵气初始(从 mission 定义 id) s.secretQi = {} return s } @@ -121,12 +134,15 @@ function empty(): WorldSimState { } function driftMarket(s: WorldSimState, noise: number): void { + let n = noise for (const id of MARKET_IDS) { + const per = n; n += 0.13 const base = poolBase(id) const cur = s.marketPool[id] ?? base // 向基准再平衡 + 噪声漂移(价格弹性) const rebalance = (base - cur) * WORLDSIM.marketRebalance - const drift = (noise - 0.5) * WORLDSIM.marketDriftRate * base + const drift = ((per % 1) - 0.5) * WORLDSIM.marketDriftRate * base + s.marketPool[id] = Math.max(base * 0.3, cur + rebalance + drift) } } @@ -139,9 +155,8 @@ function applyCalamityToMarket(s: WorldSimState, cl: CalamityName): void { } function poolBase(id: string): number { - return MARKET_BASE[id] ?? 100 + return POOL_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 @@ -149,15 +164,18 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { 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)) { + const curRealm = MAJOR_ORDER[dyn.leaderRealmIdx] ?? def.leaderRealm + const lifespan = MAJORS[curRealm].lifespan + if (w.state.month === 1) { + dyn.leaderAge++ + // 换代评估:寿终阈值 或 年首小概率(退隐/遇害)——约 50 年一代 + const chanceNow = dyn.leaderAge > lifespan * 0.9 ? 0.5 : 0.02 + if (w.rng.chance(chanceNow) && !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('氏', '')}氏新主` + npc.power = Math.round(Math.max(40, npc.power + 15)) dyn.lastEvent = '宗祧更替' dyn.lastEventYear = year s.npcSuccessions++ @@ -165,11 +183,7 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { 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) - } + void npc } } @@ -189,18 +203,28 @@ 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)}%` + let row: { year: number; month: number; src: string; text: string } + if (id.startsWith('n-')) { + const def = npcById(id) + const dyn = s.npcDyn[id] + row = { + year: w.state.year, + month: w.state.month, + src: def.name, + text: dyn ? `灵潮气象:${dyn.leaderName} 宗主更替,势力重排。` : `${def.name} 世务新张。` + } + } else { + const pool = clamp(s.marketPool[id] ?? poolBase(id), POOL_BASE[id] * 0.5, POOL_BASE[id] * 1.8) + const base = poolBase(id) + const pct = Math.round(((pool - base) / base) * 100) + const itemName = ITEMS[id]?.name ?? id + row = { + year: w.state.year, + month: w.state.month, + src: `世界·${itemName}`, + text: pct !== 0 ? `${itemName}行情${pct > 0 ? '升' : '降'}${Math.abs(pct)}% (${tide})` : `${itemName}行情平稳 (${tide})` + } } s.newsFeed.push(row) s.lastNewsMonth = w.state.month diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index 181f396..1cbab55 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -50,6 +50,15 @@ export function makeWorldSimState(): WorldSimState { } } +/** 市场基准库存(单一权威:WorldSim 再平衡 / Market 行情 / brief 图标 三方共用) */ +export const POOL_BASE: Record = { + lingcao: 600, + lingkuang: 300, + beastcore: 80, + 'pill-qiyuan': 90, + 'pill-ningyuan': 40 +} + /** 世界演化参数表(全部可调) */ export const WORLDSIM = { marketDriftRate: 0.03, diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 93072f6..2981fb7 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -128,6 +128,7 @@ export class SaveSlot { 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)) diff --git a/src/renderer/ui/components/EventModal.tsx b/src/renderer/ui/components/EventModal.tsx index c2c50a1..898cc91 100644 --- a/src/renderer/ui/components/EventModal.tsx +++ b/src/renderer/ui/components/EventModal.tsx @@ -41,7 +41,7 @@ export function EventModal() { setSquadSel(squadPool.slice(0, 4).map((c) => c.id)) return } - applyEventChoice(world, pendingEventId, idx, isRaid ? squadSel : undefined) + applyEventChoice(world, pendingEventId, idx, isRaid ? squadSel : undefined, undefined, isRaid ? formation : undefined) closeModal() const sp = useGameStore.getState().speed if (sp > 1) setSpeed(1) diff --git a/src/renderer/ui/components/LogFeed.tsx b/src/renderer/ui/components/LogFeed.tsx index 536d483..1857958 100644 --- a/src/renderer/ui/components/LogFeed.tsx +++ b/src/renderer/ui/components/LogFeed.tsx @@ -1,12 +1,13 @@ +import { memo } from 'react' import { useGameStore } from '../store' -export function LogFeed() { +export const LogFeed = memo(function LogFeed() { const feed = useGameStore((s) => s.logFeed) - const ref = useGameStore((s) => s.revision) - void ref + void useGameStore((s) => s.revision) + const reversed = feed.slice().reverse() return (
- {feed.slice().reverse().map((item) => ( + {reversed.map((item) => (
{item.year}年{item.month}月 {item.text} @@ -14,4 +15,4 @@ export function LogFeed() { ))}
) -} +}) diff --git a/src/renderer/ui/components/MemberCard.tsx b/src/renderer/ui/components/MemberCard.tsx index ee0d015..28a5fda 100644 --- a/src/renderer/ui/components/MemberCard.tsx +++ b/src/renderer/ui/components/MemberCard.tsx @@ -57,11 +57,6 @@ export function MemberCard({ c }: { c: Character }) { ) } -function postLabel(c: Character): string { - const map: Record = { head: '家主', elder: '长老', guardian: '供奉', steward: '执事', master: '掌教' } - return map[c.post ?? ''] ?? '' -} - const C_STATE: Record = { idle: '', meditation: 'good', diff --git a/src/renderer/ui/fx-canvas.ts b/src/renderer/ui/fx-canvas.ts index d4bb074..d288372 100644 --- a/src/renderer/ui/fx-canvas.ts +++ b/src/renderer/ui/fx-canvas.ts @@ -79,12 +79,20 @@ function spawn(kind: 'gold' | 'mist' | 'light', x?: number, y?: number): void { if (particles.length > density * 6) particles.splice(0, particles.length - density * 6) // 竞态防胀 } +import { ensureFx } from './fx' + function frame(): void { if (!running) return + const g = ensureFx() + if (g.preferReduced) { + // reduced-motion:粒子层静默,但保留单次清屏 + raf = window.requestAnimationFrame(frame as FrameRequestCallback) + return + } ctx?.clearRect(0, 0, window.innerWidth, window.innerHeight) const dt = 1 mistTimer += 1 - if (mistTimer % 18 === 0 && particles.length < density) spawn('mist') + if (mistTimer % 18 === 0 && particles.length < density && g.mode !== 'soft') spawn('mist') for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i] p.life += dt @@ -134,6 +142,8 @@ export function stopFxLayer(): void { if (raf) cancelAnimationFrame(raf) raf = 0 particles = [] + mistTimer = 0 + window.removeEventListener('resize', resize) } export function spawnBurst(kind: 'spark' | 'blade' | 'ripple', origin?: string): void { diff --git a/src/renderer/ui/panels/ChroniclePanel.tsx b/src/renderer/ui/panels/ChroniclePanel.tsx index 265b2e7..00e0f11 100644 --- a/src/renderer/ui/panels/ChroniclePanel.tsx +++ b/src/renderer/ui/panels/ChroniclePanel.tsx @@ -1,6 +1,5 @@ import { useGameStore } from '../store' import { useMemo, useState } from 'react' -import { ChronicleEntry } from '../../game/types/domain' const CAT_NAME: Record = { birth: '诞庆', @@ -57,7 +56,7 @@ export default function ChroniclePanel() { const all = [...world.state.chronicle] const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter) return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month)) - }, [world, filter, revision]) + }, [world, filter, world ? world.state.chronicle.length : 0]) if (!world) return null const battles = world.state.battles.slice().reverse() const battle = battles.find((b) => b.id === battleId) diff --git a/src/renderer/ui/panels/GenealogyPanel.tsx b/src/renderer/ui/panels/GenealogyPanel.tsx index bdb1ac2..b10a421 100644 --- a/src/renderer/ui/panels/GenealogyPanel.tsx +++ b/src/renderer/ui/panels/GenealogyPanel.tsx @@ -2,7 +2,7 @@ import { useGameStore } from '../store' import { computeGenealogy } from '../../game/engine/narrative/genealogy' import { describeRealm } from '../../game/data/realms' import { Character, FamilyState } from '../../game/types/domain' -import { useState } from 'react' +import { useMemo, useState } from 'react' import { MemberModal } from '../components/MemberModal' export default function GenealogyPanel() { @@ -16,7 +16,7 @@ export default function GenealogyPanel() { if (!world) return null const w = world const s = w.state - const rows = computeGenealogy(w) + const rows = useMemo(() => computeGenealogy(w), [w, revision]) const deceased = !deceasedTab ? [] : Object.values(s.members).filter((c) => !c.alive).sort((a, b) => (a.deathYear ?? 0) - (b.deathYear ?? 0)) @@ -125,4 +125,3 @@ const POST_NAME: Record = { master: '掌教' } -export type { FamilyState } diff --git a/src/renderer/ui/panels/LegacyPanel.tsx b/src/renderer/ui/panels/LegacyPanel.tsx index 5a419ff..a3d89f2 100644 --- a/src/renderer/ui/panels/LegacyPanel.tsx +++ b/src/renderer/ui/panels/LegacyPanel.tsx @@ -1,13 +1,11 @@ import { useState } from 'react' import { useGameStore } from '../store' import { computeLegacy, resolveLegacy } from '../../game/engine/narrative/legacy' -import { resolveLegacy as doesIt } from '../../game/engine/narrative/legacy' import { ResolveModal } from '../components/ResolveModal' import { buildBiography } from '../../game/engine/narrative/biography' import { yearAxis, decadeLabel } from '../../game/engine/narrative/yearaxis' import { buildReport, verdictLine } from '../../game/engine/narrative/legacyreport' -void doesIt export default function LegacyPanel() { const world = useGameStore((s) => s.world) diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index ee6c116..62632e7 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -210,10 +210,9 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。
-
版本 0.1.12 · Chronicle of the Immortal Clan
+
版本 0.1.15 · Chronicle of the Immortal Clan
) } -void buildBiography diff --git a/src/renderer/ui/screens/GameScreen.tsx b/src/renderer/ui/screens/GameScreen.tsx index 9b557f6..c2e2e38 100644 --- a/src/renderer/ui/screens/GameScreen.tsx +++ b/src/renderer/ui/screens/GameScreen.tsx @@ -74,7 +74,7 @@ export default function GameScreen() {
- + diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index ed17685..0c19eea 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -7,8 +7,7 @@ import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, 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, sGong, sClick, sTick } from './sound' -import type { PhaseStat } from '../game/engine/kernel/clock' +import { setSoundEnabled, sPaper, sGood, sBad, sWar, sBell, sTick } from './sound' import { seasonOf } from '../game/data/season' export type Screen = 'boot' | 'newgame' | 'game' @@ -69,7 +68,6 @@ export interface GameStore { } const LOG_CAP = 260 -const lastPhaseStats = new Map() let logSeq = 1 function staddEventLog(name: string): void { @@ -195,12 +193,20 @@ export const useGameStore = create((set, get) => ({ const prevMonth = w.state.month w.advanceMonth() const elapsed = performance.now() - t0 + if (import.meta.env?.DEV && elapsed > 60) { + // 仅统计(只读 clock 快照),禁止再跑整月管线 + const stats = w.clock.snapshot() + if (stats && stats.length) console.warn('[perf] tick 超预算', elapsed.toFixed(1) + 'ms') + } try { const nextInv = { ...w.state.family.inventory, stones: w.state.family.stones } const FxGate = (await import('../game/engine/kernel/fxqueue')).FxGate const deltas = FxGate.deltas(prevInv, nextInv) - const windowRef = window as unknown as { dispatchEvent: (e: Event) => void } - windowRef.dispatchEvent(new CustomEvent('fx:drift', { detail: deltas })) + const fxmod = await import('./fx') + if (!fxmod.ensureFx().preferReduced) { + const windowRef = window as unknown as { dispatchEvent: (e: Event) => void } + windowRef.dispatchEvent(new CustomEvent('fx:drift', { detail: deltas })) + } const seasonNow = seasonOf(w.state.month) const seasonPrev = seasonOf(prevMonth) if (seasonNow !== seasonPrev) { @@ -212,11 +218,8 @@ export const useGameStore = create((set, get) => ({ // fx 为增强项,失败不阻塞推进 } if (import.meta.env?.DEV && elapsed > 60) { - // 性能预算:单 tick 超 60ms 提示最慢 phase - const stats = w.clock.stepMonthly(w) - void stats - const slowest = lastPhaseStats.get(w)?.sort((a, b) => b.ms - a.ms)[0] - if (slowest) console.warn('[perf] tick 超预算', elapsed.toFixed(1) + 'ms', '最慢:', slowest.phase, slowest.ms.toFixed(1) + 'ms') + // 性能预算:仅告警(绝不重跑整月管线) + console.warn('[perf] tick 超预算', elapsed.toFixed(1) + 'ms') } w.syncRng() sTick() diff --git a/src/renderer/ui/styles.css b/src/renderer/ui/styles.css index 5f95f0d..d2a700c 100644 --- a/src/renderer/ui/styles.css +++ b/src/renderer/ui/styles.css @@ -1305,6 +1305,56 @@ html[data-blade] .battle-lines { border-bottom: 1px solid rgba(120, 98, 55, 0.25); padding-bottom: 14px; } +.modal-close { + position: absolute; + top: 14px; + right: 16px; + width: 32px; + height: 32px; + border-radius: 50%; + background: linear-gradient(180deg, #3a2d1a, #241a0d); + border: 1px solid #8a6d2f; + color: #e8d5a4; + font-size: 1.3rem; + cursor: pointer; + font-family: inherit; + z-index: 6; + display: flex; + align-items: center; + justify-content: center; + box-shadow: 0 2px 8px rgba(0,0,0,0.45), inset 0 1px 0 rgba(255,240,200,0.15); + transition: all 0.16s; +} +.modal-close:hover { + border-color: var(--vermilion); + color: #f4d8c0; + transform: scale(1.06); +} +.modal-body { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding-right: 4px; + width: 100%; +} +.modal-footer { + flex-shrink: 0; + margin: 14px -15px -8px; + padding: 12px 20px 10px; + border-top: 1px solid rgba(140, 100, 50, 0.45); + border-radius: 0 0 4px 4px; + display: flex; + justify-content: flex-end; + gap: 10px; + align-items: center; + flex-wrap: wrap; + background: linear-gradient(180deg, rgba(122, 96, 54, 0.12), rgba(90, 70, 40, 0.22)); + box-shadow: inset 0 1px 0 rgba(255, 248, 230, 0.25); +} +.modal-title { + padding-right: 44px; +} .modal-opts { display: flex; flex-direction: column; diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index 6cca83d..b493950 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('5dac4915') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('b7c3ff06') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a977654a') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ee501983') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 21c1349..d741544 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,11 +7,11 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.14 正式基线:worldsim 世界自进化(确定性重写后)三档固化。 +// 0.1.15 深度审计最终基线:worldsim 修复(换代年首化/power年度/relation去漂/快讯节流/灵脉初始化)后固化。 const GOLDEN: Record> = { - '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' } + 'bell-seed-1': { 560: 'a977654a', 1200: 'faefa00a', 2160: '5b7b375b' }, + 'bell-seed-2': { 560: '1c41881c', 1200: 'a06af06c', 2160: '142d04c3' }, + 'bell-seed-3': { 560: 'ee501983', 1200: '2a899a90', 2160: '0e490e5c' } } const TIERS = [ diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index 9c52768..275a3ae 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.11') + expect(info.version).toContain('0.1.15') 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('5dac4915') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('b7c3ff06') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a977654a') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ee501983') }) }) diff --git a/tests/gameengine.test.ts b/tests/gameengine.test.ts index d96f85d..c198f52 100644 --- a/tests/gameengine.test.ts +++ b/tests/gameengine.test.ts @@ -65,13 +65,23 @@ describe('Kernel 三合一', () => { }) }) +function runEngine(e: GameEngine, months: number): void { + for (let i = 0; i < months; i++) { + if (e.view().gameOver) break + e.advance() + // 引擎语义:遇到待决事件停下来等处理;自动化直接取第一个选项继续 + let guard = 0 + while (e.view().pendingEvent && guard < 4) { + e.resolvePending(0) + guard++ + } + } +} + describe('WorldSim 世界健康(长跑)', () => { it.each(['ws-1', 'ws-2', 'ws-3'].map((s) => [s] as const))('%s:1000 月市场池/灵气在界、世界不崩', (seed) => { const e = new GameEngine({ seed }) - for (let i = 0; i < 1000; i++) { - if (e.view().gameOver) break - e.advance() - } + runEngine(e, 1000) const ws = e.view().worldSim as (Record & { marketPool: Record secretQi: Record @@ -88,20 +98,14 @@ describe('WorldSim 世界健康(长跑)', () => { it('NPC 换代至少发生一次(世界在演化)', () => { const e = new GameEngine({ seed: 'ws-evolve' }) - for (let i = 0; i < 720; i++) { - if (e.view().gameOver) break - e.advance() - } + runEngine(e, 1200) const ws = e.view().worldSim as { npcSuccessions?: number } | undefined expect(ws?.npcSuccessions ?? 0).toBeGreaterThan(0) }) it('快讯持有且裁剪符合 N(≤120)', () => { const e = new GameEngine({ seed: 'ws-news' }) - for (let i = 0; i < 400; i++) { - if (e.view().gameOver) break - e.advance() - } + runEngine(e, 400) const ws = e.view().worldSim as { newsFeed?: unknown[] } | undefined expect(ws?.newsFeed.length ?? 0).toBeLessThanOrEqual(WORLDSIM.newsKeep) }) diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index b58f180..e469042 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('5dac4915') - expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('53005425') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('b7c3ff06') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('a977654a') + expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('1c41881c') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ee501983') }) it('facade 插件查询与 about.plugins', () => {