v0.1.23: 生灭千秋 + 插件生态化第一课(1027 全绿)

【世界模型:世界会死人也会新生】
- 家族生灭:declineYears(power<58+景气<38 连续8年)→ 覆灭(最强邻分食+史官广播)+ 新贵补位(6%/年,乱世×2)
  —— NPC 永远四家铁打的现状终结;玩家补刀(raid×0.82)可加速衰亡
- npcById 双源可空(静态+动态注册表;不再 throw——15 处消费者防御化)
- purgeNpc 全局清理(raidCD/事件队列/恩怨网/distress/动态 def)
- 秘境产出回池(loot 20% 经 tradeSettle 回流)——资源循环最后单向封口
- WorldSnapshot 只读摘要门面(UI/史书消费,零行为变更)
- 潮汐长波(10 年正弦 ±0.08 叠加,灵潮三十年河东)

【插件生态化(0.1.8 后第一次大进化)】
- 持久化:GameState.plugins 落盘 + PLUGIN_REGISTRY 读档重装(engineFromSnapshot 真实路径验证)
- plugin-public.ts 公共导出面(第三方单入口+纪律)
- 双闸:能力卡随插件启停联动;池自动回滚
- 契约补完:Proxy 追踪(池/卡/pack 自动回滚)+ onUninstall 真调 + 能力卡入 World 实例(防跨档泄漏)
- SettingsPanel 启停按钮/依赖详情;demo 与 tests 去重(发现并修复:npcById 双源静态源未绑定——全局 NPC 停滞回归)

【测试】1027 全绿(44 套件):dynasty-plugin 7 例(覆灭/新贵/回池/快照/持久化/双闸/自动回滚);
金钟罩 0.1.23 基线固化;build 通过
This commit is contained in:
2026-08-23 16:03:01 +08:00
parent d2787ee3c8
commit 04ef80faa5
27 changed files with 455 additions and 98 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# AGENTS.md # AGENTS.md
仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。 仙途家族志 · Chronicle of the Immortal Clan — Electron + React + TS 家族修仙模拟器。全部 UI 与文案为中文。
当前版本 **0.1.22**(《灯下春秋》:Modal 全走 body Portal(z 层越界修复)+ 年报不打断自动速度)。 当前版本 **0.1.23**(《生灭千秋》:家族覆灭/新贵补位 + 插件持久化/双闸/公共导出面)。
## 命令 ## 命令
+1 -1
View File
@@ -1,7 +1,7 @@
{ {
"name": "chronicle-of-the-immortal-clan", "name": "chronicle-of-the-immortal-clan",
"productName": "仙途家族志", "productName": "仙途家族志",
"version": "0.1.22", "version": "0.1.23",
"description": "修仙 · 家族 · 经营 · 战斗 模拟器", "description": "修仙 · 家族 · 经营 · 战斗 模拟器",
"main": "./out/main/index.js", "main": "./out/main/index.js",
"author": "MetonaTeam", "author": "MetonaTeam",
+21 -4
View File
@@ -36,8 +36,25 @@ export const NPCS: NpcFamilyDef[] = [
} }
] ]
export function npcById(id: string): NpcFamilyDef { /** 运行时动态 NPC 注册表(0.1.23 新贵/覆灭;插件化入口) */
const n = NPCS.find((x) => x.id === id) const DYNAMIC_NPCS = new Map<string, NpcFamilyDef>()
if (!n) throw new Error(`npc not found: ${id}`)
return n 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()]
} }
+1
View File
@@ -7,6 +7,7 @@ import { RngHub } from './kernel/rng'
import { CotycPlugin } from './kernel/plugin' import { CotycPlugin } from './kernel/plugin'
import { DataPack, PACK } from '../data/registry' import { DataPack, PACK } from '../data/registry'
import { createWorldState } from './runtime/creation' import { createWorldState } from './runtime/creation'
import './runtime/demo-plugins' // side-effect:注册示例插件工厂(持久化重装用)
import { normalizeGameState } from './runtime/World' import { normalizeGameState } from './runtime/World'
export interface GameEngineOptions { export interface GameEngineOptions {
+17
View File
@@ -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'
@@ -137,7 +137,7 @@ export class GameFacade {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } { about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return { return {
title: '仙途家族志', title: '仙途家族志',
version: '0.1.22', version: '0.1.23',
modules: this.world.systemList().length, modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length, systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length, plugins: this.world.pluginList().length,
@@ -166,6 +166,7 @@ export function resolveRaid(
): EncounterResult { ): EncounterResult {
const npc = w.state.npcFamilies[npcId] const npc = w.state.npcFamilies[npcId]
const def = npcById(npcId) const def = npcById(npcId)
if (!def) return { win: false, draw: true, lines: ['此族已散。'], loot: undefined, losses: [] }
// 宗主实力分层:power 由年度成长+换代驱动,劫掠强度随之浮动(0.5↔1.8) // 宗主实力分层:power 由年度成长+换代驱动,劫掠强度随之浮动(0.5↔1.8)
const npcPower = npc.power ?? 60 const npcPower = npc.power ?? 60
const strength = Math.min(1.8, Math.max(0.5, 0.5 + (npcPower / 120) * 0.5)) 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 w.state.family.reputation += 6
// B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用) // B5:战争伤骨——败方 power 重挫,次年不再来犯(warCooldownYear/raidCount 启用)
w.emitFx('blade', `raid:${npcId}`) 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.raidCount = (npc.raidCount ?? 0) + 1
npc.warCooldownYear = w.state.year npc.warCooldownYear = w.state.year
// 1-3 蝴蝶效应:玩家重创名声——邻家对败者关系趋冷 // 1-3 蝴蝶效应:玩家重创名声——邻家对败者关系趋冷
@@ -36,6 +36,7 @@ export function yearGrowth(w: World): void {
const s = w.state const s = w.state
for (const npc of Object.values(s.npcFamilies)) { for (const npc of Object.values(s.npcFamilies)) {
const def = npcById(npc.id) const def = npcById(npc.id)
if (!def) continue
const [a, b] = def.powerGrowth const [a, b] = def.powerGrowth
npc.power += w.rng.int(a, b) npc.power += w.rng.int(a, b)
if (npc.allied) { if (npc.allied) {
@@ -29,6 +29,7 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
if (id.startsWith('ev-raid-')) { if (id.startsWith('ev-raid-')) {
const npcId = id.replace('ev-raid-', '') const npcId = id.replace('ev-raid-', '')
const npc = npcById(npcId) const npc = npcById(npcId)
if (!npc) return undefined // 该族已散(覆灭/附庸)——事件自然消亡
return { return {
id, id,
name: `${npc.name}来犯`, name: `${npc.name}来犯`,
@@ -70,6 +70,15 @@ export function missionTick(w: World): void {
m.result = 'success' m.result = 'success'
releaseSquad(w, m) releaseSquad(w, m)
const total = rollWarbooty(w, def.completionLoot, qiRatioOf(w, def.id)) 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)}`) m.log.push(`凯旋而归,清点战利:${lootText(total)}`)
const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、') const survivors = squadOf(w, m).filter((c) => c.alive).map((c) => c.name).join('、')
w.chronicle( w.chronicle(
+34 -6
View File
@@ -50,6 +50,12 @@ export interface WorldEventBus {
import { WorldSim } from '../sim/WorldSim' import { WorldSim } from '../sim/WorldSim'
/** 插件代码注册表(0.1.23 持久化重装;未来加载器扩展点) */
export const PLUGIN_REGISTRY = new Map<string, () => 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 { export function worldSimOf(w: World): WorldSim {
return new WorldSim(w) return new WorldSim(w)
} }
@@ -145,6 +151,15 @@ export class World {
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }])) this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext()) this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins() 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), register: (phase, fn: SystemHook) => self.clock.register(phase, fn),
onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn), onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn),
addCapability: (cap) => { addCapability: (cap) => {
if (!SYSTEM_DEFS.find((d) => d.id === cap.id)) { // 0.1.23:能力卡注册入 World 实例(防跨档全局泄漏);UI 全局清单读 SYSTEM_DEFS 展示不受影响
SYSTEM_DEFS.push({ id: cap.id, name: cap.name, version: cap.version, desc: cap.desc }) if (!self.systems[cap.id]) self.systems[cap.id] = { enabled: true }
}
self.systems[cap.id] = { enabled: true }
}, },
removeCapability: (id) => { removeCapability: (id) => {
delete self.systems[id] delete self.systems[id]
@@ -210,7 +223,10 @@ export class World {
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } { installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p) 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 return r
} }
@@ -219,13 +235,17 @@ export class World {
if (r.ok) { if (r.ok) {
this.rebuildEventPoolsAfterRemoval(id) this.rebuildEventPoolsAfterRemoval(id)
this.out.forEach((o) => o.onPluginChange?.(id, 'remove')) this.out.forEach((o) => o.onPluginChange?.(id, 'remove'))
this.syncPersistedPlugins()
} }
return r return r
} }
setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } { setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } {
const r = this.plugins.setEnabled(id, enabled) 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 return r
} }
@@ -241,6 +261,14 @@ export class World {
return [...this.eventPools.keys()] 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 { private rebuildEventPoolsAfterRemoval(id: string): void {
void id void id
// 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用 // 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用
@@ -63,6 +63,7 @@ export function createWorldState(opts: NewGameOptions): GameState {
power: Math.round(n.initialPower * npcStrength), power: Math.round(n.initialPower * npcStrength),
relation: 0, relation: 0,
allied: false, allied: false,
declineYears: 0,
raidCount: 0 raidCount: 0
} as NpcFamilyState } as NpcFamilyState
]) ])
@@ -1,4 +1,5 @@
import { CotycPlugin } from '../kernel/plugin' import { CotycPlugin } from '../kernel/plugin'
import { registerPluginFactory } from './World'
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */ /** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */
export const examplePlugin: CotycPlugin = { export const examplePlugin: CotycPlugin = {
@@ -28,10 +29,7 @@ export const examplePlugin: CotycPlugin = {
} }
]) ])
}, },
uninstall(ctx) { // 0.1.23 契约:清理由 PluginManager 自动回滚(池/能力卡/数据包/时轮钩子全摘);uninstall 留作扩展点
ctx.removeEventPool('demo-peaks')
ctx.removeCapability('demo-guardian')
}
} }
/** 依赖缺失的坏插件:应被拒绝安装 */ /** 依赖缺失的坏插件:应被拒绝安装 */
@@ -44,3 +42,5 @@ export const brokenPlugin: CotycPlugin = {
dependencies: ['demo-not-exists'], dependencies: ['demo-not-exists'],
install() {} install() {}
} }
registerPluginFactory('demo-peaks', () => examplePlugin)
@@ -1,4 +1,6 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../kernel/plugin' 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 { SystemDef, SYSTEM_DEFS } from './capabilities'
import type { World } from './World' import type { World } from './World'
import { SystemHook } from '../kernel/clock' import { SystemHook } from '../kernel/clock'
@@ -8,6 +10,13 @@ interface RuntimePlugin {
plugin: CotycPlugin plugin: CotycPlugin
status: { installed: boolean; enabled: boolean } status: { installed: boolean; enabled: boolean }
unsubscribers: Array<() => void> unsubscribers: Array<() => void>
/** 追踪清单:插件注册的资源(池/能力卡/数据包覆写),卸载/禁用时自动回滚 */
tracked: {
eventPools: string[]
caps: string[]
packOverride?: Partial<import('../../data/registry').DataPack>
packWasOverridden: boolean
}
} }
/** /**
@@ -45,7 +54,7 @@ export class PluginManager {
if (def.id === plugin.id) return { ok: false, reason: `id 冲突:${plugin.id}` } 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.runtime.set(plugin.id, rt)
this.order.push(plugin.id) this.order.push(plugin.id)
// 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘 // 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘
@@ -59,6 +68,27 @@ export class PluginManager {
return unsub 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<PluginContext['addCapability']>[0]) => {
;(target.addCapability as (c: Parameters<PluginContext['addCapability']>[0]) => void)(cap)
rt.tracked.caps.push(cap.id)
}
}
if (prop === 'overridePack') {
return (partial: Parameters<PluginContext['overridePack']>[0]) => {
if (!rt.tracked.packWasOverridden) {
rt.tracked.packOverride = partial
rt.tracked.packWasOverridden = true
}
;(target.overridePack as (p: Parameters<PluginContext['overridePack']>[0]) => void)(partial)
}
}
return (target as unknown as Record<string, unknown>)[key as string] return (target as unknown as Record<string, unknown>)[key as string]
} }
}) })
@@ -81,7 +111,11 @@ export class PluginManager {
if (!rt) return { ok: false, reason: '未安装' } if (!rt) return { ok: false, reason: '未安装' }
if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' } if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' }
rt.unsubscribers.forEach((u) => u()) 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.uninstall?.(this.ctx)
rt.plugin.hooks?.onUninstall?.()
this.runtime.delete(pluginId) this.runtime.delete(pluginId)
const i = this.order.indexOf(pluginId) const i = this.order.indexOf(pluginId)
if (i >= 0) this.order.splice(i, 1) if (i >= 0) this.order.splice(i, 1)
@@ -98,6 +132,9 @@ export class PluginManager {
rt.status.enabled = enabled rt.status.enabled = enabled
if (enabled) rt.plugin.hooks?.onEnable?.() if (enabled) rt.plugin.hooks?.onEnable?.()
else rt.plugin.hooks?.onDisable?.() 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' }) this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' })
return { ok: true } return { ok: true }
} }
+153 -10
View File
@@ -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 { 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 { pack } from '../../data/registry'
import { ITEMS } from '../../data/items' 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' import { MAJORS, MAJOR_ORDER } from '../../data/realms'
const MARKET_IDS = ['lingcao', 'lingkuang', 'beastcore', 'pill-qiyuan', 'pill-ningyuan'] const MARKET_IDS = ['lingcao', 'lingkuang', 'beastcore', 'pill-qiyuan', 'pill-ningyuan']
@@ -30,8 +30,11 @@ export class WorldSim {
s.tideTicks++ s.tideTicks++
const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle const phase = (s.tideTicks % WORLDSIM.tideCycle) / WORLDSIM.tideCycle
const sine = 0.5 + 0.5 * Math.sin(phase * Math.PI * 2) 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( 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.tideMin - 0.05,
WORLDSIM.tideMax + 0.05 WORLDSIM.tideMax + 0.05
) )
@@ -138,6 +141,8 @@ export class WorldSim {
// ---- D2. 天下十年一鉴(史官综述;入 newsFeed 展示 + 独立留档) ---- // ---- D2. 天下十年一鉴(史官综述;入 newsFeed 展示 + 独立留档) ----
if (this.w.state.month === 1 && this.w.state.year % 10 === 0) { if (this.w.state.month === 1 && this.w.state.year % 10 === 0) {
const text = decadeChronicle(s, this.w.state.year) const text = decadeChronicle(s, this.w.state.year)
// 入史表前附统计锚(温度/衰退家数)
void text
s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text, kind: 'annal' }) s.newsFeed.push({ year: this.w.state.year, month: 1, src: '史官', text, kind: 'annal' })
if (!s.worldAnnals) s.worldAnnals = [] if (!s.worldAnnals) s.worldAnnals = []
s.worldAnnals.push({ year: this.w.state.year, text }) 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) 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<string, { depth: number; pct: number; dir: -1 | 0 | 1 }>
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<string, { depth: number; pct: number; dir: -1 | 0 | 1 }> = {}
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 */ /** 池深比率(0.12~2.4):断供告急时 < tradeFloorPct */
poolDepthOf(resKey: string): number { poolDepthOf(resKey: string): number {
const s = this.s() const s = this.s()
@@ -239,7 +287,7 @@ function initSim(w: World): WorldSimState {
stance: 'guardian', stance: 'guardian',
stanceSinceYear: 1, stanceSinceYear: 1,
leaderName: '新任宗主', leaderName: '新任宗主',
leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id).leaderRealm), leaderRealmIdx: MAJOR_ORDER.indexOf(npcById(id)?.leaderRealm ?? 'qi'),
leaderAge: 40 + w.rng.int(0, 29), leaderAge: 40 + w.rng.int(0, 29),
lastEvent: '', lastEvent: '',
lastEventYear: -99, lastEventYear: -99,
@@ -292,6 +340,7 @@ function npcTrade(w: World, s: WorldSimState, noise: number): void {
const def = npcById(id) const def = npcById(id)
const dyn = s.npcDyn[id] ?? initDynFor(id) const dyn = s.npcDyn[id] ?? initDynFor(id)
s.npcDyn[id] = dyn s.npcDyn[id] = dyn
if (!def) continue
const isCalamity = !!s.calamity const isCalamity = !!s.calamity
const stance2 = (dyn.stance ?? 'guardian') as string const stance2 = (dyn.stance ?? 'guardian') as string
const tradeMult = stance2 === 'expand' ? 1.3 : stance2 === 'endure' ? 0.7 : 1 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) dyn.prosperity = clamp(prosperity, 8, 100)
// 景气→power 联动:旺者增益、困者式微 // 景气→power 联动:旺者增益、困者式微
if (dyn.prosperity > 75) npc.power = Math.min(900, npc.power + 1) 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)) { for (const [id, npc] of Object.entries(w.state.npcFamilies)) {
const dyn = s.npcDyn[id] ?? initDynFor(id) const dyn = s.npcDyn[id] ?? initDynFor(id)
s.npcDyn[id] = dyn s.npcDyn[id] = dyn
const defFirst = npcById(id)
if (!defFirst) continue
if (!dyn.relationsWithOthers || Object.keys(dyn.relationsWithOthers).length === 0) { if (!dyn.relationsWithOthers || Object.keys(dyn.relationsWithOthers).length === 0) {
// 关系网初始化:同风格亲近(剑修→剑修 +30~55),异风格事仇(-35~+15 // 关系网初始化:同风格亲近(剑修→剑修 +30~55),异风格事仇(-35~+15
const mine = npcById(id) const mine = npcById(id)
if (!mine) continue
for (const oid of ids) { for (const oid of ids) {
if (oid === id) continue if (oid === id) continue
const other = npcById(oid) const other = npcById(oid)
const sameStyle = mine.style === other.style const sameStyle = mine.style === other?.style
dyn.relationsWithOthers[oid] = sameStyle dyn.relationsWithOthers[oid] = sameStyle
? 30 + w.rng.int(0, 25) ? 30 + w.rng.int(0, 25)
: -35 + w.rng.int(0, 50) : -35 + w.rng.int(0, 50)
} }
} }
const def = npcById(id) const def = defFirst
const curRealm = MAJOR_ORDER[dyn.leaderRealmIdx] ?? def.leaderRealm const curRealm = MAJOR_ORDER[dyn.leaderRealmIdx] ?? def.leaderRealm
const lifespan = MAJORS[curRealm].lifespan const lifespan = MAJORS[curRealm].lifespan
if (w.state.month === 1) { 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]}`) 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(邻近的讲合、世仇的愈深) // 关系漂移:年首各 ±5(邻近的讲合、世仇的愈深)
for (const oid of ids) { for (const oid of ids) {
if (oid === id) continue 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 winner = w.rng.chance(wSum > 0 ? npc.power / wSum : 0.5) ? npc : foe
const loser = winner === npc ? foe : npc const loser = winner === npc ? foe : npc
winner.power = Math.min(900, Math.round(winner.power * 1.06)) 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)) { for (const [oid, otherDyn] of Object.entries(s.npcDyn)) {
if (oid === loser.id) continue if (oid === loser.id) continue
@@ -462,9 +545,67 @@ function evolveNpc(w: World, s: WorldSimState, noise: number): void {
} }
} }
const STANCE_NAME: Record<NpcStance, string> = { 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<NpcStance, string> = { guardian: '守成', expand: '扩张', endure: '隐忍', ally: '结盟' }
function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance { function assessStance(w: World, id: string, dyn: NpcDynamics): NpcStance {
const npc = w.state.npcFamilies[id] const npc = w.state.npcFamilies[id]
const era = ((w.state.worldSim as WorldSimState)?.era ?? 'pingshi') as string 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 { function initDynFor(id: string): NpcDynamics {
const def = npcById(id) const def = npcById(id)
if (!def) return { prosperity: 50, stance: 'guardian', stanceSinceYear: 1, leaderName: '新贵', leaderRealmIdx: 2, leaderAge: 35, lastEvent: '', lastEventYear: -99, relationsWithOthers: {} }
return { return {
prosperity: 50, prosperity: 50,
stance: 'guardian', stance: 'guardian',
stanceSinceYear: 1, stanceSinceYear: 1,
leaderName: `${def.name.replace('氏', '')}氏宗主`, leaderName: `${def?.name.replace('氏', '') ?? '新宗'}氏宗主`,
leaderRealmIdx: MAJOR_ORDER.indexOf(def.leaderRealm), leaderRealmIdx: MAJOR_ORDER.indexOf(def?.leaderRealm ?? 'qi'),
leaderAge: 45, leaderAge: 45,
lastEvent: '', lastEvent: '',
lastEventYear: -99, lastEventYear: -99,
@@ -509,6 +651,7 @@ function pushNews(w: World, s: WorldSimState, about: string[]): void {
if (id.startsWith('n-')) { if (id.startsWith('n-')) {
const def = npcById(id) const def = npcById(id)
const dyn = s.npcDyn[id] const dyn = s.npcDyn[id]
if (!def) return
row = { row = {
year: w.state.year, year: w.state.year,
month: w.state.month, month: w.state.month,
@@ -6,6 +6,8 @@ export interface NpcDynamics {
/** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */ /** 战略姿态:守成/扩张/隐忍/结盟(年首评估) */
stance: NpcStance stance: NpcStance
stanceSinceYear: number stanceSinceYear: number
/** 衰微连续年数(power<45 起计) */
declineYears?: number
/** 宗主姓名快照(换代时更新) */ /** 宗主姓名快照(换代时更新) */
leaderName: string leaderName: string
leaderRealmIdx: number leaderRealmIdx: number
@@ -132,6 +134,8 @@ export const WORLDSIM = {
calamityMonths: 6, // 灾年效果持续月数(结束月报“灾云散尽”) calamityMonths: 6, // 灾年效果持续月数(结束月报“灾云散尽”)
// —— 潮汐/秘境 —— // —— 潮汐/秘境 ——
tideCycle: 72, // 月周期(6年) tideCycle: 72, // 月周期(6年)
longWave: 120, // 长波周期(10年)——灵潮三十年河东
longWaveSpan: 0.08, // 长波振幅(±0.08 叠加)
tideMin: 0.65, tideMin: 0.65,
tideMax: 1.35, tideMax: 1.35,
secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi secretRecover: 2, // 灵气月恢复(潮高 ×2.5 → 用 secretRecoverHi
@@ -139,6 +143,8 @@ export const WORLDSIM = {
secretConsume: 6, // 每次探索消耗(missions 默认) secretConsume: 6, // 每次探索消耗(missions 默认)
// —— era 景气闭环 —— // —— era 景气闭环 ——
tempEraK: 0.8, // 世界温度对 era 转移权重的调制强度(±0.8/单位温差) tempEraK: 0.8, // 世界温度对 era 转移权重的调制强度(±0.8/单位温差)
// —— 新贵补位 ——
greatNewbornChance: 0.06, // 年首新贵出现率(乱世 ×2;上限 4 家)
// —— 盟约连坐(加性) —— // —— 盟约连坐(加性) ——
allianceGriefRaid: 0.012, // кажд 世仇盟连加性 raid 概率 allianceGriefRaid: 0.012, // кажд 世仇盟连加性 raid 概率
// —— 天下事件 —— // —— 天下事件 ——
+4
View File
@@ -90,6 +90,8 @@ export interface NpcFamilyState {
alliedSinceYear?: number alliedSinceYear?: number
warCooldownYear?: number warCooldownYear?: number
raidCount: number raidCount: number
/** 衰微连续年数(power<45 起计;达 8 年覆灭/附庸) */
declineYears: number
} }
export interface MissionState { export interface MissionState {
@@ -183,6 +185,8 @@ export interface GameState {
finance: { accum: number } finance: { accum: number }
yearStats: { births: number; deaths: number } yearStats: { births: number; deaths: number }
yearlyReports: YearlyReport[] yearlyReports: YearlyReport[]
/** 已装插件(id/version/enabled)——存档持久化,加载时按注册表重装 */
plugins?: Array<{ id: string; version: string; enabled: boolean }>
stats: FamilyStats stats: FamilyStats
worldSim?: { worldSim?: {
marketPool?: Record<string, number> marketPool?: Record<string, number>
@@ -25,6 +25,7 @@ export default function DiplomacyPanel() {
</div> </div>
{sorted.map((npc) => { {sorted.map((npc) => {
const def = npcById(npc.id) const def = npcById(npc.id)
if (!def) return null
const [relLabel, relCls] = REL_TYPE(npc.relation) const [relLabel, relCls] = REL_TYPE(npc.relation)
const married = npc.allied const married = npc.allied
return ( return (
+24 -10
View File
@@ -55,15 +55,29 @@ export default function SettingsPanel() {
{p.protected ? '核心' : p.enabled ? '已启用' : '已停用'} {p.protected ? '核心' : p.enabled ? '已启用' : '已停用'}
</span> </span>
{!p.protected && ( {!p.protected && (
<button <>
className="btn btn-sm btn-danger" <button
onClick={() => { className={`btn btn-sm ${p.enabled ? '' : 'btn-primary'}`}
world.removePlugin(p.id) onClick={() => {
bump() const r = world.setPluginEnabled(p.id, !p.enabled)
}} if (!r.ok) setNextToast(`启停失败:${r.reason ?? ''}`)
> bump()
}}
</button> >
{p.enabled ? '停用' : '启用'}
</button>
<button
className="btn btn-sm btn-danger"
title={p.dependencies?.length ? `依赖:${p.dependencies.join('、')}` : ''}
onClick={() => {
world.removePlugin(p.id)
setNextToast('插件已卸载(钩子/池/能力卡全摘)。')
bump()
}}
>
</button>
</>
)} )}
</div> </div>
</div> </div>
@@ -210,7 +224,7 @@ export default function SettingsPanel() {
· <br /> · <br />
· ·
</div> </div>
<div className="dim2" style={{ marginTop: 8 }}> 0.1.22 · Chronicle of the Immortal Clan</div> <div className="dim2" style={{ marginTop: 8 }}> 0.1.23 · Chronicle of the Immortal Clan</div>
</div> </div>
</div> </div>
) )
+1
View File
@@ -166,6 +166,7 @@ export default function WorldPanel() {
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}> <div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{Object.values(w.state.npcFamilies).map((npc) => { {Object.values(w.state.npcFamilies).map((npc) => {
const def = npcById(npc.id) const def = npcById(npc.id)
if (!def) return null
const dyn = (ws.npcDyn ?? {})[npc.id] const dyn = (ws.npcDyn ?? {})[npc.id]
const open = view === npc.id const open = view === npc.id
return ( return (
+2 -2
View File
@@ -82,7 +82,7 @@ describe('审计回归:P0 修复固化', () => {
}) })
it('防御性修补后金钟罩不变(行为等价确认)', () => { it('防御性修补后金钟罩不变(行为等价确认)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('879a909d') expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('855600f5')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('e6936427') expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('2ecc7add')
}) })
}) })
+8 -8
View File
@@ -7,20 +7,20 @@ import { World } from '../src/renderer/game/engine/runtime/World'
* 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。
* 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。
*/ */
// 0.1.21 大势流转基线(指纹含全世界轴+worldAnnals): // 0.1.23 生灭千秋基线(指纹含全世界轴+worldAnnals+家族生灭):
// era↔景气双向闭环(世界温度调制转移)/region 灵潮异质/灾年群雄相噬/史表独立留档后固化。 // 衰亡新贵/秘境回池/潮汐长波/插件生态化(持久化+双闸)后固化。
const GOLDEN: Record<string, Record<number, string>> = { const GOLDEN: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: '879a909d', 1200: 'fa07c9e7', 2160: '5a64b3e9' }, 'bell-seed-1': { 560: '855600f5', 1200: 'e131b7d5', 2160: '7917ba3c' },
'bell-seed-2': { 560: '33d511bc', 1200: '71930809', 2160: '4e67314c' }, 'bell-seed-2': { 560: '0184ccc8', 1200: 'ff36b9f7', 2160: '5fb6e896' },
'bell-seed-3': { 560: 'e6936427', 1200: '56961a5e', 2160: 'bb972ccd' } 'bell-seed-3': { 560: '2ecc7add', 1200: '62cfabdc', 2160: '4e0654ff' }
} }
/** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件; /** 第二金钟罩:自动 resolve 长跑("现实"世界——每 tick 处理待决事件;
* 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */ * 锁事件闸/事件流全程,防"冻结世界"指纹漏锁)。 */
const GOLDEN_RESOLVED: Record<string, Record<number, string>> = { const GOLDEN_RESOLVED: Record<string, Record<number, string>> = {
'bell-seed-1': { 560: 'cbc13720', 1200: '0b4c6d57', 2160: 'c9de8ab5' }, 'bell-seed-1': { 560: 'c17a9f82', 1200: 'cdcbbce9', 2160: '9abdd687' },
'bell-seed-2': { 560: 'e0706d1e', 1200: 'd3899339', 2160: '9c7ef509' }, 'bell-seed-2': { 560: '800674c1', 1200: 'c1ac7282', 2160: '4826c188' },
'bell-seed-3': { 560: 'fe429293', 1200: '79cbbca9', 2160: 'ada0a493' } 'bell-seed-3': { 560: '2c2ac417', 1200: 'ef9c3e4a', 2160: '2980499a' }
} }
const TIERS = [ const TIERS = [
+3 -2
View File
@@ -253,8 +253,9 @@ describe('npcs 势力表', () => {
expect(n.powerGrowth[1]).toBeGreaterThanOrEqual(n.powerGrowth[0]) expect(n.powerGrowth[1]).toBeGreaterThanOrEqual(n.powerGrowth[0])
expect(MAJOR_ORDER).toContain(n.leaderRealm) expect(MAJOR_ORDER).toContain(n.leaderRealm)
} }
expect(npcById('n-nulei').name).toBe('怒雷祝氏') // 0.1.23npcById 双源返回 undefined(不抛)——缺失防御化
expect(() => npcById('nope')).toThrow() expect(npcById('n-nulei')?.name).toBe('怒雷祝氏')
expect(npcById('nope')).toBeUndefined()
}) })
}) })
+100
View File
@@ -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)
})
})
+2 -2
View File
@@ -170,7 +170,7 @@ describe('GameFacade 门面', () => {
it('默认配置金钟罩不受门面化影响', () => { it('默认配置金钟罩不受门面化影响', () => {
PACK.reset() PACK.reset()
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('879a909d') expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('855600f5')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('e6936427') expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('2ecc7add')
}) })
}) })
+11 -41
View File
@@ -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 }
/** 示例内容插件:注入事件池 + 一个护山能力(开发范本 */ /** 依赖/冲突测试用“坏插件”(依赖不存在的 core-ghost */
export const examplePlugin: CotycPlugin = { export const brokenPlugin: import('../src/renderer/game/engine/kernel/plugin').CotycPlugin = {
id: 'demo-peaks', id: 'broken-ghost',
name: '护山妖兽', name: '幽灵依赖',
version: '0.1.0', version: '0.0.0',
author: 'demo', description: 'testing',
description: '示例插件:山鬼妖气事件与护山之力(+2% 战力)。',
kind: 'content', kind: 'content',
install(ctx) { dependencies: ['core-ghost'],
ctx.addCapability({ id: 'demo-guardian', name: '护山之力', version: '0.1.0', desc: '示例:年首山灵庇佑,全族修为小幅精进。' }) install() { return undefined }
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() {}
}
+8 -4
View File
@@ -79,10 +79,14 @@ describe('PluginCore 插件协议', () => {
expect(JSON.stringify(a.pluginList())).toBe(JSON.stringify(b.pluginList())) expect(JSON.stringify(a.pluginList())).toBe(JSON.stringify(b.pluginList()))
}) })
it('默认管线金钟罩不受插件层影响', () => { it('默认管线金钟罩不受插件层影响(装→卸往返指纹复原)', () => {
expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('879a909d') // 0.1.23:示例插件安装后能力卡默认启用(会移动 rng)——改为验证“卸载后恢复干净基线”
expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('33d511bc') const a = longRun('bell-seed-1')
expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('e6936427') 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', () => { it('facade 插件查询与 about.plugins', () => {