feat(0.1.14-P4/P5): UI 单点 + 引擎级测试(979)+ 顶栏天下风云

- GameScreen 顶栏「天下风云」胶囊:潮汐印(灵涨/汐平/灵衰)+ 物价标签(物腾/平稳/物贱)
  + title 显示最近快讯(worldsim-brief 纯函数)
- store 接 GameEngine 起点(startNewGame 走引擎;facade/engine 单源)
- 新测试 gameengine.test.ts ×12:引擎门面(构造/advance单源/act+query+subscribe/datapack覆写+restore/about)
  Kernel 三合一 / WorldSim 世界健康(3 seed × 1000 月:池/灵气在界、换代>0、快讯≤120)/ sim 联动
- 全量 36 套件 / 979 测试通过;金钟罩三档固化(0.1.14 正式);typecheck 0 error
- AGENTS 补「引擎系统」章节;版本 0.1.14
This commit is contained in:
2026-08-23 13:33:39 +08:00
parent 5dd5d6b558
commit 26bd61fba0
7 changed files with 272 additions and 4 deletions
+94
View File
@@ -0,0 +1,94 @@
# 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 --noEmitnpm 会打 warn 噪音,用 npx tsc --noEmit 更干净)
npm run dev # electron-vite 热更新(开发期 origin 为 http://localhost:5173
npm run build # 产物到 out/
npm run package # build + electron-builderLinux 打 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-eventsprotected 不可卸)。
- **事件多池**`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` GameFacadeact 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。
- **金钟罩**:0.1.11 审计修复批次后三档指纹(47/100/180 年 × 3 seed = 9 值,见 tests/clock.test.ts0.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 只认识 engineworld 为兼容仍暴露)。
- **内核三合一** `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/runtimeWorld/插件/门面/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`**:月度 phaseproduction/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 是 mutableadvance 后 `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`。
- 数据在浏览器 OPFSrenderer 内),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 -- <file>` / 整文件回滚**(这是历史事故元凶)。确需回滚时:`git show HEAD:<file> > /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,勿入库。
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.13", "version": "0.1.14",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
@@ -0,0 +1,13 @@
import { World } from '../runtime/World'
export function worldSimBrief(w: World): { tideLabel: string; priceLabel: string; latest: string } | null {
const ws = w.state.worldSim
if (!ws) return null
const tide = ws.tide ?? 0.5
const tideLabel = tide > 0.9 ? '灵涨' : tide < 0.7 ? '灵衰' : '汐平'
const pool = ws.marketPool ?? {}
const ratio = (pool['lingcao'] ?? 600) / 600
const priceLabel = ratio > 1.15 ? '物腾' : ratio < 0.85 ? '物贱' : '平稳'
const latest = ws.newsFeed?.[ws.newsFeed.length - 1]?.text ?? ''
return { tideLabel, priceLabel, latest }
}
+17
View File
@@ -16,6 +16,7 @@ import { fmt as fmtNum } from '../../game/engine/kernel/format'
import { seasonOf } from '../../game/data/season' import { seasonOf } from '../../game/data/season'
import { seasonTint, yearRing, tideOf } from '../../game/engine/kernel/timesense' import { seasonTint, yearRing, tideOf } from '../../game/engine/kernel/timesense'
import landscapeUrl from '../../assets/gen/landscape-gold.svg' import landscapeUrl from '../../assets/gen/landscape-gold.svg'
import { worldSimBrief } from '../../game/engine/sim/worldsim-brief'
import { GuideStrip } from '../components/GuideStrip' import { GuideStrip } from '../components/GuideStrip'
const TABS: { id: PanelId; label: string }[] = [ const TABS: { id: PanelId; label: string }[] = [
@@ -87,6 +88,7 @@ export default function GameScreen() {
</div> </div>
))} ))}
</div> </div>
<WorldCondensed />
<button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}></button> <button className="btn btn-sm" title="手动存点" onClick={() => void saveNow()}></button>
</div> </div>
<UrgentBadges /> <UrgentBadges />
@@ -139,6 +141,21 @@ export default function GameScreen() {
) )
} }
function WorldCondensed() {
const revision = useGameStore((s) => s.revision)
void revision
const st = useGameStore.getState()
if (!st.world) return null
const brief = worldSimBrief(st.world)
if (!brief) return null
return (
<button className="world-badge" title={`潮汐${brief.tideLabel} · 物价${brief.priceLabel} · 快讯:${brief.latest ?? ''}`}>
<span className="seal">{brief.tideLabel}</span>
<span className="wb-price">{brief.priceLabel}</span>
</button>
)
}
function dispRes(id: string): number { function dispRes(id: string): number {
const st = useGameStore.getState() const st = useGameStore.getState()
const w = st.world const w = st.world
+7 -3
View File
@@ -1,5 +1,6 @@
import { create } from 'zustand' import { create } from 'zustand'
import { World, WorldEventBus } from '../game/engine/runtime/World' import { World, WorldEventBus } from '../game/engine/runtime/World'
import { GameEngine } from '../game/engine/GameEngine'
import { EventDef } from '../game/data/events' import { EventDef } from '../game/data/events'
import { findEvent, applyEventChoice } from '../game/engine/runtime/Systems/events' import { findEvent, applyEventChoice } from '../game/engine/runtime/Systems/events'
import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain' import { BattleLog, ChronicleEntry, GameState, LogItem, SaveMeta, YearlyReport, SnapshotMeta } from '../game/types/domain'
@@ -61,6 +62,7 @@ export interface GameStore {
closePaper: () => void closePaper: () => void
toggleSound: () => void toggleSound: () => void
facade?: GameFacade facade?: GameFacade
engine?: GameEngine
advancing: boolean advancing: boolean
setNextToast: (msg: string) => void setNextToast: (msg: string) => void
act: (name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload) => boolean act: (name: ActName, payload: import('../game/engine/runtime/ApiFacade').ActPayload) => boolean
@@ -88,6 +90,7 @@ function stopTimer(): void {
export const useGameStore = create<GameStore>((set, get) => ({ export const useGameStore = create<GameStore>((set, get) => ({
facade: undefined as GameFacade | undefined, facade: undefined as GameFacade | undefined,
engine: undefined as GameEngine | undefined,
advancing: false, advancing: false,
setNextToast: (msg: string) => setNextToast(msg), setNextToast: (msg: string) => setNextToast(msg),
screen: 'boot', screen: 'boot',
@@ -144,11 +147,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
go: (screen) => set({ screen }), go: (screen) => set({ screen }),
startNewGame: async (opts, slot) => { startNewGame: async (opts, slot) => {
const world = World.create(opts) const engine = new GameEngine({ seed: opts.seed })
set({ world, 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 }) 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 })
const st = get() const st = get()
st.world?.out.push(makeBus(st)) st.world?.out.push(makeBus(st))
const facade = new GameFacade(world, slot) const facade = st.facade ?? new GameFacade(world, slot)
set({ facade }) set({ facade })
await st.saveNow('开局') await st.saveNow('开局')
}, },
+22
View File
@@ -1799,3 +1799,25 @@ html[data-blade] .battle-lines {
max-height: 120px; max-height: 120px;
overflow-y: auto; overflow-y: auto;
} }
/* ---------- 天下风云徽章 ---------- */
.world-badge {
display: inline-flex;
align-items: center;
gap: 6px;
background: rgba(38, 29, 16, 0.65);
border: 1px solid #3a3021;
border-radius: 3px;
padding: 3px 10px;
cursor: pointer;
font-family: inherit;
color: #cbbd9e;
transition: all 0.15s;
}
.world-badge:hover {
border-color: var(--gold);
}
.world-badge .wb-price {
font-size: 0.8rem;
color: #a8c2da;
}
+118
View File
@@ -0,0 +1,118 @@
import { describe, expect, it } from 'vitest'
import { GameEngine } from '../src/renderer/game/engine/GameEngine'
import { makeKernel } from '../src/renderer/game/engine/kernel/Kernel'
import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim'
import { WORLDSIM } from '../src/renderer/game/engine/sim/worldsim-data'
describe('GameEngine 引擎门面', () => {
it('构造:内核注入世界 + 门面 + 数据包', () => {
const e = new GameEngine({ seed: 'engine-1' })
expect(e.world).toBeTruthy()
expect(e.facade).toBeTruthy()
expect(e.kernel.clock.subscriptionCount()).toBeGreaterThanOrEqual(10)
expect(e.status()).toBe('running')
})
it('advance 驱动内核时钟(确定单源)', () => {
const e = new GameEngine({ seed: 'engine-2' })
const y0 = e.view().year
const m0 = e.view().month
e.advance()
const crossed = e.view().month === 1 && e.view().year === y0 + 1
const normal = e.view().month === m0 + 1
expect(crossed || normal).toBe(true)
})
it('act/query/subscribe 全链路', () => {
const e = new GameEngine({ seed: 'engine-3' })
const events: string[] = []
const unsub = e.subscribe((ev) => events.push(ev.type))
expect(e.act('member.post', { memberId: 'x3', post: 'elder' })).toBe(true)
e.advance()
// 一次 advance 必然产生内核事件流(log/pending/paper 至少其一)
expect(events.length).toBeGreaterThan(0)
unsub()
const fam = e.query('family') as { year: number }
expect(fam.year).toBeGreaterThanOrEqual(1)
})
it('datapack 覆写生效 + restore 可回放', () => {
const e = new GameEngine({ seed: 'engine-4' })
e.datapack.override({
items: { ...e.datapack.current().items, lingcao: { ...e.datapack.current().items['lingcao'], basePrice: 250 } }
})
const snap = e.snapshot()
expect(snap.family.stones).toBeGreaterThanOrEqual(0)
e.restore(snap)
expect(e.status()).toBe('running')
})
it('about 元数据含 plugins(≥3)与版本 0.1.14', () => {
const e = new GameEngine({ seed: 'engine-5' })
const a = e.about()
expect(a.plugins).toBeGreaterThanOrEqual(3)
expect(a.version).toContain('0.1.14')
})
})
describe('Kernel 三合一', () => {
it('时钟/随机/总线协同(同 seed 同随机序列)', () => {
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')
})
})
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()
}
const ws = e.view().worldSim as (Record<string, unknown> & {
marketPool: Record<string, number>
secretQi: Record<string, number>
tide: number
}) | undefined
expect(ws).toBeTruthy()
if (ws) {
for (const v of Object.values(ws.marketPool)) expect(v).toBeGreaterThan(0)
for (const v of Object.values(ws.secretQi ?? {})) expect(v).toBeGreaterThanOrEqual(0)
expect(ws.tide).toBeGreaterThan(0.5)
expect(ws.tide).toBeLessThan(1.4)
}
})
it('NPC 换代至少发生一次(世界在演化)', () => {
const e = new GameEngine({ seed: 'ws-evolve' })
for (let i = 0; i < 720; i++) {
if (e.view().gameOver) break
e.advance()
}
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()
}
const ws = e.view().worldSim as { newsFeed?: unknown[] } | undefined
expect(ws?.newsFeed.length ?? 0).toBeLessThanOrEqual(WORLDSIM.newsKeep)
})
})
describe('Sim 与外层联动', () => {
it('tideMult 在界(0.5-1.4)且随年景变化', () => {
const e = new GameEngine({ seed: 'ws-tide-link' })
const sim = new WorldSim(e.world)
const before = sim.tideMult()
expect(before).toBeGreaterThanOrEqual(0.5)
expect(before).toBeLessThan(1.4)
})
})