v0.1.8: 万物皆是插件 + 全应用审计修复(60+ 缺陷歼灭)

插件协议:
- CotycPlugin Manifest(dependencies/conflicts/protected/install/uninstall)
- PluginManager:安装序确定性/依赖拒绝/异常回滚/追踪型上下文(卸载全摘钩子)
- 三核心插件常驻(Systems/Data/Events);插件清单/安装移除/启停广播
- EventPoolRegistry 多池合并;facade about/query('plugins')/plugin 订阅
- 设置页插件清单(含卸载按钮、核心保护);examplePlugin 开发范本入仓

全应用审计修复(引擎 39 + UI/主进程 40 双路清单,P0→P2 歼灭):
- P0:单亲 rollRoots 崩/插件事件软锁(findEvent 查池)/fire 覆盖幸存事件/
  facade 未赋值(act 目录 6/24 生效→全量接通)/packOverride 死实现真接管/
  4 能力卡无接线(trib/apprentice/tournament/season)/读档 rowid 方言崩溃/
  buildApprentice 空候选崩/插件安装无回滚/trait 加成从未消费/DEV 双跑破坏时序
- P1:定时器幸存/advance in-flight 闸/battle 暂停/tournament 点将真传/
  史书 memo 依赖/面板 hooks 顺序违规/远征 squad 残留 id/跨档状态残留/
  toast 自清除/读档错误提示/导入后重载世界/pendingEvent 读档回填
- P2:版本三处统一/NewGame 随机收编/学费错误文案/姓氏池去重/
  功法品阶错位(凡阶哨兵)/功法定价对齐/market 白名单校验/
  战利功法去重/回声漏封缄/clock 迭代快照/core 事件池保护/
  渡劫陨落走真突破/寄读 desc 诚实化/doas 死代码清理等 30 项

测试 239→256;金钟罩复验(防御批零漂移)
This commit is contained in:
2026-08-23 09:58:15 +08:00
parent 9e0b06e898
commit c09395a6d4
39 changed files with 932 additions and 157 deletions
+9 -1
View File
@@ -32,12 +32,20 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附
- 私有 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.8 修复批次后三指纹 9af71ecb / 63cede89 / ebfec4a4(受控变更已在注释记录)。
## 架构要点
- `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 月指纹常驻。任何改动若破坏确定性立即红;**有意变更时序时**三枚指纹一并小重算并在注释注明原因。
- **金钟罩 `tests/clock.test.ts`**3 枚固定 seed 560 月指纹常驻0.1.8 基线:9af71ecb/63cede89/ebfec4a4。任何改动若破坏确定性立即红;**有意变更时序时**三枚指纹一并小重算并在注释注明原因0.1.6 加时节、0.1.8 加特征/利己/飞升窗口时曾受控重算)
- `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 依赖。
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志",
"version": "0.1.7",
"version": "0.1.8",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js",
"author": "MetonaTeam",
+6 -1
View File
@@ -149,8 +149,13 @@ async function capture(
function serveAppProtocol(): void {
protocol.handle(SCHEME, (req) => {
let pathname = ''
try {
const url = new URL(req.url)
let pathname = decodeURIComponent(url.pathname)
pathname = decodeURIComponent(url.pathname)
} catch {
return new Response('bad request', { status: 400 })
}
if (pathname === '/') pathname = '/index.html'
const target = normalize(join(RENDERER_ROOT, pathname)) + sep
const resolved = normalize(join(RENDERER_ROOT, pathname))
+3 -4
View File
@@ -48,13 +48,12 @@ export function buildBiography(s: GameState, memberId: string): BioLine[] {
}
// 大比
const tourneys = s.stats.tourneyHistory.filter((t) => true).slice(-1)
const hisTourney = tourneys.filter((t) => {
const hisTourneys = s.stats.tourneyHistory.filter((t) => {
const battle = s.battles.find((b) => b.year === t.year && b.title.includes('大比'))
return battle?.lines.some((l) => l.includes(c.name))
})
if (hisTourney.length > 0) {
lines.push({ at: hisTourney[0].year, label: '大比', text: `${hisTourney[0].year}年,随队参加太虚大比,列第${hisTourney[0].rank}名。` })
for (const t of hisTourneys) {
lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` })
}
// 墓碑铭文
+15 -3
View File
@@ -38,14 +38,22 @@ export class GameClock {
private monthly = new Map<PhaseId, SystemHook[]>()
private yearly: SystemHook[] = []
register(phase: PhaseId, fn: SystemHook): void {
register(phase: PhaseId, fn: SystemHook): () => void {
const list = this.monthly.get(phase) ?? []
list.push(fn)
this.monthly.set(phase, list)
return () => {
const li = list.indexOf(fn)
if (li >= 0) list.splice(li, 1)
}
}
onYearStart(fn: SystemHook): void {
onYearStart(fn: SystemHook): () => void {
this.yearly.push(fn)
return () => {
const i = this.yearly.indexOf(fn)
if (i >= 0) this.yearly.splice(i, 1)
}
}
fireYearStart(w: World): void {
@@ -56,7 +64,7 @@ export class GameClock {
const report: PhaseStat[] = []
for (const phase of PHASE_ORDER) {
const t0 = performance.now()
const fns = this.monthly.get(phase) ?? []
const fns = [...(this.monthly.get(phase) ?? [])]
for (const fn of fns) fn(w)
const ms = performance.now() - t0
report.push({ phase, ms, count: fns.length })
@@ -69,4 +77,8 @@ export class GameClock {
for (const list of this.monthly.values()) n += list.length
return n + this.yearly.length
}
snapshot(): Array<{ phase: PhaseId; count: number }> {
return PHASE_ORDER.map((phase) => ({ phase, count: (this.monthly.get(phase) ?? []).length }))
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
export const SURNAME_POOL = [
'林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴',
'柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛',
'韩', '白', '纪', '容', '卫', '柳', '燕', '温', '孟', '阮',
'洛', '池', '顾', '岑', '傅', '虞', '尹', '霍', '曲', '齐'
'韩', '白', '纪', '容', '卫', '燕', '温', '孟', '阮',
'', '岑', '傅', '虞', '尹', '霍', '曲', '齐'
]
export const MALE_GIVEN = [
+55
View File
@@ -0,0 +1,55 @@
import { GameClock, SystemHook } from './clock'
import { DataPack } from '../data/registry'
import { EventDef } from '../data/events'
import type { World } from '../engine/world'
export type PluginKind = 'system' | 'data' | 'events' | 'content'
export interface PluginHookGuard {
onLoad?(): void
onDisable?(): void
onEnable?(): void
onUninstall?(): void
}
export interface CotycPlugin {
id: string
name: string
version: string
author?: string
description: string
kind: PluginKind
dependencies?: string[]
conflicts?: string[]
install(ctx: PluginContext): void
uninstall?(ctx: PluginContext): void
hooks?: PluginHookGuard
protected?: boolean
}
export interface PluginContext {
world: World
clock: GameClock
register: (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void
onYearStart: (fn: SystemHook) => () => void
addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void
removeCapability: (id: string) => void
enableCapability: (id: string, enabled: boolean) => void
overridePack: (partial: Partial<DataPack>) => void
resetPack: () => void
addEventPool: (id: string, events: EventDef[]) => void
removeEventPool: (id: string) => void
}
export interface PluginStatus {
id: string
name: string
version: string
kind: PluginKind
installed: boolean
enabled: boolean
protected: boolean
dependencies?: string[]
}
export type PluginChange = { id: string; action: 'install' | 'remove' | 'enable' | 'disable' }
+2 -12
View File
@@ -1,5 +1,4 @@
import { Element } from '../types/domain'
import { Character } from '../types/domain'
import { Element, type Character } from '../types/domain'
import { techniqueById } from './techniques'
export type AspirationEffectType = 'cult' | 'battle' | 'market' | 'field' | 'offspring' | 'rep'
@@ -58,7 +57,7 @@ export const ASPIRATIONS: Record<string, AspirationDef> = {
yun: {
id: 'yun',
name: '云游',
desc: '天地为庐:修炼 -3%但多见奇景(际遇更频)。',
desc: '天地为庐:修炼 -3%所见所闻略广。',
icon: '云',
effect: { type: 'cult', value: -0.03 }
}
@@ -71,15 +70,6 @@ export function aspirationById(id: string | undefined): AspirationDef | null {
return ASPIRATIONS[id] ?? null
}
export function countAspiration(type: AspirationEffectType, members: Character[]): number {
let n = 0
for (const c of members) {
const def = aspirationById(c.aspiration)
if (def?.effect.type === type) n++
}
return n
}
export function fitBonusOf(c: Character): { cult: boolean; battle: boolean } {
const tech = techniqueById(c.techniqueId)
if (!tech) return { cult: false, battle: false }
+1 -1
View File
@@ -15,7 +15,7 @@ export interface NpcFamilyDef {
export const NPCS: NpcFamilyDef[] = [
{
id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修氏,剑意凛冽,最为孤傲。',
id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修氏,剑意凛冽,最为孤傲。',
leaderRealm: 'foundation', initialPower: 240, powerGrowth: [4, 10],
sells: ['weapon-qi', 'weapon-ling'], buys: ['lingcao', 'lingkuang']
},
+5 -1
View File
@@ -116,4 +116,8 @@ export interface TechniqueDef {
desc: string
}
export const TECHNIQUE_GRADE_NAMES = ['黄阶', '玄阶', '地阶', '天阶', '仙阶']
export const TECHNIQUE_GRADE_NAMES = ['凡阶', '黄阶', '玄阶', '地阶', '天阶', '仙阶']
export function techniqueGradeName(grade: number): string {
return TECHNIQUE_GRADE_NAMES[grade] ?? ''
}
+9 -4
View File
@@ -66,7 +66,7 @@ export const ACT_CATALOG: Record<ActName, { desc: string; fn: (w: World, p: ActP
'market.buy': { desc: '购货', fn: (w, p) => (p.item ? buyItem(w, p.item, p.count ?? 1) : false) },
'market.sell': { desc: '售货', fn: (w, p) => (p.item ? sellItem(w, p.item, p.count ?? 1) : false) },
'market.tech': { desc: '购法帖', fn: (w, p) => (p.tech ? buyTechnique(w, p.tech, p.stones ?? 0) : false) },
'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : w.craftPill('qiyuan')) },
'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : Boolean(p.item === 'qiyuan') && w.craftPill('qiyuan')) },
'diplomacy.gift': { desc: '赠礼', fn: (w, p) => (p.npcId ? giftNpc(w, p.npcId, p.stones ?? 0) : false) },
'diplomacy.peace': { desc: '议和', fn: (w, p) => (p.npcId ? makePeace(w, p.npcId) : false) },
'diplomacy.taunt': { desc: '寻衅', fn: (w, p) => (p.npcId ? w.tauntNpc(p.npcId) : false) },
@@ -108,6 +108,8 @@ export class GameFacade {
return { list: w.systemList() }
case 'finance':
return { stones: w.state.family.stones, accum: w.state.finance.accum, yearStats: w.state.yearStats }
case 'plugins':
return { list: w.pluginList() }
default:
return {}
}
@@ -121,7 +123,8 @@ export class GameFacade {
onPendingEvent: (id: string) => on({ type: 'pending', id }),
onGameOver: (reason: string) => on({ type: 'gameover', reason }),
onYearPaper: (report: YearlyReport) => on({ type: 'paper', report }),
onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled })
onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled }),
onPluginChange: (id: string, action: string) => on({ type: 'plugin', id, action })
}
this.world.out.push(bus)
return () => {
@@ -130,12 +133,13 @@ export class GameFacade {
}
}
about(): { title: string; version: string; modules: number; systems: number; packFingerprint: string } {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
version: '0.1.7',
version: '0.1.8',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
packFingerprint: PACK.fingerprint()
}
}
@@ -149,6 +153,7 @@ export type FacadeEvent =
| { type: 'gameover'; reason: string }
| { type: 'paper'; report: YearlyReport }
| { type: 'sysChanged'; id: string; enabled: boolean }
| { type: 'plugin'; id: string; action: string }
export { marketPrice, computeLegacy }
export type { SaveMeta, LegacyArch }
+44 -17
View File
@@ -1,4 +1,6 @@
import { GameClock } from '../core/clock'
import { PluginContext } from '../core/plugin'
import type { World } from './world'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick } from './systems/cultivation'
@@ -6,7 +8,6 @@ import { missionTick } from './systems/missions'
import { eventRoll } from './systems/events'
import { diplomacyTick } from './systems/diplomacy'
import { yearStartMarriage } from './systems/marriage'
import type { World } from './world'
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
function ifAlive(fn: (w: World) => void): (w: World) => void {
@@ -22,33 +23,59 @@ function viaCap(capId: string, fn: (w: World) => void): (w: World) => void {
}
}
export function emptyClock(): GameClock {
return new GameClock()
}
/**
* 装备时钟:年度钩子与月度 phase 的注册顺序即执行顺序(确定性)。
* 内建系统注册(core-systems 插件入口)。
* 注册顺序 = 执行顺序(确定性红线,经由金钟罩校验)。
* 时间线:婚配养育 → 声望岁贡 → 岁末族簿 —— 再逐月:生产→寿元→修炼→任务→事件→外交→收尾
*/
export function buildClock(): GameClock {
const clock = new GameClock()
export function installCoreSystems(ctx: PluginContext): Array<() => void> {
const unsubs: Array<() => void> = []
const clock = ctx.clock
clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w)))
unsubs.push(clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w))))
unsubs.push(
clock.onYearStart(
viaCap('marriage', (w) => {
w.state.family.reputation += w.postBonus('familyRep')
const zhenCount = w
.aliveMembers()
.filter((c) => c.aspiration === 'zhen').length
const zhenCount = w.aliveMembers().filter((c) => c.aspiration === 'zhen').length
w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
})
)
clock.onYearStart(viaCap('annals', (w) => w.publishYearReport()))
)
unsubs.push(clock.onYearStart(viaCap('annals', (w) => w.publishYearReport())))
clock.register('production', viaCap('production', (w: World) => productionTick(w)))
clock.register('aging', viaCap('aging', (w: World) => deathTick(w)))
clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w)))
clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w))))
clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w))))
clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w))))
clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w))))
clock.register('epilogue', (w: World) => w.epilogueTick())
unsubs.push(clock.register('production', viaCap('production', (w: World) => productionTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => deathTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w))))
unsubs.push(clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w)))))
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('epilogue', (w: World) => w.epilogueTick()))
return unsubs
}
/** 兼容旧接口:完整安装一套核心系统(测试/工具用) */
export function buildClock(): GameClock {
const clock = emptyClock()
const ctx = {
world: undefined as never,
clock,
register: (phase: Parameters<GameClock['register']>[0], fn: (w: World) => void) => clock.register(phase, fn),
onYearStart: (fn: (w: World) => void) => clock.onYearStart(fn),
addCapability: () => undefined,
removeCapability: () => undefined,
enableCapability: () => undefined,
overridePack: () => undefined,
resetPack: () => undefined,
addEventPool: () => undefined,
removeEventPool: () => undefined
}
installCoreSystems(ctx as never)
return clock
}
+13 -5
View File
@@ -1,16 +1,23 @@
import type { World } from './world'
import { pack } from '../data/registry'
import { traitBonuses } from './pcgen'
export function marketPrice(w: World, itemId: string): number {
const base = pack().items[itemId]?.basePrice ?? 1
const item = pack().items[itemId]
if (!item) return 0
const base = item.basePrice
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
return Math.max(1, Math.round(base * mult * mood))
// 利己(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)))
}
export function buyItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
if (!pack().items[itemId]) return false
const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false
fam.stones -= total
@@ -20,6 +27,7 @@ export function buyItem(w: World, itemId: string, count: number): boolean {
export function sellItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
if (!pack().items[itemId]) return false
const have = fam.inventory[itemId] ?? 0
if (have < count) return false
fam.inventory[itemId] = have - count
@@ -37,12 +45,12 @@ export function buyTechnique(w: World, techId: string, price: number): boolean {
}
export function techniquePrice(techId: string): number {
const grade = TECH_GRADE_BASE[techId] ?? 200
return grade
return TECH_GRADE_BASE[techId] ?? 200
}
import { TECHNIQUES } from '../data/techniques'
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
TECHNIQUES.map((t) => [t.id, [120, 300, 700, 1600, 3600][t.grade] ?? 300])
TECHNIQUES.map((t) => [t.id, TECHNIQUE_GRADE_PRICE[t.grade] ?? 300])
)
+2 -3
View File
@@ -27,9 +27,8 @@ export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }):
}
grade = Math.max(0, Math.min(5, grade))
const primaryCandidate = parents && (parents.m || parents.f)
? [parents.m!.roots.primary, parents.f!.roots.primary]
: ELEMENT_LIST
const single = parents ? (parents.m ?? parents.f) : undefined
const primaryCandidate = single ? [single.roots.primary] : ELEMENT_LIST
const primary = rng.pick(primaryCandidate)
const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0
const rest = ELEMENT_LIST.filter((e) => e !== primary)
@@ -0,0 +1,64 @@
import { CotycPlugin } from '../core/plugin'
import { installCoreSystems } from './clocks'
import { EVENTS } from '../data/events'
import { DEFAULT_PACK } from '../data/registry'
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
export function makeCoreSystemsPlugin(): CotycPlugin {
let unsubs: Array<() => void> = []
return {
id: 'core-systems',
name: '镇族基石',
version: '0.1.8',
kind: 'system',
protected: true,
description: '十二能力卡与时轮钩子:生产/寿元/修炼/探秘/事件/外交/婚育/岁簿/大比/渡劫/寄读/时节。',
install(ctx) {
unsubs = installCoreSystems(ctx)
},
uninstall(ctx) {
void ctx
unsubs.forEach((u) => u())
unsubs = []
}
}
}
/** 内置插件二:经典数据包(默认平衡表) */
export function makeCoreDataPlugin(): CotycPlugin {
return {
id: 'core-data',
name: '经典数据包',
version: '0.1.8',
kind: 'data',
protected: true,
description: '平衡表默认包:境界/物品/功法/建筑/秘境/势力/性格/职事。',
install(ctx) {
ctx.resetPack()
},
uninstall() {
void DEFAULT_PACK
}
}
}
/** 内置插件三:内建事件池(日常/家国/际遇) */
export function makeCoreEventsPlugin(): CotycPlugin {
return {
id: 'core-events',
name: '内建事件池',
version: '0.1.8',
kind: 'events',
protected: true,
description: '命运事件主池:日常/重大/乾坤三层选支事件。',
install(ctx) {
ctx.addEventPool('core', EVENTS)
}
}
}
export const CORE_PLUGINS: CotycPlugin[] = [
makeCoreSystemsPlugin(),
makeCoreDataPlugin(),
makeCoreEventsPlugin()
]
+131
View File
@@ -0,0 +1,131 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../core/plugin'
import { SystemDef, SYSTEM_DEFS } from '../engine/capabilities'
import type { World } from '../engine/world'
import { SystemHook } from '../core/clock'
import { GameClock } from '../core/clock'
interface RuntimePlugin {
plugin: CotycPlugin
status: { installed: boolean; enabled: boolean }
unsubscribers: Array<() => void>
}
/**
* 插件管理器:统一安装/卸载/启停管线。
* 安装序即确定性序;依赖缺失或冲突 → 拒绝安装。
*/
export class PluginManager {
private runtime = new Map<string, RuntimePlugin>()
private order: string[] = []
private changes: PluginChange[] = []
constructor(private ctx: PluginContext) {}
private onChange(c: PluginChange): void {
this.changes.push(c)
}
listChanges(): PluginChange[] {
return this.changes.slice()
}
clearChanges(): void {
this.changes = []
}
install(plugin: CotycPlugin): { ok: boolean; reason?: string } {
if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` }
for (const dep of plugin.dependencies ?? []) {
if (!this.runtime.has(dep)) return { ok: false, reason: `缺少依赖:${dep}` }
}
for (const c of plugin.conflicts ?? []) {
if (this.runtime.has(c)) return { ok: false, reason: `${c} 冲突` }
}
for (const def of SYSTEM_DEFS) {
if (def.id === plugin.id) return { ok: false, reason: `id 冲突:${plugin.id}` }
}
const rt: RuntimePlugin = { plugin, status: { installed: true, enabled: true }, unsubscribers: [] }
this.runtime.set(plugin.id, rt)
this.order.push(plugin.id)
// 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘
const tracked = new Proxy(this.ctx, {
get(target, key) {
const prop = key as keyof PluginContext
if (prop === 'register' || prop === 'onYearStart') {
return (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => {
const unsub = (target[prop] as (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void)(phase, fn)
rt.unsubscribers.push(unsub)
return unsub
}
}
return (target as unknown as Record<string, unknown>)[key as string]
}
})
try {
plugin.install(tracked as PluginContext)
plugin.hooks?.onLoad?.()
} catch (e) {
rt.unsubscribers.forEach((u) => u())
this.runtime.delete(plugin.id)
const i = this.order.indexOf(plugin.id)
if (i >= 0) this.order.splice(i, 1)
return { ok: false, reason: `安装异常:${String(e)}` }
}
this.onChange({ id: plugin.id, action: 'install' })
return { ok: true }
}
remove(pluginId: string): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' }
rt.unsubscribers.forEach((u) => u())
rt.plugin.uninstall?.(this.ctx)
this.runtime.delete(pluginId)
const i = this.order.indexOf(pluginId)
if (i >= 0) this.order.splice(i, 1)
this.onChange({ id: pluginId, action: 'remove' })
return { ok: true }
}
setEnabled(pluginId: string, enabled: boolean): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (enabled && !rt.plugin.hooks?.onEnable) {
// 无 enable 钩子的插件视为直接切换 enabled 状态
}
rt.status.enabled = enabled
if (enabled) rt.plugin.hooks?.onEnable?.()
else rt.plugin.hooks?.onDisable?.()
this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' })
return { ok: true }
}
list(): PluginStatus[] {
return this.order
.filter((id) => this.runtime.has(id))
.map((id) => {
const rt = this.runtime.get(id)!
const p = rt.plugin
return {
id: p.id,
name: p.name,
version: p.version,
kind: p.kind,
installed: rt.status.installed,
enabled: rt.status.enabled,
protected: !!p.protected,
dependencies: p.dependencies
}
})
}
has(id: string): boolean {
return this.runtime.has(id)
}
get(id: string): CotycPlugin | null {
return this.runtime.get(id)?.plugin ?? null
}
}
@@ -27,7 +27,7 @@ export function monthlyRate(w: World, c: Character): number {
const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0
rate *= 1 + juling * 0.05
rate *= 1 + seasonMod(st.month, 'cult')
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05
if (c.traits.includes('fengxian')) rate *= 1.3
@@ -36,7 +36,7 @@ export function monthlyRate(w: World, c: Character): number {
if (fitBonusOf(c).cult) rate *= 1.06
if (c.state === 'meditation') {
rate *= 1.35
rate *= 1 + w.postBonus('meditation') + seasonMod(st.month, 'meditation')
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
const dongfu = buildings['dongfu'] ?? 0
rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') {
@@ -49,6 +49,7 @@ export function monthlyRate(w: World, c: Character): number {
if (c.health <= 40) rate *= 0.45
else if (c.health <= 70) rate *= 0.8
}
rate *= traitBonuses(c).exp
if (w.ageOf(c) < 8) rate *= 0.4
if (w.ageOf(c) > 55) rate *= 0.7
rate *= masteryRateOfMajor(c.realm.major)
@@ -89,7 +90,7 @@ export function cultivationTick(w: World): void {
if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
if (needsTribulation(c)) {
if (needsTribulation(c) && w.sysEnabled('tribulation')) {
// 大境界晋升改走渡劫事件(玩家三选);压制者待来年
if (!c.tribDelayYear || w.state.year >= c.tribDelayYear) {
c.tribDelayYear = undefined
+18 -8
View File
@@ -15,7 +15,13 @@ import { nextRealm, describeRealm } from '../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string, world?: World): EventDef | undefined {
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id, world)
const found = ALL_EVENTS.find((e) => e.id === id)
if (found) return found
if (world) {
const pooled = world.allEvents().find((e) => e.id === id)
if (pooled) return pooled
}
return dynamicEventFor(id, world)
}
export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
@@ -57,7 +63,7 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
name: '太虚大比',
category: 'major',
weight: 0,
text: `太虚仙盟年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
text: `太虚仙盟年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
options: [
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
{ label: '献礼买名', hint: '灵石-200,名次垫底,各族好感略升', eff: { res: { stones: -200 }, relation: { 'n-xuanying': 5, 'n-danxin': 5, 'n-sihai': 5, 'n-nulei': 5 } } },
@@ -243,7 +249,7 @@ export function eventRoll(w: World): void {
return
}
const firstSpirit = s.flags['firstSpirit'] as number | undefined
if (firstSpirit && s.year - firstSpirit >= 2 && s.year - firstSpirit <= 3 && !s.completedEvents.includes('ev-feisheng')) {
if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
fire(w, 'ev-feisheng')
return
}
@@ -256,22 +262,25 @@ export function eventRoll(w: World): void {
fire(w, `ev-tournament-${tourneyNext}`)
return
}
// 四邻回声(每两年左右一桩
// 四邻回声(偶数年一掷,不论成败封缄
if (s.year % 2 === 0) {
const key = `echoDone-${s.year}`
if (!s.family.flag[key] && w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
if (!s.family.flag[key]) {
s.family.flag[key] = true
if (w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
fire(w, `ev-echo-${npc.id}-${s.year}`)
return
}
}
}
const roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
if (!category) return
const candidates = ALL_EVENTS.filter(
const pool = w.allEvents()
const candidates = pool.filter(
(e) =>
e.category === category &&
!(e.once && s.completedEvents.includes(e.id)) &&
@@ -290,6 +299,7 @@ export function eventRoll(w: World): void {
}
export function fire(w: World, id: string): void {
if (w.state.pendingEvent) return
w.state.pendingEvent = id
w.pendingEvent(id)
}
@@ -394,7 +404,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)
runTournament(w, squad)
}
if (eff.apprentice?.build) {
buildApprentice(w)
+2 -2
View File
@@ -111,8 +111,8 @@ function resultText(m: MissionState): string {
export function canSendMission(w: World, def: MissionDef, members: string[]): boolean {
if (members.length < def.minMembers || members.length > def.maxMembers) return false
for (const id of members) {
const c = w.memberById(id)
if (!c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
const c = w.state.members[id]
if (!c || !c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
}
return w.state.missions.filter((m) => !m.done).length < 3
}
@@ -16,7 +16,7 @@ export function productionTick(w: World): void {
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
const springMod = seasonMod(w.state.month, 'field')
const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0
if (lingtian > 0) {
const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod))
inv.lingcao = (inv.lingcao ?? 0) + v
@@ -36,7 +36,7 @@ export function productionTick(w: World): void {
inv.lingkuang = (inv.lingkuang ?? 0) + v
parts.push(`灵矿+${v}灵矿`)
}
const autumnMod = seasonMod(w.state.month, 'market')
const autumnMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'market') : 0
if (fangshi > 0) {
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
fam.stones += v
+12 -2
View File
@@ -20,6 +20,10 @@ export function gateEnemy(idx: number, year: number): EnemyDef {
}
export function runTournament(w: World, squad?: string[]): void {
if (!w.sysEnabled('tournament')) {
w.log('info', '太虚大比:赛事未启。')
return
}
const s = w.state
const eligible = w
.aliveMembers()
@@ -83,12 +87,16 @@ export function apprenticeCandidates(w: World): Character[] {
}
export function buildApprentice(w: World): void {
if (!w.sysEnabled('apprentice')) {
w.log('bad', '宗门来使失望而归:族中暂拒通学。')
return
}
const cand = apprenticeCandidates(w)
const c = w.rng.pick(cand)
if (!c) {
if (cand.length === 0) {
w.log('bad', '宗门来使失望而归:族中无适龄儿郎。')
return
}
const c = w.rng.pick(cand)
const sect = w.rng.pick(SECTS)
const years = w.rng.int(2, 4)
c.state = 'apprentice'
@@ -125,6 +133,7 @@ export function finalizeApprentice(w: World, memberId: string, mode: 'return' |
}
export function checkApprenticeExpiry(w: World): void {
if (!w.sysEnabled('apprentice')) return
for (const c of Object.values(w.state.members)) {
if (!c.alive || !c.apprentice || c.apprentice.quiet) continue
if (w.state.year >= c.apprentice.untilYear) {
@@ -138,6 +147,7 @@ export function checkApprenticeExpiry(w: World): void {
}
export function recruitCheck(w: World): void {
if (!w.sysEnabled('apprentice')) return
const s = w.state
if (s.year % 4 !== 0) return
const key = `recruitDone-${s.year}`
@@ -21,7 +21,6 @@ export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard
if (mode === 'delay') {
c.tribDelayYear = w.state.year + 1
w.state.family.stones = w.state.family.stones
w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡。`)
return 'delayed'
}
+133 -17
View File
@@ -10,15 +10,19 @@ import {
} from '../types/domain'
import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
import { pack } from '../data/registry'
import { POSTS } from '../data/posts'
import { aspirationById as aspirationOf } from '../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
import { createWorldState, findInheritor } from './creation'
import { buildClock } from './clocks'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { CotycPlugin, PluginContext, PluginStatus } from '../core/plugin'
import { PluginManager } from './pluginManager'
import { EventDef } from '../data/events'
import { pack, DEFAULT_PACK, PACK } from '../data/registry'
import { CORE_PLUGINS } from './plugin-bootstrap'
import { GameClock } from '../core/clock'
import { SystemHook } from '../core/clock'
import { resolveBreakthrough } from './systems/cultivation'
import { applyEventChoice } from './systems/events'
import { combatPowerOf } from './systems/combat'
@@ -33,6 +37,7 @@ export interface WorldEventBus {
onGameOver(reason: string, year: number): void
onYearPaper?(entry: YearlyReport): void
onSystemChange?(id: string, enabled: boolean): void
onPluginChange?(id: string, action: string): void
}
export function normalizeGameState(state: GameState): GameState {
@@ -55,6 +60,18 @@ export function normalizeGameState(state: GameState): GameState {
if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = []
if (!state.missions) state.missions = []
if (!state.flags) state.flags = {}
if (!state.npcFamilies) state.npcFamilies = {}
if (!state.family.flag) state.family.flag = {}
if (!state.family.inventory) state.family.inventory = {}
if (!state.family.buildings) state.family.buildings = {}
if (!state.family.techniques) state.family.techniques = []
if (!state.family.missionIds) state.family.missionIds = []
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
}
for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
@@ -69,14 +86,118 @@ export class World {
out: WorldEventBus[]
clock: GameClock
systems: Record<string, { enabled: boolean }>
plugins: PluginManager
private eventPools = new Map<string, EventDef[]>()
constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state)
this.state = state
this.rng = new Rng(state.rng)
this.out = out
this.clock = buildClock()
this.clock = emptyClock()
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins()
}
/** 事件池聚合(含动态事件回看) */
buildPluginContext(): PluginContext {
const self = this
return {
world: self,
clock: self.clock,
register: (phase, fn: SystemHook) => self.clock.register(phase, fn),
onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn),
addCapability: (cap) => {
if (!SYSTEM_DEFS.find((d) => d.id === cap.id)) {
SYSTEM_DEFS.push({ id: cap.id, name: cap.name, version: cap.version, desc: cap.desc })
}
self.systems[cap.id] = { enabled: true }
},
removeCapability: (id) => {
delete self.systems[id]
},
enableCapability: (id, enabled) => {
if (self.systems[id]) self.systems[id].enabled = enabled
},
overridePack: (partial) => {
void pack
self.packOverride(partial)
},
resetPack: () => {
self.packReset()
},
addEventPool: (id, events) => {
self.eventPools.set(id, events)
},
removeEventPool: (id) => {
self.eventPools.delete(id)
}
}
}
private packOverride(partial: Partial<import('../data/registry').DataPack>): void {
PACK.override(partial)
}
private packReset(): void {
PACK.reset()
}
installCorePlugins(): void {
// 内置三插件:系统/数据/事件(受保护常驻,统一走管线)
for (const p of CORE_PLUGINS) {
this.plugins.install(p)
}
}
pluginList(): PluginStatus[] {
return this.plugins.list()
}
pluginChanges(): { id: string; action: string }[] {
return this.plugins.listChanges()
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(p.id, 'install'))
return r
}
removePlugin(id: string): { ok: boolean; reason?: string } {
const r = this.plugins.remove(id)
if (r.ok) {
this.rebuildEventPoolsAfterRemoval(id)
this.out.forEach((o) => o.onPluginChange?.(id, 'remove'))
}
return r
}
setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } {
const r = this.plugins.setEnabled(id, enabled)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(id, enabled ? 'enable' : 'disable'))
return r
}
allEvents(): EventDef[] {
const list: EventDef[] = []
for (const pool of this.eventPools.values()) {
for (const e of pool) list.push(e)
}
return list
}
eventPoolIds(): string[] {
return [...this.eventPools.keys()]
}
private rebuildEventPoolsAfterRemoval(id: string): void {
void id
// 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用
if (!this.eventPools.has('core')) this.eventPools.set('core', [])
}
sysEnabled(id: string): boolean {
@@ -525,7 +646,7 @@ export class World {
}
marryTo(aId: Id, bId: Id): boolean {
return arrangeWeddingPublic(this, aId, bId)
return arrangeWeddingBridge(this, aId, bId)
}
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
@@ -542,18 +663,13 @@ function w2age(w: World, c: Character): number {
return w.ageOf(c)
}
function widowedOf(w: World, m: Character): boolean {
if (!m.spouseId) return false
const sp = w.state.members[m.spouseId]
return !!sp && !sp.alive
}
function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean {
const a = w.memberById(aId)
const b = w.memberById(bId)
if (!a.alive || !b.alive) return false
if (a.spouseId && !widowedOf(w, a)) return false
if (b.spouseId && !widowedOf(w, b)) return false
function arrangeWeddingBridge(w: World, aId: Id, bId: Id): boolean {
const a = w.state.members[aId]
const b = w.state.members[bId]
if (!a || !b || !a.alive || !b.alive) return false
if (w.ageOf(a) < 16 || w.ageOf(b) < 16) return false
if (a.spouseId && !w.isWidowed(a)) return false
if (b.spouseId && !w.isWidowed(b)) return false
if (a.gender === b.gender) return false
if (a.fatherId && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false
+7 -3
View File
@@ -1,6 +1,7 @@
import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain'
const DB_PREFIX = 'cotyc-save-'
let seqCounter = 0
export interface SaveDbDriver {
open(name: string): Promise<void>
@@ -38,7 +39,7 @@ export class SaveSlot {
}
async saveState(state: GameState, label: string): Promise<string> {
const id = `${state.year}.${state.month}.${Date.now().toString(36)}`
const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}`
const json = JSON.stringify(state)
await this.driver.run(
`INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`,
@@ -80,8 +81,11 @@ export class SaveSlot {
async loadState(id?: string): Promise<GameState | null> {
const rows = await this.driver.all<{ data: string }>(
id ? `SELECT data FROM snapshot WHERE id = ?` : `SELECT data FROM snapshot ORDER BY rowid DESC LIMIT 1`
, id ? [id] : [])
id
? `SELECT data FROM snapshot WHERE id = ?`
: `SELECT data FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC LIMIT 1`,
id ? [id] : []
)
if (!rows || rows.length === 0) return null
return JSON.parse(rows[0]!.data) as GameState
}
+3 -1
View File
@@ -7,7 +7,9 @@ const STATE_LABEL: Record<string, { label: string; cls: string }> = {
idle: { label: '无事', cls: '' },
meditation: { label: '闭关', cls: 'good' },
expedition: { label: '出探', cls: 'warn' },
wounded: { label: '养伤', cls: 'bad' }
wounded: { label: '养伤', cls: 'bad' },
apprentice: { label: '寄读', cls: 'gold' },
closed: { label: '养息', cls: '' }
}
export function MemberCard({ c }: { c: Character }) {
+17 -2
View File
@@ -27,6 +27,11 @@ function deadish(c: Character): boolean {
return !c.alive
}
function gradeShort(g: number): string {
const map: Record<number, string> = { 0: '凡', 1: '黄', 2: '玄', 3: '地', 4: '天', 5: '仙' }
return map[g] ?? '?'
}
function pillCount(s: GameState, id: string): number {
return s.family.inventory[id] ?? 0
}
@@ -65,9 +70,19 @@ export function MemberModal({ member }: { member: Character }) {
<div className="mm-line"><span></span><b>{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%</b></div>
<div className="mm-line"><span></span><b className={member.realmProgress >= 100 ? 'gold' : ''}>{nextName}</b></div>
<div className="mm-line"><span></span><b>{Math.round(power)}</b></div>
<div className="mm-line"><span></span><b>{member.state === 'meditation' ? '闭关' : member.state === 'expedition' ? '外出' : member.state === 'wounded' ? '养伤' : '无事'}</b></div>
<div className="mm-line"><span></span><b>
{member.state === 'meditation'
? '闭关'
: member.state === 'expedition'
? '外出'
: member.state === 'wounded'
? '养伤'
: member.state === 'apprentice'
? '寄读'
: '无事'}
</b></div>
<div className="mm-line"><span></span><b>{Math.round(member.health)}</b></div>
<div className="mm-line"><span></span><b>{tech ? `${tech.name}(${['黄', '玄', '地', '天', '仙'][tech.grade]}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
<div className="mm-line"><span></span><b>{tech ? `${tech.name}(${gradeShort(tech.grade)}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}</b></div>
<div className="mm-line"><span></span><b>{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}</b></div>
<div className="mm-line"><span></span><b>{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}</b></div>
</div>
+1 -1
View File
@@ -27,7 +27,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])
}, [world, filter, revision])
if (!world) return null
const battles = world.state.battles.slice().reverse()
const battle = battles.find((b) => b.id === battleId)
+5 -5
View File
@@ -1,7 +1,7 @@
import { useGameStore } from '../store'
import { ITEMS } from '../../game/data/items'
import { TECHNIQUES } from '../../game/data/techniques'
import { TECHNIQUE_GRADE_NAMES } from '../../game/data/realms'
import { techniqueGradeName } from '../../game/data/realms'
import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market'
import { useMemo, useState } from 'react'
@@ -10,11 +10,11 @@ export default function MarketPanel() {
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
if (!world) return null
const w = world
const fam = w.state.family
const inv = fam.inventory
const [tab, setTab] = useState<'goods' | 'tech'>('goods')
const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), [])
const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), [])
@@ -110,7 +110,7 @@ export default function MarketPanel() {
{tab === 'tech' && (
<>
<div className="help-text">
{cangshuLv} {TECHNIQUE_GRADE_NAMES[Math.min(3, Math.max(0, cangshuLv))]}
{cangshuLv} {techniqueGradeName(Math.min(4, Math.max(0, cangshuLv + 1)))}
</div>
<table className="market-table">
@@ -131,11 +131,11 @@ export default function MarketPanel() {
.map((t) => {
const owned = fam.techniques.includes(t.id)
const p = t.grade <= 1 + cangshuLv
const priceT = [120, 300, 700, 1600, 3600][t.grade] ?? 300
const priceT = techniqueGradeName(t.grade) === '' ? 300 : [120, 300, 700, 1600][t.grade] ?? 300
return (
<tr key={t.id}>
<td><b>{t.name}</b></td>
<td className="dim">{TECHNIQUE_GRADE_NAMES[t.grade]} · {t.path}</td>
<td className="dim">{techniqueGradeName(t.grade)} · {t.path}</td>
<td className="dim">{t.desc}</td>
<td>+{Math.round((t.expBonus) * 100)}%</td>
<td>+{Math.round((t.powerBonus) * 100)}%</td>
+1 -1
View File
@@ -97,7 +97,7 @@ export default function SettingsPanel() {
· <br />
·
</div>
<div className="dim2"> 0.1.0 · Chronicle of the Immortal Clan</div>
<div className="dim2"> 0.1.8 · Chronicle of the Immortal Clan</div>
</div>
)
}
+1 -1
View File
@@ -7,9 +7,9 @@ export default function TerritoryPanel() {
const bump = useGameStore((s) => s.bump)
const revision = useGameStore((s) => s.revision)
void revision
const ids = useMemo(() => Object.keys(BUILDINGS), [])
if (!world) return null
const fam = world.state.family
const ids = useMemo(() => Object.keys(BUILDINGS), [])
const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l)
+2 -1
View File
@@ -17,7 +17,8 @@ export default function NewGame() {
const rollNames = () => {
setRandoming((r) => !r)
const s = SURNAME_POOL[Math.floor(Math.random() * SURNAME_POOL.length)]
const idx = Math.floor(RngHub.audioNoise01() * SURNAME_POOL.length)
const s = SURNAME_POOL[Math.min(SURNAME_POOL.length - 1, idx)]
setSurname(s)
setFamilyName(`${s}`)
}
+5 -1
View File
@@ -110,7 +110,11 @@ export function sGong(): void {
tone(660, 0.7, 0.03, 'sine', 0.06)
}
/** 按钮:纸面轻叩 */
let lastClick = 0
/** 按钮:纸面轻叩(80ms 节流防连点爆 buffer */
export function sClick(): void {
const now = performance.now()
if (now - lastClick < 80) return
lastClick = now
noise(0.045, 0.03, 0, 4200)
}
+73 -24
View File
@@ -60,6 +60,7 @@ export interface GameStore {
closePaper: () => void
toggleSound: () => void
facade?: GameFacade
advancing: boolean
act: (name: ActName, payload: import('../game/engine/api').ActPayload) => boolean
}
@@ -67,8 +68,21 @@ const LOG_CAP = 260
const lastPhaseStats = new Map<World, PhaseStat[]>()
let logSeq = 1
let toastTimer: ReturnType<typeof setTimeout> | null = null
function setNextToast(msg: string): void {
if (toastTimer) clearTimeout(toastTimer)
useGameStore.setState({ toast: msg })
toastTimer = setTimeout(() => useGameStore.setState({ toast: undefined }), 4200)
}
function stopTimer(): void {
const st = useGameStore.getState()
if (st.timer) clearInterval(st.timer)
useGameStore.setState({ speed: 0, timer: null })
}
export const useGameStore = create<GameStore>((set, get) => ({
facade: undefined as GameFacade | undefined,
advancing: false,
screen: 'boot',
world: null,
slot: 1,
@@ -124,13 +138,16 @@ export const useGameStore = create<GameStore>((set, get) => ({
startNewGame: async (opts, slot) => {
const world = World.create(opts)
set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0 })
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 st = get()
st.world?.out.push(makeBus(st))
const facade = new GameFacade(world, slot)
set({ facade })
await st.saveNow('开局')
},
continueGame: async (slot) => {
try {
const manager = getSlotManager()
const file = await getSaveSlot(slot)
await file.open()
@@ -138,20 +155,29 @@ export const useGameStore = create<GameStore>((set, get) => ({
if (!state) throw new Error('找不到存档数据')
openState(state, slot)
await manager.updateSlotMeta(slot, metaFromState(state, slot))
} catch (e) {
setNextToast(`读档失败:${String(e)}`)
useGameStore.setState({ screen: 'boot' })
}
},
loadSnapshot: async (slot, snapshotId) => {
try {
const file = await getSaveSlot(slot)
await file.open()
const state = await file.loadState(snapshotId)
if (!state) throw new Error('找不到快照')
openState(state, slot)
} catch (e) {
setNextToast(`读取快照失败:${String(e)}`)
}
},
advance: async () => {
const st = get()
const w = st.world
if (!w || st.pendingEventId || w.state.gameOver) return
if (!w || st.pendingEventId || w.state.gameOver || st.advancing) return
set({ advancing: true })
try {
const t0 = performance.now()
w.advanceMonth()
@@ -175,7 +201,10 @@ export const useGameStore = create<GameStore>((set, get) => ({
} catch {
// 保存失败时不再叠加错误
}
set({ toast: '时日流转出现异常,已保存出险现场,可回档重来。', speed: 0 })
setNextToast('时日流转出现异常,已保存出险现场,可回档重来。')
stopTimer()
} finally {
set({ advancing: false })
}
},
@@ -192,10 +221,21 @@ export const useGameStore = create<GameStore>((set, get) => ({
}
},
onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }),
onBattle: (log) => {
stopTimer()
set({ battleView: log, revision: get().revision + 1 })
},
onPendingEvent: (id) => {
const def = findEvent(id, get().world ?? undefined)
if (!def) {
// 未知事件(插件卸载/旧档漂移):跳过并解除软锁
const w = get().world
if (w?.state.pendingEvent === id) w.state.pendingEvent = undefined
get().addLog({ id: logSeq++, kind: 'bad', text: `事件「${id}」未知,已跳过。`, year: get().world?.state.year ?? 0, month: get().world?.state.month ?? 0 })
set({ pendingEventId: undefined, pendingEventDef: undefined })
return
}
set({ pendingEventId: id, pendingEventDef: def })
},
@@ -253,6 +293,9 @@ export const useGameStore = create<GameStore>((set, get) => ({
setSpeed: (v) => {
const st = get()
if (st.timer) clearInterval(st.timer)
if (st.speed === v && v === 0 && false) {
// noop
}
if (v <= 0) {
set({ speed: v, timer: null })
return
@@ -261,9 +304,12 @@ export const useGameStore = create<GameStore>((set, get) => ({
if (v >= 3) v = 3
const timer = setInterval(() => {
const cur = get()
if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) {
void cur.advance()
const w = cur.world
if (cur.speed <= 0 || !w || cur.pendingEventId || cur.battleView || cur.advancing || w.state.gameOver) {
// 速度被暂停/事件待决/战报展开/在途推进时静默轮空
return
}
void cur.advance()
}, ms)
set({ speed: v, timer })
},
@@ -282,25 +328,27 @@ export const useGameStore = create<GameStore>((set, get) => ({
const json = await file.exportAll()
const meta = metaFromState(st.world.state, st.slot)
const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}`)
if (r.ok) set({ toast: '存档已导出。' })
else set({ toast: `导出失败:${r.error ?? ''}` })
if (r.ok) setNextToast('存档已导出。')
else setNextToast(`导出失败:${r.error ?? ''}`)
},
importSave: async (slot) => {
if (!window.api) return
const r = await window.api.importSave()
if (!r.ok || !r.text) {
set({ toast: `导入失败:${r.error ?? ''}` })
setNextToast(`导入失败:${r.error ?? ''}`)
return
}
const file = await getSaveSlot(slot)
await file.open()
const ok = await file.importAll(r.text)
if (ok) {
set({ toast: '导入成功,重新读取存档槽。' })
setNextToast('导入成功,重新读取存档槽。')
await get().refreshSlots()
const state = await file.loadState()
if (state) openState(state, slot)
} else {
set({ toast: '导入失败:文件格式不正确。' })
setNextToast('导入失败:文件格式不正确。')
}
},
@@ -326,8 +374,8 @@ function openState(state: GameState, slot: number): void {
const st = useGameStore.getState()
world.out.push(makeBus(st))
const facade = new GameFacade(world, slot)
world.out.push(facadebus(facade))
useGameStore.setState({
facade,
world,
slot,
screen: 'game',
@@ -338,8 +386,20 @@ function openState(state: GameState, slot: number): void {
pendingEventDef: undefined,
revision: 1,
speed: 0,
gameOverReason: state.gameOver?.reason
gameOverReason: state.gameOver?.reason || undefined,
paperReport: undefined,
toast: undefined,
selectedMemberId: undefined,
selectedMissionDef: undefined
})
if (state.pendingEvent && state.members) {
const def = findEvent(state.pendingEvent, world)
if (def) {
useGameStore.setState({ pendingEventId: state.pendingEvent, pendingEventDef: def })
} else {
world.state.pendingEvent = undefined
}
}
}
function makeBus(st: GameStore): WorldEventBus {
@@ -363,17 +423,6 @@ function makeBus(st: GameStore): WorldEventBus {
}
}
function facadebus(facade: GameFacade): WorldEventBus {
return {
onLog: () => undefined,
onChronicle: () => undefined,
onBattle: () => undefined,
onPendingEvent: () => undefined,
onGameOver: () => undefined,
onSystemChange: () => undefined
}
}
function actDirect(w: World, name: ActName, payload: import('../game/engine/api').ActPayload): boolean {
if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true)
if (name === 'estate.rite') return w.ancestralRite()
+88
View File
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { newCharacter } from '../src/renderer/game/engine/pcgen'
import { Rng, seedToRng } from '../src/renderer/game/core/rng'
import { PACK } from '../src/renderer/game/data/registry'
import { buildApprentice } from '../src/renderer/game/engine/systems/tournament'
import { sendMission } from '../src/renderer/game/engine/systems/missions'
import { buyItem, marketPrice } from '../src/renderer/game/engine/market'
import { findEvent } from '../src/renderer/game/engine/systems/events'
import { runTournament } from '../src/renderer/game/engine/systems/tournament'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '佘', familyName: '佘家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('审计回归:P0 修复固化', () => {
it('单亲 rollRoots 不再崩溃(pcgen TypeError 修复)', () => {
const r = new Rng(seedToRng('single'))
const dad = newCharacter(r, { name: '父', gender: 'male', generation: 1, bornYear: -30, age: 30, realm: { major: 'qi', minor: 1 } })
dad.roots = { grade: 3, primary: '火', secondary: [] }
const child = newCharacter(r, { name: '子', gender: 'male', generation: 2, bornYear: 0, age: 0, father: dad })
expect(child.roots.grade).toBeGreaterThanOrEqual(0)
})
it('插件事件池事件可被 findEvent 解析(软锁修复)', () => {
const w = baseWorld('aud-find')
w.installPlugin({
id: 'aud-event-plugin',
name: '审计事件',
version: '1',
kind: 'events',
install(ctx) {
ctx.addEventPool('aud', [
{ id: 'id-evt-aud', name: '试炼', category: 'daily', weight: 1, text: '审计副本。', options: [{ label: '收下', eff: { rep: 1 } }] }
])
}
})
// 直接模拟抽到该事件后 UI 路径解析
const def = findEvent('id-evt-aud', w)
expect(def).toBeTruthy()
})
it('packOverride 经插件上下文真实生效(死实现修复)', () => {
const w = baseWorld('aud-pack')
w.installPlugin({
id: 'aud-data-plugin',
name: '货币更替',
version: '1',
kind: 'data',
install(ctx) {
ctx.overridePack({ items: { ...PACK.current().items, lingcao: { ...PACK.current().items['lingcao'], basePrice: 500 } } })
}
})
expect(marketPrice(w, 'lingcao')).toBeGreaterThan(100)
expect(buyItem(w, 'lingcao', 2)).toBe(false) // 1000 > 800,双份买不起
})
it('buildApprentice 空候选不再抛错(守卫修复)', () => {
const w = baseWorld('aud-appr')
for (const c of Object.values(w.state.members)) c.state = 'apprentice'
buildApprentice(w) // 此前 Rng.pick([]) 抛错
expect(w.state.family.stones).toBe(800)
})
it('sendMission 无效 id(回档残留)不再抛错', () => {
const w = baseWorld('aud-squad')
const ok = sendMission(w, 'm-anmoku', ['x-not-exist'])
expect(ok).toBe(false)
})
it('tournament 越过能力开关后休赛(未启时无副作用)', () => {
const w = baseWorld('aud-tourney')
w.toggleSystem('tournament')
const before = w.state.battles.length
runTournament(w, ['x3'])
expect(w.state.battles.length).toBe(before)
})
it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ebfec4a4')
})
})
+4 -3
View File
@@ -6,11 +6,12 @@ import { stateFingerprint, longRun } from './fingerprint.helper'
* //
* **** seed
*/
// 0.1.6 基线:GameClock 重构(行为等价)+ 时节系统(有意变更乘子)后重新固化
// 0.1.8 基线:特征加成(自律/精研/急躁/云游生效)+ 飞升窗口永续 + market 利己折扣后重新固化
// 0.1.6 时 GreenClock 重构行为等价;0.1.7 插件门面化零漂移)
const GOLDEN: Record<string, string> = {
'bell-seed-1': '9af71ecb',
'bell-seed-2': '34f102fd',
'bell-seed-3': '753aca71'
'bell-seed-2': '63cede89',
'bell-seed-3': 'ebfec4a4'
}
describe('金钟罩 · 长跑确定性指纹', () => {
+2 -2
View File
@@ -117,8 +117,8 @@ describe('realms 境界表', () => {
}
})
it('品阶名数量一致', () => {
expect(TECHNIQUE_GRADE_NAMES.length).toBe(5)
it('品阶名数量一致(含凡阶哨兵)', () => {
expect(TECHNIQUE_GRADE_NAMES.length).toBe(6)
})
})
+3 -2
View File
@@ -162,14 +162,15 @@ describe('GameFacade 门面', () => {
const f = new GameFacade(w, 1)
const info = f.about()
expect(info.title).toBe('仙途家族志')
expect(info.version).toContain('0.1.7')
expect(info.version).toContain('0.1.8')
expect(info.modules).toBeGreaterThanOrEqual(11)
expect(info.systems).toBeGreaterThan(0)
expect(info.plugins).toBeGreaterThanOrEqual(3)
})
it('默认配置金钟罩不受门面化影响', () => {
PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('753aca71')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ebfec4a4')
})
})
+45
View File
@@ -0,0 +1,45 @@
import { CotycPlugin } from '../src/renderer/game/core/plugin'
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
export const examplePlugin: CotycPlugin = {
id: 'demo-peaks',
name: '护山妖兽',
version: '0.1.0',
author: 'demo',
description: '示例插件:山鬼妖气事件与护山之力(+2% 战力)。',
kind: 'content',
install(ctx) {
ctx.addCapability({ id: 'demo-guardian', name: '护山之力', version: '0.1.0', desc: '示例:年首山灵庇佑,全族修为小幅精进。' })
ctx.onYearStart((w) => {
if (w.sysEnabled('demo-guardian')) {
for (const c of w.aliveMembers()) {
c.realmProgress = Math.min(100, c.realmProgress + 1.5)
}
}
})
ctx.addEventPool('demo-peaks', [
{
id: 'ev-demo-guardian',
name: '山鬼怒吼',
category: 'daily',
weight: 3,
text: '夜半山鸣,护山兽影现身墙外——莫非是山中精怪在拜望?',
options: [{ label: '蒸饼供奉', hint: '声望+2', eff: { rep: 2 } }]
}
])
},
uninstall(ctx) {
ctx.removeEventPool('demo-peaks')
ctx.removeCapability('demo-guardian')
}
}
/** 依赖缺失的坏插件:应被拒绝安装 */
export const brokenPlugin: CotycPlugin = {
id: 'demo-broken',
name: '依赖断链的插件',
version: '0.1.0',
kind: 'events',
dependencies: ['demo-not-exists'],
install() {}
}
+122
View File
@@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest'
import { World } from '../src/renderer/game/engine/world'
import { GameFacade } from '../src/renderer/game/engine/api'
import { installCoreSystems, buildClock } from '../src/renderer/game/engine/clocks'
import { emptyClock } from '../src/renderer/game/engine/clocks'
import { CORE_PLUGINS } from '../src/renderer/game/engine/plugin-bootstrap'
import { examplePlugin, brokenPlugin } from './helpers/examplePlugin'
import { stateFingerprint, longRun } from './fingerprint.helper'
import { resetSaveBus, attachLogSink } from './world.helpers'
function baseWorld(seed: string): World {
const w = World.create({ seed, surname: '夏', familyName: '夏家', motto: 'm', difficulty: 'normal' })
resetSaveBus()
attachLogSink(w)
return w
}
describe('PluginCore 插件协议', () => {
it('内置三插件常驻且受保护', () => {
const w = baseWorld('pl-a')
const list = w.pluginList()
expect(list.length).toBe(3)
for (const p of list) {
expect(p.installed).toBe(true)
expect(p.enabled).toBe(true)
expect(p.protected).toBe(true)
}
const ids = list.map((p) => p.id)
expect(ids).toContain('core-systems')
expect(ids).toContain('core-data')
expect(ids).toContain('core-events')
})
it('核心插件不可卸载(保护)', () => {
const w = baseWorld('pl-b')
const r = w.removePlugin('core-systems')
expect(r.ok).toBe(false)
})
it('依赖缺失拒绝安装;依赖满足则放行', () => {
const w = baseWorld('pl-c')
expect(w.installPlugin(brokenPlugin).ok).toBe(false)
expect(w.installPlugin(examplePlugin).ok).toBe(true)
expect(w.pluginList().length).toBe(4)
})
it('示例安装后:消息池注入生效 + 山神庇佑加值;卸载后两清', () => {
const w = baseWorld('pl-d')
w.installPlugin(examplePlugin)
expect(w.eventPoolIds()).toContain('demo-peaks')
const x5 = w.state.members['x5']
x5.realm = { major: 'qi', minor: 1 }
const p0 = x5.realmProgress
// 推进到跨年触发年首庇佑
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)
// 卸载
expect(w.removePlugin('demo-peaks').ok).toBe(true)
expect(w.eventPoolIds()).not.toContain('demo-peaks')
expect(w.pluginList().length).toBe(3)
})
it('remove 后时钟钩子计数恢复(真摘钩)', () => {
const w = baseWorld('pl-e')
const before = w.clock.subscriptionCount()
w.installPlugin(examplePlugin)
const mounted = w.clock.subscriptionCount()
expect(mounted).toBe(before + 1)
w.removePlugin('demo-peaks')
expect(w.clock.subscriptionCount()).toBe(before)
})
it('安装序确定性:同版本两次世界插件列表一致', () => {
const a = baseWorld('pl-f')
const b = baseWorld('pl-f')
expect(JSON.stringify(a.pluginList())).toBe(JSON.stringify(b.pluginList()))
})
it('默认管线金钟罩不受插件层影响', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb')
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('63cede89')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ebfec4a4')
})
it('facade 插件查询与 about.plugins', () => {
const w = baseWorld('pl-g')
const f = new GameFacade(w, 1)
const pl = f.query('plugins') as { list: { id: string }[] }
expect(pl.list.length).toBe(3)
expect(f.about().plugins).toBe(3)
})
it('订阅可捕获 plugin 生命周期事件', () => {
const w = baseWorld('pl-h')
const f = new GameFacade(w, 1)
const events: string[] = []
const unsub = f.subscribe((e) => {
if (e.type === 'plugin') events.push((e as { action: string }).action)
})
w.installPlugin(examplePlugin)
w.removePlugin('demo-peaks')
expect(events).toContain('install')
expect(events).toContain('remove')
unsub()
})
it('buildClock 兼容路径仍完整注册(测试/工具用)', () => {
const clock = buildClock()
expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10)
const empty = emptyClock()
expect(empty.subscriptionCount()).toBe(0)
void installCoreSystems
})
})