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:
@@ -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
@@ -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
@@ -117,8 +117,8 @@ describe('realms 境界表', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('品阶名数量一致', () => {
|
||||
expect(TECHNIQUE_GRADE_NAMES.length).toBe(5)
|
||||
it('品阶名数量一致(含凡阶哨兵)', () => {
|
||||
expect(TECHNIQUE_GRADE_NAMES.length).toBe(6)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user