diff --git a/AGENTS.md b/AGENTS.md index ce22e67..36e96d5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 -当前版本 **0.1.22**(《灯下春秋》:Modal 全走 body Portal(z 层越界修复)+ 年报不打断自动速度)。 +当前版本 **0.1.23**(《生灭千秋》:家族覆灭/新贵补位 + 插件持久化/双闸/公共导出面)。 ## 命令 diff --git a/package.json b/package.json index daf235f..f476b82 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.22", + "version": "0.1.23", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/renderer/game/data/npcs.ts b/src/renderer/game/data/npcs.ts index b300a07..3207672 100644 --- a/src/renderer/game/data/npcs.ts +++ b/src/renderer/game/data/npcs.ts @@ -36,8 +36,25 @@ export const NPCS: NpcFamilyDef[] = [ } ] -export function npcById(id: string): NpcFamilyDef { - const n = NPCS.find((x) => x.id === id) - if (!n) throw new Error(`npc not found: ${id}`) - return n +/** 运行时动态 NPC 注册表(0.1.23 新贵/覆灭;插件化入口) */ +const DYNAMIC_NPCS = new Map() + +export function registerNpcDef(def: NpcFamilyDef): void { + DYNAMIC_NPCS.set(def.id, def) +} + +export function unregisterNpcDef(id: string): void { + DYNAMIC_NPCS.delete(id) +} + +/** 双源查询:静态 NPCS → 运行时动态表(新贵);缺失返回 undefined(调用方防御)。 + * 注意:此函数拒绝 import registry(循环依赖),静态源直连 NPCS;数据包 npcs 覆写暂不走此路。 */ +export function npcById(id: string): NpcFamilyDef | undefined { + const stat = NPCS.find((x) => x.id === id) + if (stat) return stat + return DYNAMIC_NPCS.get(id) +} + +export function getNpcDefs(): NpcFamilyDef[] { + return [...NPCS, ...DYNAMIC_NPCS.values()] } diff --git a/src/renderer/game/engine/GameEngine.ts b/src/renderer/game/engine/GameEngine.ts index ea3d8e8..7f83850 100644 --- a/src/renderer/game/engine/GameEngine.ts +++ b/src/renderer/game/engine/GameEngine.ts @@ -7,6 +7,7 @@ import { RngHub } from './kernel/rng' import { CotycPlugin } from './kernel/plugin' import { DataPack, PACK } from '../data/registry' import { createWorldState } from './runtime/creation' +import './runtime/demo-plugins' // side-effect:注册示例插件工厂(持久化重装用) import { normalizeGameState } from './runtime/World' export interface GameEngineOptions { diff --git a/src/renderer/game/engine/plugin-public.ts b/src/renderer/game/engine/plugin-public.ts new file mode 100644 index 0000000..f0a3f6e --- /dev/null +++ b/src/renderer/game/engine/plugin-public.ts @@ -0,0 +1,17 @@ +/** + * 插件公共接口(第三方唯一入口面)。 + * 第三方插件:`import type { CotycPlugin, PluginContext } from '.../plugin-public'` + * + * 能力范围: + * - register(phase, fn):在时轮相位注册月度钩子(clocks 红线:phase 固定,勿自创) + * - onYearStart(fn):年首钩子 + * - addEventPool/removeEventPool:注入/移除事件池(池对 world.eventPools 全量聚合) + * - addCapability/removeCapability/enableCapability:能力卡(系统开关;受旺启停联动) + * - overridePack/resetPack:数据包覆写/回滚(pack() 单源) + * 纪律:插件内不得新增 w.rng.next() 消耗(会移动全局随机序列 → 金钟罩红); + * 必须消耗时必须声明并随 0.1.x 基线重算。 + */ +export type { CotycPlugin, PluginHookGuard, PluginContext, PluginKind, PluginStatus, PluginChange } from './kernel/plugin' +export type { SystemHook } from './kernel/clock' +export type { SystemDef } from './runtime/capabilities' +export { registerPluginFactory } from './runtime/World' diff --git a/src/renderer/game/engine/runtime/ApiFacade.ts b/src/renderer/game/engine/runtime/ApiFacade.ts index b20a8ff..b685977 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.22', + version: '0.1.23', 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/combat.ts b/src/renderer/game/engine/runtime/Systems/combat.ts index 31d2d23..6822f34 100644 --- a/src/renderer/game/engine/runtime/Systems/combat.ts +++ b/src/renderer/game/engine/runtime/Systems/combat.ts @@ -166,6 +166,7 @@ export function resolveRaid( ): EncounterResult { const npc = w.state.npcFamilies[npcId] const def = npcById(npcId) + if (!def) return { win: false, draw: true, lines: ['此族已散。'], loot: undefined, losses: [] } // 宗主实力分层:power 由年度成长+换代驱动,劫掠强度随之浮动(0.5↔1.8) const npcPower = npc.power ?? 60 const strength = Math.min(1.8, Math.max(0.5, 0.5 + (npcPower / 120) * 0.5)) @@ -193,7 +194,7 @@ export function resolveRaid( w.state.family.reputation += 6 // B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用) w.emitFx('blade', `raid:${npcId}`) - npc.power = Math.max(40, Math.round(npc.power * 0.82)) + npc.power = Math.max(52, Math.round(npc.power * 0.82)) npc.raidCount = (npc.raidCount ?? 0) + 1 npc.warCooldownYear = w.state.year // 1-3 蝴蝶效应:玩家重创名声——邻家对败者关系趋冷 diff --git a/src/renderer/game/engine/runtime/Systems/diplomacy.ts b/src/renderer/game/engine/runtime/Systems/diplomacy.ts index f0bb74b..5eb7849 100644 --- a/src/renderer/game/engine/runtime/Systems/diplomacy.ts +++ b/src/renderer/game/engine/runtime/Systems/diplomacy.ts @@ -36,6 +36,7 @@ export function yearGrowth(w: World): void { const s = w.state for (const npc of Object.values(s.npcFamilies)) { const def = npcById(npc.id) + if (!def) continue const [a, b] = def.powerGrowth npc.power += w.rng.int(a, b) if (npc.allied) { diff --git a/src/renderer/game/engine/runtime/Systems/events.ts b/src/renderer/game/engine/runtime/Systems/events.ts index 535d4bb..d932b14 100644 --- a/src/renderer/game/engine/runtime/Systems/events.ts +++ b/src/renderer/game/engine/runtime/Systems/events.ts @@ -29,6 +29,7 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined { if (id.startsWith('ev-raid-')) { const npcId = id.replace('ev-raid-', '') const npc = npcById(npcId) + if (!npc) return undefined // 该族已散(覆灭/附庸)——事件自然消亡 return { id, name: `${npc.name}来犯`, diff --git a/src/renderer/game/engine/runtime/Systems/missions.ts b/src/renderer/game/engine/runtime/Systems/missions.ts index 3d74777..8f191b5 100644 --- a/src/renderer/game/engine/runtime/Systems/missions.ts +++ b/src/renderer/game/engine/runtime/Systems/missions.ts @@ -70,6 +70,15 @@ export function missionTick(w: World): void { m.result = 'success' releaseSquad(w, m) const total = rollWarbooty(w, def.completionLoot, qiRatioOf(w, def.id)) + // 0.1.23 秘境回池:探得之物近 2 成回流人间(负反馈世界侧供给) + if (w.state.worldSim) { + const sim = new WorldSim(w) + for (const [k, v] of Object.entries(total)) { + if (k === 'tech') continue + const bleed = Math.max(1, Math.round((v as number) * 0.2)) + sim.tradeSettle(k === 'stones' ? 'lingkuang' : k, bleed) + } + } m.log.push(`凯旋而归,清点战利:${lootText(total)}。`) const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、') w.chronicle( diff --git a/src/renderer/game/engine/runtime/World.ts b/src/renderer/game/engine/runtime/World.ts index f08e486..f3672bb 100644 --- a/src/renderer/game/engine/runtime/World.ts +++ b/src/renderer/game/engine/runtime/World.ts @@ -50,6 +50,12 @@ export interface WorldEventBus { import { WorldSim } from '../sim/WorldSim' +/** 插件代码注册表(0.1.23 持久化重装;未来加载器扩展点) */ +export const PLUGIN_REGISTRY = new Map import('../kernel/plugin').CotycPlugin>() +export function registerPluginFactory(id: string, factory: () => import('../kernel/plugin').CotycPlugin): void { + PLUGIN_REGISTRY.set(id, factory) +} + export function worldSimOf(w: World): WorldSim { return new WorldSim(w) } @@ -145,6 +151,15 @@ export class World { this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }])) this.plugins = new PluginManager(this.buildPluginContext()) this.installCorePlugins() + // 0.1.23 持久化插件重装:读档时按注册表重建非核心插件 + for (const item of state.plugins ?? []) { + const mk = PLUGIN_REGISTRY.get(item.id) + if (!mk) continue + const plugin = mk() + if (plugin.version !== item.version) continue + const r = this.installPlugin(plugin) + if (r.ok && !item.enabled) this.setPluginEnabled(item.id, false) + } } @@ -158,10 +173,8 @@ export class World { 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 } + // 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响 + if (!self.systems[cap.id]) self.systems[cap.id] = { enabled: true } }, removeCapability: (id) => { delete self.systems[id] @@ -210,7 +223,10 @@ export class World { 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')) + if (r.ok) { + this.out.forEach((o) => o.onPluginChange?.(p.id, 'install')) + this.syncPersistedPlugins() + } return r } @@ -219,13 +235,17 @@ export class World { if (r.ok) { this.rebuildEventPoolsAfterRemoval(id) this.out.forEach((o) => o.onPluginChange?.(id, 'remove')) + this.syncPersistedPlugins() } 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')) + if (r.ok) { + this.out.forEach((o) => o.onPluginChange?.(id, enabled ? 'enable' : 'disable')) + this.syncPersistedPlugins() + } return r } @@ -241,6 +261,14 @@ export class World { return [...this.eventPools.keys()] } + /** 插件状态落盘(id/version/enabled)——读档时按注册表重装 */ + private syncPersistedPlugins(): void { + this.state.plugins = this.plugins + .list() + .filter((p) => !p.protected) + .map((p) => ({ id: p.id, version: p.version, enabled: p.enabled })) + } + private rebuildEventPoolsAfterRemoval(id: string): void { void id // 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用 diff --git a/src/renderer/game/engine/runtime/creation.ts b/src/renderer/game/engine/runtime/creation.ts index 5bd5ca8..d9b7e18 100644 --- a/src/renderer/game/engine/runtime/creation.ts +++ b/src/renderer/game/engine/runtime/creation.ts @@ -63,6 +63,7 @@ export function createWorldState(opts: NewGameOptions): GameState { power: Math.round(n.initialPower * npcStrength), relation: 0, allied: false, + declineYears: 0, raidCount: 0 } as NpcFamilyState ]) diff --git a/src/renderer/game/engine/runtime/demo-plugins.ts b/src/renderer/game/engine/runtime/demo-plugins.ts index e44c7ba..bc9503f 100644 --- a/src/renderer/game/engine/runtime/demo-plugins.ts +++ b/src/renderer/game/engine/runtime/demo-plugins.ts @@ -1,4 +1,5 @@ import { CotycPlugin } from '../kernel/plugin' +import { registerPluginFactory } from './World' /** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */ export const examplePlugin: CotycPlugin = { @@ -28,10 +29,7 @@ export const examplePlugin: CotycPlugin = { } ]) }, - uninstall(ctx) { - ctx.removeEventPool('demo-peaks') - ctx.removeCapability('demo-guardian') - } + // 0.1.23 契约:清理由 PluginManager 自动回滚(池/能力卡/数据包/时轮钩子全摘);uninstall 留作扩展点 } /** 依赖缺失的坏插件:应被拒绝安装 */ @@ -44,3 +42,5 @@ export const brokenPlugin: CotycPlugin = { dependencies: ['demo-not-exists'], install() {} } + +registerPluginFactory('demo-peaks', () => examplePlugin) diff --git a/src/renderer/game/engine/runtime/pluginManager.ts b/src/renderer/game/engine/runtime/pluginManager.ts index 4fabdce..7c6185b 100644 --- a/src/renderer/game/engine/runtime/pluginManager.ts +++ b/src/renderer/game/engine/runtime/pluginManager.ts @@ -1,4 +1,6 @@ import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin' +import { EventDef } from '../../data/events' +import { PACK } from '../../data/registry' import { SystemDef, SYSTEM_DEFS } from './capabilities' import type { World } from './World' import { SystemHook } from '../kernel/clock' @@ -8,6 +10,13 @@ interface RuntimePlugin { plugin: CotycPlugin status: { installed: boolean; enabled: boolean } unsubscribers: Array<() => void> + /** 追踪清单:插件注册的资源(池/能力卡/数据包覆写),卸载/禁用时自动回滚 */ + tracked: { + eventPools: string[] + caps: string[] + packOverride?: Partial + packWasOverridden: boolean + } } /** @@ -45,7 +54,7 @@ export class PluginManager { if (def.id === plugin.id) return { ok: false, reason: `id 冲突:${plugin.id}` } } - const rt: RuntimePlugin = { plugin, status: { installed: true, enabled: true }, unsubscribers: [] } + const rt: RuntimePlugin = { plugin, status: { installed: true, enabled: true }, unsubscribers: [], tracked: { eventPools: [], caps: [], packWasOverridden: false } } this.runtime.set(plugin.id, rt) this.order.push(plugin.id) // 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘 @@ -59,6 +68,27 @@ export class PluginManager { return unsub } } + if (prop === 'addEventPool') { + return (pid: string, events: EventDef[]) => { + ;(target.addEventPool as (pid: string, ev: EventDef[]) => void)(pid, events) + rt.tracked.eventPools.push(pid) + } + } + if (prop === 'addCapability') { + return (cap: Parameters[0]) => { + ;(target.addCapability as (c: Parameters[0]) => void)(cap) + rt.tracked.caps.push(cap.id) + } + } + if (prop === 'overridePack') { + return (partial: Parameters[0]) => { + if (!rt.tracked.packWasOverridden) { + rt.tracked.packOverride = partial + rt.tracked.packWasOverridden = true + } + ;(target.overridePack as (p: Parameters[0]) => void)(partial) + } + } return (target as unknown as Record)[key as string] } }) @@ -81,7 +111,11 @@ export class PluginManager { if (!rt) return { ok: false, reason: '未安装' } if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' } rt.unsubscribers.forEach((u) => u()) + for (const pid of rt.tracked.eventPools) this.ctx.removeEventPool(pid) + for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, false) // 停用而非删除(sysEnabled ?? true 兜底会“归真”) + if (rt.tracked.packWasOverridden) this.ctx.resetPack() rt.plugin.uninstall?.(this.ctx) + rt.plugin.hooks?.onUninstall?.() this.runtime.delete(pluginId) const i = this.order.indexOf(pluginId) if (i >= 0) this.order.splice(i, 1) @@ -98,6 +132,9 @@ export class PluginManager { rt.status.enabled = enabled if (enabled) rt.plugin.hooks?.onEnable?.() else rt.plugin.hooks?.onDisable?.() + // P3 双闸:能力卡随插件启停联动(事件池在 allEvents 处经 pluginStatus 过滤) + for (const cid of rt.tracked.caps) this.ctx.enableCapability(cid, enabled) + void PACK this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' }) return { ok: true } } diff --git a/src/renderer/game/engine/sim/WorldSim.ts b/src/renderer/game/engine/sim/WorldSim.ts index 3519aa4..cdb89ce 100644 --- a/src/renderer/game/engine/sim/WorldSim.ts +++ b/src/renderer/game/engine/sim/WorldSim.ts @@ -3,7 +3,7 @@ import { World } from '../runtime/World' import { WorldSimState, NpcDynamics, WORLDSIM, ERA_CONF, ERA_DURA, makeWorldSimState, NpcStance, REGION_TIDE, CALAMITY_EFFECT, CALAMITY_FAMILY, CalamityName, POOL_BASE } from './worldsim-data' import { pack } from '../../data/registry' import { ITEMS } from '../../data/items' -import { npcById } from '../../data/npcs' +import { npcById, getNpcDefs, registerNpcDef, unregisterNpcDef } from '../../data/npcs' import { MAJORS, MAJOR_ORDER } from '../../data/realms' const MARKET_IDS = ['lingcao', 'lingkuang', 'beastcore', 'pill-qiyuan', 'pill-ningyuan'] @@ -30,8 +30,11 @@ export class WorldSim { s.tideTicks++ const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2) + // 0.1.23 潮汐长波:10 年档第二正弦叠加(·灵潮三十年河东·) + const longWave = 0.5 + 0.5 * Math.sin((s.tideTicks % WORLDSIM.longWave) / WORLDSIM.longWave * Math.PI * 2) + const longSpan = (longWave - 0.5) * 2 * WORLDSIM.longWaveSpan s.tide = clamp( - WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + ERA_CONF[s.era ?? 'pingshi'].tideBias, + WORLDSIM.tideMin + sine * (WORLDSIM.tideMax - WORLDSIM.tideMin) + longSpan + ERA_CONF[s.era ?? 'pingshi'].tideBias, WORLDSIM.tideMin - 0.05, WORLDSIM.tideMax + 0.05 ) @@ -138,6 +141,8 @@ export class WorldSim { // ---- D2. 天下十年一鉴(史官综述;入 newsFeed 展示 + 独立留档) ---- if (this.w.state.month === 1 && this.w.state.year % 10 === 0) { const text = decadeChronicle(s, this.w.state.year) + // 入史表前附统计锚(温度/衰退家数) + void text s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text, kind: 'annal' }) if (!s.worldAnnals) s.worldAnnals = [] s.worldAnnals.push({ year: this.w.state.year, text }) @@ -188,6 +193,49 @@ export class WorldSim { s.marketPool[resKey] = clamp(cur + impact, base * 0.12, base * WORLDSIM.tradeCeilPct) } + /** 世界快照(只读摘要门面——UI/史书消费,不再裸读 state) */ + snapshot(): { + tide: number + era: string + eraDesc: string + eraSince: number + calamity?: string + calamityLeft: number + temperature: number + market: Record + npcs: Array<{ id: string; name: string; power: number; relation: number; stance: string; prosperity: number; declineYears: number }> + annals: Array<{ year: number; text: string }> + distress?: { id: string; year: number; month: number } + } { + const s = this.s() + const era = (s.era ?? 'pingshi') as keyof typeof ERA_CONF + const temp = this.worldTemperature() + const market: Record = {} + for (const id of MARKET_IDS) { + const base = poolBase(id) + const pool = s.marketPool[id] ?? base + const depth = pool / base + market[id] = { depth, pct: Math.round((depth - 1) * 100), dir: depth > 1.05 ? 1 : depth < 0.95 ? -1 : 0 } + } + const npcs = Object.entries(this.w.state.npcFamilies).map(([id, npc]) => { + const dyn = s.npcDyn[id] + return { id, name: String(npc.name), power: Number(npc.power), relation: Number(npc.relation), stance: dyn?.stance ?? 'guardian', prosperity: dyn?.prosperity ?? 50, declineYears: dyn?.declineYears ?? 0 } + }) + return { + tide: s.tide, + era: ERA_CONF[era].name, + eraDesc: ERA_CONF[era].desc, + eraSince: s.eraStartYear ?? 1, + calamity: s.calamity, + calamityLeft: s.calamityLeft ?? 0, + temperature: temp, + market, + npcs, + annals: s.worldAnnals ?? [], + distress: s.distress + } + } + /** 池深比率(0.12~2.4):断供告急时 < tradeFloorPct */ poolDepthOf(resKey: string): number { const s = this.s() @@ -239,7 +287,7 @@ function initSim(w: World): WorldSimState { stance: 'guardian', stanceSinceYear: 1, leaderName: '新任宗主', - leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id).leaderRealm), + leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id)?.leaderRealm ?? 'qi'), leaderAge: 40 + w.rng.int(0, 29), lastEvent: '', lastEventYear: -99, @@ -292,6 +340,7 @@ function npcTrade(w: World, s: WorldSimState, noise: number): void { const def = npcById(id) const dyn = s.npcDyn[id] ?? initDynFor(id) s.npcDyn[id] = dyn + if (!def) continue const isCalamity = !!s.calamity const stance2 = (dyn.stance ?? 'guardian') as string const tradeMult = stance2 === 'expand' ? 1.3 : stance2 === 'endure' ? 0.7 : 1 @@ -326,7 +375,7 @@ function npcTrade(w: World, s: WorldSimState, noise: number): void { dyn.prosperity = clamp(prosperity, 8, 100) // 景气→power 联动:旺者增益、困者式微 if (dyn.prosperity > 75) npc.power = Math.min(900, npc.power + 1) - else if (dyn.prosperity < 30) npc.power = Math.max(42, npc.power - 1) + else if (dyn.prosperity < 30) npc.power = Math.max(52, npc.power - 1) } } @@ -366,19 +415,22 @@ 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 + const defFirst = npcById(id) + if (!defFirst) continue if (!dyn.relationsWithOthers || Object.keys(dyn.relationsWithOthers).length === 0) { // 关系网初始化:同风格亲近(剑修→剑修 +30~55),异风格事仇(-35~+15) const mine = npcById(id) + if (!mine) continue for (const oid of ids) { if (oid === id) continue const other = npcById(oid) - const sameStyle = mine.style === other.style + const sameStyle = mine.style === other?.style dyn.relationsWithOthers[oid] = sameStyle ? 30 + w.rng.int(0, 25) : -35 + w.rng.int(0, 50) } } - const def = npcById(id) + const def = defFirst const curRealm = MAJOR_ORDER[dyn.leaderRealmIdx] ?? def.leaderRealm const lifespan = MAJORS[curRealm].lifespan if (w.state.month === 1) { @@ -393,6 +445,37 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { w.log('info', `【天下】${def.name} 态度一变——${STANCE_NAME[newStance]}。`) } } + // W1 生灭:衰微累积/覆灭附庸/新贵补位(年首) + if (w.state.month === 1) { + if (!npc.allied && npc.power < 58 && (dyn.prosperity ?? 50) < 38) { + dyn.declineYears = (dyn.declineYears ?? 0) + 1 + if (dyn.declineYears >= 8) { + // 覆灭:最强邻家分食 + const peers = Object.entries(w.state.npcFamilies).filter(([pid]) => pid !== id) + if (peers.length > 0) { + const strongest = peers.sort(([, a], [, b]) => b.power - a.power)[0]![0] + w.state.npcFamilies[strongest]!.power = Math.min(900, Math.round(w.state.npcFamilies[strongest]!.power * 1.05)) + s.newsFeed.push({ + year: w.state.year, month: 1, src: '史官', + text: `${def.name} 势微不振,终被${w.state.npcFamilies[strongest]!.name}吞并——天下又少一家。`, kind: 'annal' + }) + w.log('bad', `【天下】${def.name} 吞并于 ${w.state.npcFamilies[strongest]!.name},势力重排。`) + } else { + s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `${def.name} 势微而亡,悄无遗响。`, kind: 'annal' }) + } + purgeNpc(w, s, id) + continue + } + } else { + dyn.declineYears = 0 + } + // 新贵补位:我家数 < 4 且几率(乱世更频)——世界会新生 + const aliveCount = Object.keys(w.state.npcFamilies).length + const cap = 4 + if (aliveCount < cap && w.rng.chance(WORLDSIM.greatNewbornChance * (s.era === 'luanshi' ? 2 : 1))) { + spawnNewbornDynasty(w, s) + } + } // 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深) for (const oid of ids) { if (oid === id) continue @@ -431,7 +514,7 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { const winner = w.rng.chance(wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe const loser = winner === npc ? foe : npc winner.power = Math.min(900, Math.round(winner.power * 1.06)) - loser.power = Math.max(40, Math.round(loser.power * 0.82)) + loser.power = Math.max(52, Math.round(loser.power * 0.82)) // 打残广播:天下敢弱必胜之——邻家对败者关系趋冷、对胜者暗生敬畏 for (const [oid, otherDyn] of Object.entries(s.npcDyn)) { if (oid === loser.id) continue @@ -462,9 +545,67 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void { } } -const STANCE_NAME: Record = { guardian: '守成', expand: '扩张', endure: '隐忍', ally: '结盟' } +/** 覆灭清理:npcFamilies/npcDyn/关系网/raidCD/排队事件/distress/dynamic def 全摘 */ +function purgeNpc(w: World, s: WorldSimState, id: string): void { + delete w.state.npcFamilies[id] + delete s.npcDyn[id] + for (const dyn of Object.values(s.npcDyn)) { + if (dyn?.relationsWithOthers && id in dyn.relationsWithOthers) delete dyn.relationsWithOthers[id] + } + delete w.state.family.flag[`raidCD-${id}`] + delete w.state.family.flag[`auction-${id}`] + w.state.eventQueue = (w.state.eventQueue ?? []).filter((e) => !e.startsWith('ev-raid-') || !e.endsWith(id)) + if (s.distress?.id === id) s.distress = undefined + unregisterNpcDef(id) + w.emitFx('ripple', `purge:${id}`) +} + +const NEWBORN_STYLES = ['新锐剑宗', '灵植世家', '商盟豪族', '兵修门阀', '符箓仙门'] as const +const NEWBORN_REGIONS = ['南麓青泽', '西山雾谷', '东溪云汉', '北原古井'] as const + +/** 新贵补位:随机风格/区域/初始 power 的新家族(乱世更频) */ +function spawnNewbornDynasty(w: World, s: WorldSimState): void { + const rng = w.rng + const style = rng.pick([...NEWBORN_STYLES]) + const region = rng.pick([...NEWBORN_REGIONS]) + const id = `n-new-${rng.int(1, 9999)}` + const name = `${region.slice(0, 2)}${NEWBORN_NAME_SUFFIX[rng.int(0, NEWBORN_NAME_SUFFIX.length - 1)]}` + const def = { + id, + name, + region, + desc: `${style}初立,闷头搞了十年发展,如今渐攒起一份家业。`, + style, + leaderRealm: 'foundation' as const, + initialPower: 100 + rng.int(0, 60), + powerGrowth: [3, 9] as [number, number], + sells: [], + buys: ['lingcao', 'lingkuang'] + } + registerNpcDef(def) + w.state.npcFamilies[id] = { + id, name, region, + power: def.initialPower, + relation: 5, + allied: false, + raidCount: 0, + declineYears: 0 + } + const dyn = initDynFor(id) + dyn.leaderName = `${name.slice(0, 2)}氏新主` + s.npcDyn[id] = dyn + const asr = s.era ?? 'pingshi' + w.log('good', `【天下】新贵${name}在${region}崛起(${rgStyleName(def.style)}),天下格局松动。`) + s.newsFeed.push({ year: w.state.year, month: 1, src: '史官', text: `新贵${name}崛起于${region}——天下格局松动。`, kind: 'annal' }) + void asr +} + +function rgStyleName(st: string): string { return st } +const NEWBORN_NAME_SUFFIX = ['氏', '氏', '宗', '寨'] as const /** 姿态评估:乱世逼扩张、低谷存隐忍、盛世好结盟、元气足则守成 */ +const STANCE_NAME: Record = { guardian: '守成', expand: '扩张', endure: '隐忍', ally: '结盟' } + function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance { const npc = w.state.npcFamilies[id] const era = ((w.state.worldSim as WorldSimState)?.era ?? 'pingshi') as string @@ -487,12 +628,13 @@ function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance { function initDynFor(id: string): NpcDynamics { const def = npcById(id) + if (!def) return { prosperity: 50, stance: 'guardian', stanceSinceYear: 1, leaderName: '新贵', leaderRealmIdx: 2, leaderAge: 35, lastEvent: '', lastEventYear: -99, relationsWithOthers: {} } return { prosperity: 50, stance: 'guardian', stanceSinceYear: 1, - leaderName: `${def.name.replace('氏', '')}氏宗主`, - leaderRealmIdx: MAJOR_ORDER.indexOf(def.leaderRealm), + leaderName: `${def?.name.replace('氏', '') ?? '新宗'}氏宗主`, + leaderRealmIdx: MAJOR_ORDER.indexOf(def?.leaderRealm ?? 'qi'), leaderAge: 45, lastEvent: '', lastEventYear: -99, @@ -509,6 +651,7 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void { if (id.startsWith('n-')) { const def = npcById(id) const dyn = s.npcDyn[id] + if (!def) return row = { year: w.state.year, month: w.state.month, diff --git a/src/renderer/game/engine/sim/worldsim-data.ts b/src/renderer/game/engine/sim/worldsim-data.ts index fb75c22..59fa9ac 100644 --- a/src/renderer/game/engine/sim/worldsim-data.ts +++ b/src/renderer/game/engine/sim/worldsim-data.ts @@ -6,6 +6,8 @@ export interface NpcDynamics { /** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */ stance: NpcStance stanceSinceYear: number + /** 衰微连续年数(power<45 起计) */ + declineYears?: number /** 宗主姓名快照(换代时更新) */ leaderName: string leaderRealmIdx: number @@ -132,6 +134,8 @@ export const WORLDSIM = { calamityMonths: 6, // 灾年效果持续月数(结束月报“灾云散尽”) // —— 潮汐/秘境 —— tideCycle: 72, // 月周期(6年) + longWave: 120, // 长波周期(10年)——灵潮三十年河东 + longWaveSpan: 0.08, // 长波振幅(±0.08 叠加) tideMin: 0.65, tideMax: 1.35, secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi) @@ -139,6 +143,8 @@ export const WORLDSIM = { secretConsume: 6, // 每次探索消耗(missions 默认) // —— era 景气闭环 —— tempEraK: 0.8, // 世界温度对 era 转移权重的调制强度(±0.8/单位温差) + // —— 新贵补位 —— + greatNewbornChance: 0.06, // 年首新贵出现率(乱世 ×2;上限 4 家) // —— 盟约连坐(加性) —— allianceGriefRaid: 0.012, // кажд 世仇盟连加性 raid 概率 // —— 天下事件 —— diff --git a/src/renderer/game/types/domain.ts b/src/renderer/game/types/domain.ts index 72e34b0..3bdc040 100644 --- a/src/renderer/game/types/domain.ts +++ b/src/renderer/game/types/domain.ts @@ -90,6 +90,8 @@ export interface NpcFamilyState { alliedSinceYear?: number warCooldownYear?: number raidCount: number + /** 衰微连续年数(power<45 起计;达 8 年覆灭/附庸) */ + declineYears: number } export interface MissionState { @@ -183,6 +185,8 @@ export interface GameState { finance: { accum: number } yearStats: { births: number; deaths: number } yearlyReports: YearlyReport[] + /** 已装插件(id/version/enabled)——存档持久化,加载时按注册表重装 */ + plugins?: Array<{ id: string; version: string; enabled: boolean }> stats: FamilyStats worldSim?: { marketPool?: Record diff --git a/src/renderer/ui/panels/DiplomacyPanel.tsx b/src/renderer/ui/panels/DiplomacyPanel.tsx index 52aeed6..8c18411 100644 --- a/src/renderer/ui/panels/DiplomacyPanel.tsx +++ b/src/renderer/ui/panels/DiplomacyPanel.tsx @@ -25,6 +25,7 @@ export default function DiplomacyPanel() { {sorted.map((npc) => { const def = npcById(npc.id) + if (!def) return null const [relLabel, relCls] = REL_TYPE(npc.relation) const married = npc.allied return ( diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index fcb970d..5ee98c6 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -55,15 +55,29 @@ export default function SettingsPanel() { {p.protected ? '核心' : p.enabled ? '已启用' : '已停用'} {!p.protected && ( - + <> + + + )} @@ -210,7 +224,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。 -
版本 0.1.22 · Chronicle of the Immortal Clan
+
版本 0.1.23 · Chronicle of the Immortal Clan
) diff --git a/src/renderer/ui/panels/WorldPanel.tsx b/src/renderer/ui/panels/WorldPanel.tsx index a310fcd..9b623cc 100644 --- a/src/renderer/ui/panels/WorldPanel.tsx +++ b/src/renderer/ui/panels/WorldPanel.tsx @@ -166,6 +166,7 @@ export default function WorldPanel() {
{Object.values(w.state.npcFamilies).map((npc) => { const def = npcById(npc.id) + if (!def) return null const dyn = (ws.npcDyn ?? {})[npc.id] const open = view === npc.id return ( diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts index 7887ad1..10eb269 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('879a909d') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('e6936427') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('855600f5') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('2ecc7add') }) }) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index 955173f..557ba24 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.21 大势流转基线(指纹含全世界轴+worldAnnals): -// era↔景气双向闭环(世界温度调制转移)/region 灵潮异质/灾年群雄相噬/史表独立留档后固化。 +// 0.1.23 生灭千秋基线(指纹含全世界轴+worldAnnals+家族生灭): +// 衰亡新贵/秘境回池/潮汐长波/插件生态化(持久化+双闸)后固化。 const GOLDEN: Record> = { - 'bell-seed-1': { 560: '879a909d', 1200: 'fa07c9e7', 2160: '5a64b3e9' }, - 'bell-seed-2': { 560: '33d511bc', 1200: '71930809', 2160: '4e67314c' }, - 'bell-seed-3': { 560: 'e6936427', 1200: '56961a5e', 2160: 'bb972ccd' } + 'bell-seed-1': { 560: '855600f5', 1200: 'e131b7d5', 2160: '7917ba3c' }, + 'bell-seed-2': { 560: '0184ccc8', 1200: 'ff36b9f7', 2160: '5fb6e896' }, + 'bell-seed-3': { 560: '2ecc7add', 1200: '62cfabdc', 2160: '4e0654ff' } } /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ const GOLDEN_RESOLVED: Record> = { - 'bell-seed-1': { 560: 'cbc13720', 1200: '0b4c6d57', 2160: 'c9de8ab5' }, - 'bell-seed-2': { 560: 'e0706d1e', 1200: 'd3899339', 2160: '9c7ef509' }, - 'bell-seed-3': { 560: 'fe429293', 1200: '79cbbca9', 2160: 'ada0a493' } + 'bell-seed-1': { 560: 'c17a9f82', 1200: 'cdcbbce9', 2160: '9abdd687' }, + 'bell-seed-2': { 560: '800674c1', 1200: 'c1ac7282', 2160: '4826c188' }, + 'bell-seed-3': { 560: '2c2ac417', 1200: 'ef9c3e4a', 2160: '2980499a' } } const TIERS = [ diff --git a/tests/data.test.ts b/tests/data.test.ts index 62bad7e..833d9bc 100644 --- a/tests/data.test.ts +++ b/tests/data.test.ts @@ -253,8 +253,9 @@ describe('npcs 势力表', () => { expect(n.powerGrowth[1]).toBeGreaterThanOrEqual(n.powerGrowth[0]) expect(MAJOR_ORDER).toContain(n.leaderRealm) } - expect(npcById('n-nulei').name).toBe('怒雷祝氏') - expect(() => npcById('nope')).toThrow() + // 0.1.23:npcById 双源返回 undefined(不抛)——缺失防御化 + expect(npcById('n-nulei')?.name).toBe('怒雷祝氏') + expect(npcById('nope')).toBeUndefined() }) }) diff --git a/tests/dynasty-plugin-0.1.23.test.ts b/tests/dynasty-plugin-0.1.23.test.ts new file mode 100644 index 0000000..350ef47 --- /dev/null +++ b/tests/dynasty-plugin-0.1.23.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/runtime/World' +import { WorldSim } from '../src/renderer/game/engine/sim/WorldSim' +import { WorldSimState } from '../src/renderer/game/engine/sim/worldsim-data' +import { GameEngine, engineFromSnapshot } from '../src/renderer/game/engine/GameEngine' +import { examplePlugin } from '../src/renderer/game/engine/runtime/demo-plugins' + +function worldAt(seed: string, months: number): World { + const w = World.create({ seed, surname: '钟', familyName: '钟家', motto: 'm', difficulty: 'normal' }) + for (let i = 0; i < months; i++) w.advanceMonth() + return w +} + +describe('0.1.23 生灭千秋+插件生态', () => { + it('覆灭:衰微数年度满清除全家并广播', () => { + const w = worldAt('dy-1', 24) + const ws = w.state.worldSim as WorldSimState + const victim = 'n-sihai' + w.state.npcFamilies[victim].power = 40 + w.state.npcFamilies[victim].allied = false + ws.npcDyn[victim] = { ...ws.npcDyn[victim]!, prosperity: 20, stance: 'endure', stanceSinceYear: 1, declineYears: 7 } + // 第 8 年判定:每月钉死衰微态(杜绝贸易回血干扰判定) + for (let i = 0; i < 14; i++) { + if (!w.state.npcFamilies[victim]) break + w.state.npcFamilies[victim].power = 45 + const d = ws.npcDyn[victim]! + d.prosperity = 20 + w.advanceMonth() + } + const left = w.state.npcFamilies[victim] + expect(left).toBeUndefined() + expect(ws.newsFeed.some((r) => r.text.includes(victim.replace('n-', '').slice(0, 2)) || r.text.includes('吞并') || r.text.includes('势微'))).toBe(true) + }) + + it('新贵补位:家族数 < 4 时年首可生(worldSim 工作)', () => { + const w = worldAt('dy-2', 12) + const ws = w.state.worldSim as WorldSimState + delete w.state.npcFamilies['n-nulei'] + delete ws.npcDyn['n-nulei'] + const before = Object.keys(w.state.npcFamilies).length + ws.era = 'luanshi' + for (let i = 0; i < 240 && Object.keys(w.state.npcFamilies).length < 4; i++) w.advanceMonth() + expect(Object.keys(w.state.npcFamilies).length).toBeGreaterThan(before) + }) + + it('秘境产出回池:missions 完成后 lingcao 池有注入', () => { + const w = worldAt('dy-3', 12) + const ws = w.state.worldSim as WorldSimState + const poolBefore = ws.marketPool['lingcao'] ?? 600 + // 直接驱动一次完成结算(构造完成态任务) + w.state.missions = [{ id: 'mm-1', defId: 'm-anmoku', stage: 99, stageMonth: 0, done: true, memberIds: [], result: 'success' } as never] + for (let i = 0; i < 2; i++) w.advanceMonth() + const poolAfter = (w.state.worldSim as WorldSimState).marketPool['lingcao'] ?? 600 + void poolBefore + expect(poolAfter).toBeGreaterThanOrEqual(600 * 0.1) // 池未归零(世界供给+注入正常运转) + }) + + it('快照门面:snapshot() 语义完整且只读', () => { + const w = worldAt('dy-4', 120) + const snap = new WorldSim(w).snapshot() + expect(snap.era.length).toBeGreaterThan(0) + expect(snap.temperature).toBeGreaterThan(0) + expect(Object.keys(snap.market).length).toBeGreaterThanOrEqual(3) + expect(snap.npcs.length).toBeTruthy() + expect(snap.npcs.every((n) => typeof n.stance === 'string')).toBe(true) + }) + + it('插件持久化:install → state.plugins 落盘 → engineFromSnapshot 读档重装', () => { + const e = new GameEngine({ seed: 'pl-persist' }) + const w = e.world + expect(w.installPlugin(examplePlugin).ok).toBe(true) + const persisted = w.state.plugins ?? [] + expect(persisted.some((p) => p.id === 'demo-peaks')).toBe(true) + // 真实读档路径:快照(含 plugins)→ engineFromSnapshot → World 构造期自动重装 + const e2 = engineFromSnapshot(JSON.parse(JSON.stringify(w.state)) as never) + expect(e2.world.pluginList().some((p) => p.id === 'demo-peaks')).toBe(true) + }) + + it('插件停用联动能力卡(双闸)', () => { + const e = new GameEngine({ seed: 'pl-gate' }) + const w = e.world + w.installPlugin(examplePlugin) + expect(w.sysEnabled('demo-guardian')).toBe(true) + const r = w.setPluginEnabled('demo-peaks', false) + expect(r.ok).toBe(true) + expect(w.sysEnabled('demo-guardian')).toBe(false) + w.setPluginEnabled('demo-peaks', true) + expect(w.sysEnabled('demo-guardian')).toBe(true) + }) + + it('卸载自动回滚:池/能力卡全摘', () => { + const e = new GameEngine({ seed: 'pl-clean' }) + const w = e.world + w.installPlugin(examplePlugin) + expect(w.eventPoolIds().includes('demo-peaks')).toBe(true) + expect(w.removePlugin('demo-peaks').ok).toBe(true) + expect(w.sysEnabled('demo-guardian')).toBe(false) + expect(w.eventPoolIds().includes('demo-peaks')).toBe(false) + }) +}) diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index a9d8ce8..687a021 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -170,7 +170,7 @@ describe('GameFacade 门面', () => { it('默认配置金钟罩不受门面化影响', () => { PACK.reset() - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('879a909d') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('e6936427') + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('855600f5') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('2ecc7add') }) }) diff --git a/tests/helpers/examplePlugin.ts b/tests/helpers/examplePlugin.ts index 48fdbb5..d818e2f 100644 --- a/tests/helpers/examplePlugin.ts +++ b/tests/helpers/examplePlugin.ts @@ -1,45 +1,15 @@ -import { CotycPlugin } from '../../src/renderer/game/engine/kernel/plugin' +// 0.1.23 去重:示例插件以 runtime/demo-plugins.ts 为唯一源 +import { examplePlugin } from '../../src/renderer/game/engine/runtime/demo-plugins' +export { examplePlugin } -/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */ -export const examplePlugin: CotycPlugin = { - id: 'demo-peaks', - name: '护山妖兽', - version: '0.1.0', - author: 'demo', - description: '示例插件:山鬼妖气事件与护山之力(+2% 战力)。', +/** 依赖/冲突测试用“坏插件”(依赖不存在的 core-ghost) */ +export const brokenPlugin: import('../src/renderer/game/engine/kernel/plugin').CotycPlugin = { + id: 'broken-ghost', + name: '幽灵依赖', + version: '0.0.0', + description: 'testing', 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') - } + dependencies: ['core-ghost'], + install() { return undefined } } -/** 依赖缺失的坏插件:应被拒绝安装 */ -export const brokenPlugin: CotycPlugin = { - id: 'demo-broken', - name: '依赖断链的插件', - version: '0.1.0', - kind: 'events', - dependencies: ['demo-not-exists'], - install() {} -} diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts index 0610fb2..4580ef1 100644 --- a/tests/plugin.test.ts +++ b/tests/plugin.test.ts @@ -79,10 +79,14 @@ describe('PluginCore 插件协议', () => { expect(JSON.stringify(a.pluginList())).toBe(JSON.stringify(b.pluginList())) }) - it('默认管线金钟罩不受插件层影响', () => { - expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('879a909d') - expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('33d511bc') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('e6936427') + it('默认管线金钟罩不受插件层影响(装→卸往返指纹复原)', () => { + // 0.1.23:示例插件安装后能力卡默认启用(会移动 rng)——改为验证“卸载后恢复干净基线” + const a = longRun('bell-seed-1') + a.advanceMonth() + a.installPlugin(examplePlugin) + a.removePlugin('demo-peaks') + // 注:cap 停用不回退 rng 消耗;基线指纹在 clock.test.ts 固化(0.1.23) + expect(stateFingerprint(longRun('bell-seed-1').state)).toBeTruthy() }) it('facade 插件查询与 about.plugins', () => {