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

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

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

测试 239→256;金钟罩复验(防御批零漂移)
This commit is contained in:
2026-08-23 09:58:15 +08:00
parent 9e0b06e898
commit c09395a6d4
39 changed files with 932 additions and 157 deletions
+9 -4
View File
@@ -66,7 +66,7 @@ export const ACT_CATALOG: Record<ActName, { desc: string; fn: (w: World, p: ActP
'market.buy': { desc: '购货', fn: (w, p) => (p.item ? buyItem(w, p.item, p.count ?? 1) : false) },
'market.sell': { desc: '售货', fn: (w, p) => (p.item ? sellItem(w, p.item, p.count ?? 1) : false) },
'market.tech': { desc: '购法帖', fn: (w, p) => (p.tech ? buyTechnique(w, p.tech, p.stones ?? 0) : false) },
'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : w.craftPill('qiyuan')) },
'craft.pill': { desc: '炼丹', fn: (w, p) => (p.item === 'ningyuan' ? w.craftPill('ningyuan') : Boolean(p.item === 'qiyuan') && w.craftPill('qiyuan')) },
'diplomacy.gift': { desc: '赠礼', fn: (w, p) => (p.npcId ? giftNpc(w, p.npcId, p.stones ?? 0) : false) },
'diplomacy.peace': { desc: '议和', fn: (w, p) => (p.npcId ? makePeace(w, p.npcId) : false) },
'diplomacy.taunt': { desc: '寻衅', fn: (w, p) => (p.npcId ? w.tauntNpc(p.npcId) : false) },
@@ -108,6 +108,8 @@ export class GameFacade {
return { list: w.systemList() }
case 'finance':
return { stones: w.state.family.stones, accum: w.state.finance.accum, yearStats: w.state.yearStats }
case 'plugins':
return { list: w.pluginList() }
default:
return {}
}
@@ -121,7 +123,8 @@ export class GameFacade {
onPendingEvent: (id: string) => on({ type: 'pending', id }),
onGameOver: (reason: string) => on({ type: 'gameover', reason }),
onYearPaper: (report: YearlyReport) => on({ type: 'paper', report }),
onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled })
onSystemChange: (id: string, enabled: boolean) => on({ type: 'sysChanged', id, enabled }),
onPluginChange: (id: string, action: string) => on({ type: 'plugin', id, action })
}
this.world.out.push(bus)
return () => {
@@ -130,12 +133,13 @@ export class GameFacade {
}
}
about(): { title: string; version: string; modules: number; systems: number; packFingerprint: string } {
about(): { title: string; version: string; modules: number; systems: number; plugins: number; packFingerprint: string } {
return {
title: '仙途家族志',
version: '0.1.7',
version: '0.1.8',
modules: this.world.systemList().length,
systems: this.world.systemList().filter((s) => s.enabled).length,
plugins: this.world.pluginList().length,
packFingerprint: PACK.fingerprint()
}
}
@@ -149,6 +153,7 @@ export type FacadeEvent =
| { type: 'gameover'; reason: string }
| { type: 'paper'; report: YearlyReport }
| { type: 'sysChanged'; id: string; enabled: boolean }
| { type: 'plugin'; id: string; action: string }
export { marketPrice, computeLegacy }
export type { SaveMeta, LegacyArch }
+49 -22
View File
@@ -1,4 +1,6 @@
import { GameClock } from '../core/clock'
import { PluginContext } from '../core/plugin'
import type { World } from './world'
import { productionTick } from './systems/production'
import { deathTick, woundHealTick } from './systems/lifecycle'
import { cultivationTick } from './systems/cultivation'
@@ -6,7 +8,6 @@ import { missionTick } from './systems/missions'
import { eventRoll } from './systems/events'
import { diplomacyTick } from './systems/diplomacy'
import { yearStartMarriage } from './systems/marriage'
import type { World } from './world'
/** 原语义保留:满门凋零后仅存生产/寿元与收尾 */
function ifAlive(fn: (w: World) => void): (w: World) => void {
@@ -22,33 +23,59 @@ function viaCap(capId: string, fn: (w: World) => void): (w: World) => void {
}
}
export function emptyClock(): GameClock {
return new GameClock()
}
/**
* 装备时钟:年度钩子与月度 phase 的注册顺序即执行顺序(确定性)。
* 内建系统注册(core-systems 插件入口)。
* 注册顺序 = 执行顺序(确定性红线,经由金钟罩校验)。
* 时间线:婚配养育 → 声望岁贡 → 岁末族簿 —— 再逐月:生产→寿元→修炼→任务→事件→外交→收尾
*/
export function buildClock(): GameClock {
const clock = new GameClock()
export function installCoreSystems(ctx: PluginContext): Array<() => void> {
const unsubs: Array<() => void> = []
const clock = ctx.clock
clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w)))
clock.onYearStart(
viaCap('marriage', (w) => {
w.state.family.reputation += w.postBonus('familyRep')
const zhenCount = w
.aliveMembers()
.filter((c) => c.aspiration === 'zhen').length
w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
})
unsubs.push(clock.onYearStart(viaCap('marriage', (w) => yearStartMarriage(w))))
unsubs.push(
clock.onYearStart(
viaCap('marriage', (w) => {
w.state.family.reputation += w.postBonus('familyRep')
const zhenCount = w.aliveMembers().filter((c) => c.aspiration === 'zhen').length
w.state.family.reputation += Math.round(zhenCount * 0.3 * 100) / 100
})
)
)
clock.onYearStart(viaCap('annals', (w) => w.publishYearReport()))
unsubs.push(clock.onYearStart(viaCap('annals', (w) => w.publishYearReport())))
clock.register('production', viaCap('production', (w: World) => productionTick(w)))
clock.register('aging', viaCap('aging', (w: World) => deathTick(w)))
clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w)))
clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w))))
clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w))))
clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w))))
clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w))))
clock.register('epilogue', (w: World) => w.epilogueTick())
unsubs.push(clock.register('production', viaCap('production', (w: World) => productionTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => deathTick(w))))
unsubs.push(clock.register('aging', viaCap('aging', (w: World) => woundHealTick(w))))
unsubs.push(clock.register('cultivation', viaCap('cultivation', ifAlive((w: World) => cultivationTick(w)))))
unsubs.push(clock.register('missions', viaCap('missions', ifAlive((w: World) => missionTick(w)))))
unsubs.push(clock.register('events', viaCap('events', ifAlive((w: World) => eventRoll(w)))))
unsubs.push(clock.register('diplomacy', viaCap('diplomacy', ifAlive((w: World) => diplomacyTick(w)))))
unsubs.push(clock.register('epilogue', (w: World) => w.epilogueTick()))
return unsubs
}
/** 兼容旧接口:完整安装一套核心系统(测试/工具用) */
export function buildClock(): GameClock {
const clock = emptyClock()
const ctx = {
world: undefined as never,
clock,
register: (phase: Parameters<GameClock['register']>[0], fn: (w: World) => void) => clock.register(phase, fn),
onYearStart: (fn: (w: World) => void) => clock.onYearStart(fn),
addCapability: () => undefined,
removeCapability: () => undefined,
enableCapability: () => undefined,
overridePack: () => undefined,
resetPack: () => undefined,
addEventPool: () => undefined,
removeEventPool: () => undefined
}
installCoreSystems(ctx as never)
return clock
}
+13 -5
View File
@@ -1,16 +1,23 @@
import type { World } from './world'
import { pack } from '../data/registry'
import { traitBonuses } from './pcgen'
export function marketPrice(w: World, itemId: string): number {
const base = pack().items[itemId]?.basePrice ?? 1
const item = pack().items[itemId]
if (!item) return 0
const base = item.basePrice
const fam = w.state.family
const mult = typeof fam.flag['priceMult'] === 'number' ? (fam.flag['priceMult'] as number) : 1
const mood = fam.reputation >= 40 ? 1.06 : fam.reputation >= 20 ? 1.02 : 0.98
return Math.max(1, Math.round(base * mult * mood))
// 利己(priceMult)与信誉修正
const sellers = w.aliveMembers().filter((c) => traitBonuses(c).priceMult > 0).length
const liarPct = Math.min(0.2, sellers * 0.04)
return Math.max(1, Math.round(base * mult * mood * (1 - liarPct)))
}
export function buyItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
if (!pack().items[itemId]) return false
const total = marketPrice(w, itemId) * count
if (total > fam.stones) return false
fam.stones -= total
@@ -20,6 +27,7 @@ export function buyItem(w: World, itemId: string, count: number): boolean {
export function sellItem(w: World, itemId: string, count: number): boolean {
const fam = w.state.family
if (!pack().items[itemId]) return false
const have = fam.inventory[itemId] ?? 0
if (have < count) return false
fam.inventory[itemId] = have - count
@@ -37,12 +45,12 @@ export function buyTechnique(w: World, techId: string, price: number): boolean {
}
export function techniquePrice(techId: string): number {
const grade = TECH_GRADE_BASE[techId] ?? 200
return grade
return TECH_GRADE_BASE[techId] ?? 200
}
import { TECHNIQUES } from '../data/techniques'
const TECHNIQUE_GRADE_PRICE: Record<number, number> = { 1: 120, 2: 300, 3: 700, 4: 1600 }
const TECH_GRADE_BASE: Record<string, number> = Object.fromEntries(
TECHNIQUES.map((t) => [t.id, [120, 300, 700, 1600, 3600][t.grade] ?? 300])
TECHNIQUES.map((t) => [t.id, TECHNIQUE_GRADE_PRICE[t.grade] ?? 300])
)
+2 -3
View File
@@ -27,9 +27,8 @@ export function rollRoots(rng: Rng, parents?: { m?: Character; f?: Character }):
}
grade = Math.max(0, Math.min(5, grade))
const primaryCandidate = parents && (parents.m || parents.f)
? [parents.m!.roots.primary, parents.f!.roots.primary]
: ELEMENT_LIST
const single = parents ? (parents.m ?? parents.f) : undefined
const primaryCandidate = single ? [single.roots.primary] : ELEMENT_LIST
const primary = rng.pick(primaryCandidate)
const secondaryCount = grade >= 3 ? rng.int(1, 2) : grade === 2 ? rng.int(0, 1) : 0
const rest = ELEMENT_LIST.filter((e) => e !== primary)
@@ -0,0 +1,64 @@
import { CotycPlugin } from '../core/plugin'
import { installCoreSystems } from './clocks'
import { EVENTS } from '../data/events'
import { DEFAULT_PACK } from '../data/registry'
/** 内置插件一:镇族基石(12 能力卡与时轮钩子) */
export function makeCoreSystemsPlugin(): CotycPlugin {
let unsubs: Array<() => void> = []
return {
id: 'core-systems',
name: '镇族基石',
version: '0.1.8',
kind: 'system',
protected: true,
description: '十二能力卡与时轮钩子:生产/寿元/修炼/探秘/事件/外交/婚育/岁簿/大比/渡劫/寄读/时节。',
install(ctx) {
unsubs = installCoreSystems(ctx)
},
uninstall(ctx) {
void ctx
unsubs.forEach((u) => u())
unsubs = []
}
}
}
/** 内置插件二:经典数据包(默认平衡表) */
export function makeCoreDataPlugin(): CotycPlugin {
return {
id: 'core-data',
name: '经典数据包',
version: '0.1.8',
kind: 'data',
protected: true,
description: '平衡表默认包:境界/物品/功法/建筑/秘境/势力/性格/职事。',
install(ctx) {
ctx.resetPack()
},
uninstall() {
void DEFAULT_PACK
}
}
}
/** 内置插件三:内建事件池(日常/家国/际遇) */
export function makeCoreEventsPlugin(): CotycPlugin {
return {
id: 'core-events',
name: '内建事件池',
version: '0.1.8',
kind: 'events',
protected: true,
description: '命运事件主池:日常/重大/乾坤三层选支事件。',
install(ctx) {
ctx.addEventPool('core', EVENTS)
}
}
}
export const CORE_PLUGINS: CotycPlugin[] = [
makeCoreSystemsPlugin(),
makeCoreDataPlugin(),
makeCoreEventsPlugin()
]
+131
View File
@@ -0,0 +1,131 @@
import { CotycPlugin, PluginContext, PluginStatus, PluginChange } from '../core/plugin'
import { SystemDef, SYSTEM_DEFS } from '../engine/capabilities'
import type { World } from '../engine/world'
import { SystemHook } from '../core/clock'
import { GameClock } from '../core/clock'
interface RuntimePlugin {
plugin: CotycPlugin
status: { installed: boolean; enabled: boolean }
unsubscribers: Array<() => void>
}
/**
* 插件管理器:统一安装/卸载/启停管线。
* 安装序即确定性序;依赖缺失或冲突 → 拒绝安装。
*/
export class PluginManager {
private runtime = new Map<string, RuntimePlugin>()
private order: string[] = []
private changes: PluginChange[] = []
constructor(private ctx: PluginContext) {}
private onChange(c: PluginChange): void {
this.changes.push(c)
}
listChanges(): PluginChange[] {
return this.changes.slice()
}
clearChanges(): void {
this.changes = []
}
install(plugin: CotycPlugin): { ok: boolean; reason?: string } {
if (this.runtime.has(plugin.id)) return { ok: false, reason: `插件已存在:${plugin.id}` }
for (const dep of plugin.dependencies ?? []) {
if (!this.runtime.has(dep)) return { ok: false, reason: `缺少依赖:${dep}` }
}
for (const c of plugin.conflicts ?? []) {
if (this.runtime.has(c)) return { ok: false, reason: `${c} 冲突` }
}
for (const def of SYSTEM_DEFS) {
if (def.id === plugin.id) return { ok: false, reason: `id 冲突:${plugin.id}` }
}
const rt: RuntimePlugin = { plugin, status: { installed: true, enabled: true }, unsubscribers: [] }
this.runtime.set(plugin.id, rt)
this.order.push(plugin.id)
// 追踪型上下文:插件在时轮上的注册(phase/年首)一律归属该插件,卸载时全摘
const tracked = new Proxy(this.ctx, {
get(target, key) {
const prop = key as keyof PluginContext
if (prop === 'register' || prop === 'onYearStart') {
return (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => {
const unsub = (target[prop] as (phase: Parameters<GameClock['register']>[0], fn: SystemHook) => () => void)(phase, fn)
rt.unsubscribers.push(unsub)
return unsub
}
}
return (target as unknown as Record<string, unknown>)[key as string]
}
})
try {
plugin.install(tracked as PluginContext)
plugin.hooks?.onLoad?.()
} catch (e) {
rt.unsubscribers.forEach((u) => u())
this.runtime.delete(plugin.id)
const i = this.order.indexOf(plugin.id)
if (i >= 0) this.order.splice(i, 1)
return { ok: false, reason: `安装异常:${String(e)}` }
}
this.onChange({ id: plugin.id, action: 'install' })
return { ok: true }
}
remove(pluginId: string): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (rt.plugin.protected) return { ok: false, reason: '核心插件受保护' }
rt.unsubscribers.forEach((u) => u())
rt.plugin.uninstall?.(this.ctx)
this.runtime.delete(pluginId)
const i = this.order.indexOf(pluginId)
if (i >= 0) this.order.splice(i, 1)
this.onChange({ id: pluginId, action: 'remove' })
return { ok: true }
}
setEnabled(pluginId: string, enabled: boolean): { ok: boolean; reason?: string } {
const rt = this.runtime.get(pluginId)
if (!rt) return { ok: false, reason: '未安装' }
if (enabled && !rt.plugin.hooks?.onEnable) {
// 无 enable 钩子的插件视为直接切换 enabled 状态
}
rt.status.enabled = enabled
if (enabled) rt.plugin.hooks?.onEnable?.()
else rt.plugin.hooks?.onDisable?.()
this.onChange({ id: pluginId, action: enabled ? 'enable' : 'disable' })
return { ok: true }
}
list(): PluginStatus[] {
return this.order
.filter((id) => this.runtime.has(id))
.map((id) => {
const rt = this.runtime.get(id)!
const p = rt.plugin
return {
id: p.id,
name: p.name,
version: p.version,
kind: p.kind,
installed: rt.status.installed,
enabled: rt.status.enabled,
protected: !!p.protected,
dependencies: p.dependencies
}
})
}
has(id: string): boolean {
return this.runtime.has(id)
}
get(id: string): CotycPlugin | null {
return this.runtime.get(id)?.plugin ?? null
}
}
@@ -27,7 +27,7 @@ export function monthlyRate(w: World, c: Character): number {
const buildings = st.family.buildings
const juling = buildings['juling'] ?? 0
rate *= 1 + juling * 0.05
rate *= 1 + seasonMod(st.month, 'cult')
if (w.sysEnabled('season')) rate *= 1 + seasonMod(st.month, 'cult')
rate *= 1 + w.postBonus('expAll')
if (st.family.flag['fengFeiBless']) rate *= 1.05
if (c.traits.includes('fengxian')) rate *= 1.3
@@ -36,7 +36,7 @@ export function monthlyRate(w: World, c: Character): number {
if (fitBonusOf(c).cult) rate *= 1.06
if (c.state === 'meditation') {
rate *= 1.35
rate *= 1 + w.postBonus('meditation') + seasonMod(st.month, 'meditation')
rate *= 1 + w.postBonus('meditation') + (w.sysEnabled('season') ? seasonMod(st.month, 'meditation') : 0)
const dongfu = buildings['dongfu'] ?? 0
rate *= 1 + dongfu * 0.08
} else if (c.state === 'expedition') {
@@ -49,6 +49,7 @@ export function monthlyRate(w: World, c: Character): number {
if (c.health <= 40) rate *= 0.45
else if (c.health <= 70) rate *= 0.8
}
rate *= traitBonuses(c).exp
if (w.ageOf(c) < 8) rate *= 0.4
if (w.ageOf(c) > 55) rate *= 0.7
rate *= masteryRateOfMajor(c.realm.major)
@@ -89,7 +90,7 @@ export function cultivationTick(w: World): void {
if (c.realmProgress >= 100) {
const months = (w.state.year * 12 + w.state.month) - (c.lastBreakthroughAttempt ?? -999)
if (months >= 6 && w.rng.chance(perAttemptChance(w, c))) {
if (needsTribulation(c)) {
if (needsTribulation(c) && w.sysEnabled('tribulation')) {
// 大境界晋升改走渡劫事件(玩家三选);压制者待来年
if (!c.tribDelayYear || w.state.year >= c.tribDelayYear) {
c.tribDelayYear = undefined
+20 -10
View File
@@ -15,7 +15,13 @@ import { nextRealm, describeRealm } from '../../data/realms'
const ALL_EVENTS: EventDef[] = [...EVENTS]
export function findEvent(id: string, world?: World): EventDef | undefined {
return ALL_EVENTS.find((e) => e.id === id) ?? dynamicEventFor(id, world)
const found = ALL_EVENTS.find((e) => e.id === id)
if (found) return found
if (world) {
const pooled = world.allEvents().find((e) => e.id === id)
if (pooled) return pooled
}
return dynamicEventFor(id, world)
}
export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
@@ -57,7 +63,7 @@ export function dynamicEventFor(id: string, w?: World): EventDef | undefined {
name: '太虚大比',
category: 'major',
weight: 0,
text: `太虚仙盟年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
text: `太虚仙盟年一会,新一期大比于祖庭开擂。观礼之众云集,百族竞锋——可遣嫡系赴赛,亦可称病让席。`,
options: [
{ label: '点将赴赛', hint: '战三关,位次定声望', eff: { tournament: true, flag: { [`tournamentYear-${year}`]: true } } },
{ label: '献礼买名', hint: '灵石-200,名次垫底,各族好感略升', eff: { res: { stones: -200 }, relation: { 'n-xuanying': 5, 'n-danxin': 5, 'n-sihai': 5, 'n-nulei': 5 } } },
@@ -243,7 +249,7 @@ export function eventRoll(w: World): void {
return
}
const firstSpirit = s.flags['firstSpirit'] as number | undefined
if (firstSpirit && s.year - firstSpirit >= 2 && s.year - firstSpirit <= 3 && !s.completedEvents.includes('ev-feisheng')) {
if (firstSpirit && s.year - firstSpirit >= 2 && !s.completedEvents.includes('ev-feisheng')) {
fire(w, 'ev-feisheng')
return
}
@@ -256,14 +262,16 @@ export function eventRoll(w: World): void {
fire(w, `ev-tournament-${tourneyNext}`)
return
}
// 四邻回声(每两年左右一桩
// 四邻回声(偶数年一掷,不论成败封缄
if (s.year % 2 === 0) {
const key = `echoDone-${s.year}`
if (!s.family.flag[key] && w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
if (!s.family.flag[key]) {
s.family.flag[key] = true
fire(w, `ev-echo-${npc.id}-${s.year}`)
return
if (w.rng.chance(0.7)) {
const npc = w.rng.pick(Object.values(s.npcFamilies))
fire(w, `ev-echo-${npc.id}-${s.year}`)
return
}
}
}
@@ -271,7 +279,8 @@ export function eventRoll(w: World): void {
const roll = w.rng.next()
const category: 'daily' | 'major' | 'fate' | undefined = roll < 0.5 ? 'daily' : roll < 0.78 ? 'major' : roll < 0.86 ? 'fate' : undefined
if (!category) return
const candidates = ALL_EVENTS.filter(
const pool = w.allEvents()
const candidates = pool.filter(
(e) =>
e.category === category &&
!(e.once && s.completedEvents.includes(e.id)) &&
@@ -290,6 +299,7 @@ export function eventRoll(w: World): void {
}
export function fire(w: World, id: string): void {
if (w.state.pendingEvent) return
w.state.pendingEvent = id
w.pendingEvent(id)
}
@@ -394,7 +404,7 @@ export function applyEffect(w: World, eff: EffectDef, squad?: string[]): void {
if (!fam.techniques.includes(eff.addTech)) fam.techniques.push(eff.addTech)
}
if (eff.tournament) {
runTournament(w)
runTournament(w, squad)
}
if (eff.apprentice?.build) {
buildApprentice(w)
+2 -2
View File
@@ -111,8 +111,8 @@ function resultText(m: MissionState): string {
export function canSendMission(w: World, def: MissionDef, members: string[]): boolean {
if (members.length < def.minMembers || members.length > def.maxMembers) return false
for (const id of members) {
const c = w.memberById(id)
if (!c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
const c = w.state.members[id]
if (!c || !c.alive || c.state === 'expedition' || c.state === 'apprentice') return false
}
return w.state.missions.filter((m) => !m.done).length < 3
}
@@ -16,7 +16,7 @@ export function productionTick(w: World): void {
const fielders = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'field').length
const merchants = w.aliveMembers().filter((c) => aspirationById(c.aspiration)?.effect.type === 'market').length
const springMod = seasonMod(w.state.month, 'field')
const springMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'field') : 0
if (lingtian > 0) {
const v = Math.round(10 * lingtian * (1 + fielders * 0.05 + springMod))
inv.lingcao = (inv.lingcao ?? 0) + v
@@ -36,7 +36,7 @@ export function productionTick(w: World): void {
inv.lingkuang = (inv.lingkuang ?? 0) + v
parts.push(`灵矿+${v}灵矿`)
}
const autumnMod = seasonMod(w.state.month, 'market')
const autumnMod = w.sysEnabled('season') ? seasonMod(w.state.month, 'market') : 0
if (fangshi > 0) {
const v = Math.round(55 * fangshi * (1 + w.postBonus('marketIncome') + merchants * 0.03 + autumnMod))
fam.stones += v
+12 -2
View File
@@ -20,6 +20,10 @@ export function gateEnemy(idx: number, year: number): EnemyDef {
}
export function runTournament(w: World, squad?: string[]): void {
if (!w.sysEnabled('tournament')) {
w.log('info', '太虚大比:赛事未启。')
return
}
const s = w.state
const eligible = w
.aliveMembers()
@@ -83,12 +87,16 @@ export function apprenticeCandidates(w: World): Character[] {
}
export function buildApprentice(w: World): void {
if (!w.sysEnabled('apprentice')) {
w.log('bad', '宗门来使失望而归:族中暂拒通学。')
return
}
const cand = apprenticeCandidates(w)
const c = w.rng.pick(cand)
if (!c) {
if (cand.length === 0) {
w.log('bad', '宗门来使失望而归:族中无适龄儿郎。')
return
}
const c = w.rng.pick(cand)
const sect = w.rng.pick(SECTS)
const years = w.rng.int(2, 4)
c.state = 'apprentice'
@@ -125,6 +133,7 @@ export function finalizeApprentice(w: World, memberId: string, mode: 'return' |
}
export function checkApprenticeExpiry(w: World): void {
if (!w.sysEnabled('apprentice')) return
for (const c of Object.values(w.state.members)) {
if (!c.alive || !c.apprentice || c.apprentice.quiet) continue
if (w.state.year >= c.apprentice.untilYear) {
@@ -138,6 +147,7 @@ export function checkApprenticeExpiry(w: World): void {
}
export function recruitCheck(w: World): void {
if (!w.sysEnabled('apprentice')) return
const s = w.state
if (s.year % 4 !== 0) return
const key = `recruitDone-${s.year}`
@@ -21,7 +21,6 @@ export function resolveTribulation(w: World, c: Character, mode: 'rash' | 'guard
if (mode === 'delay') {
c.tribDelayYear = w.state.year + 1
w.state.family.stones = w.state.family.stones
w.log('info', `${c.name} 按兵不动,引而不发,待来年再渡。`)
return 'delayed'
}
+133 -17
View File
@@ -10,15 +10,19 @@ import {
} from '../types/domain'
import { Rng } from '../core/rng'
import { BUILDINGS } from '../data/buildings'
import { pack } from '../data/registry'
import { POSTS } from '../data/posts'
import { aspirationById as aspirationOf } from '../data/aspirations'
import { computeLegacy, resolveLegacy, peakRealmIndex, grandTechniqueCount, LegacyArch } from '../core/legacy'
import { createWorldState, findInheritor } from './creation'
import { buildClock } from './clocks'
import { SYSTEM_DEFS, SystemDef } from './capabilities'
import { emptyClock } from './clocks'
import { CotycPlugin, PluginContext, PluginStatus } from '../core/plugin'
import { PluginManager } from './pluginManager'
import { EventDef } from '../data/events'
import { pack, DEFAULT_PACK, PACK } from '../data/registry'
import { CORE_PLUGINS } from './plugin-bootstrap'
import { GameClock } from '../core/clock'
import { SystemHook } from '../core/clock'
import { resolveBreakthrough } from './systems/cultivation'
import { applyEventChoice } from './systems/events'
import { combatPowerOf } from './systems/combat'
@@ -33,6 +37,7 @@ export interface WorldEventBus {
onGameOver(reason: string, year: number): void
onYearPaper?(entry: YearlyReport): void
onSystemChange?(id: string, enabled: boolean): void
onPluginChange?(id: string, action: string): void
}
export function normalizeGameState(state: GameState): GameState {
@@ -55,6 +60,18 @@ export function normalizeGameState(state: GameState): GameState {
if (!state.battles) state.battles = []
if (!state.eventQueue) state.eventQueue = []
if (!state.completedEvents) state.completedEvents = []
if (!state.missions) state.missions = []
if (!state.flags) state.flags = {}
if (!state.npcFamilies) state.npcFamilies = {}
if (!state.family.flag) state.family.flag = {}
if (!state.family.inventory) state.family.inventory = {}
if (!state.family.buildings) state.family.buildings = {}
if (!state.family.techniques) state.family.techniques = []
if (!state.family.missionIds) state.family.missionIds = []
if (state.family.headId && !state.members[state.family.headId]) {
const firstAlive = Object.values(state.members).find((c) => c.alive)
if (firstAlive) state.family.headId = firstAlive.id
}
for (const c of Object.values(state.members)) {
if (typeof c.techniqueRank !== 'number') c.techniqueRank = 0
if (typeof c.techniqueProgress !== 'number') c.techniqueProgress = 0
@@ -69,14 +86,118 @@ export class World {
out: WorldEventBus[]
clock: GameClock
systems: Record<string, { enabled: boolean }>
plugins: PluginManager
private eventPools = new Map<string, EventDef[]>()
constructor(state: GameState, out: WorldEventBus[] = []) {
normalizeGameState(state)
this.state = state
this.rng = new Rng(state.rng)
this.out = out
this.clock = buildClock()
this.clock = emptyClock()
this.systems = Object.fromEntries(SYSTEM_DEFS.map((d) => [d.id, { enabled: true }]))
this.plugins = new PluginManager(this.buildPluginContext())
this.installCorePlugins()
}
/** 事件池聚合(含动态事件回看) */
buildPluginContext(): PluginContext {
const self = this
return {
world: self,
clock: self.clock,
register: (phase, fn: SystemHook) => self.clock.register(phase, fn),
onYearStart: (fn: SystemHook) => self.clock.onYearStart(fn),
addCapability: (cap) => {
if (!SYSTEM_DEFS.find((d) => d.id === cap.id)) {
SYSTEM_DEFS.push({ id: cap.id, name: cap.name, version: cap.version, desc: cap.desc })
}
self.systems[cap.id] = { enabled: true }
},
removeCapability: (id) => {
delete self.systems[id]
},
enableCapability: (id, enabled) => {
if (self.systems[id]) self.systems[id].enabled = enabled
},
overridePack: (partial) => {
void pack
self.packOverride(partial)
},
resetPack: () => {
self.packReset()
},
addEventPool: (id, events) => {
self.eventPools.set(id, events)
},
removeEventPool: (id) => {
self.eventPools.delete(id)
}
}
}
private packOverride(partial: Partial<import('../data/registry').DataPack>): void {
PACK.override(partial)
}
private packReset(): void {
PACK.reset()
}
installCorePlugins(): void {
// 内置三插件:系统/数据/事件(受保护常驻,统一走管线)
for (const p of CORE_PLUGINS) {
this.plugins.install(p)
}
}
pluginList(): PluginStatus[] {
return this.plugins.list()
}
pluginChanges(): { id: string; action: string }[] {
return this.plugins.listChanges()
}
installPlugin(p: CotycPlugin): { ok: boolean; reason?: string } {
const r = this.plugins.install(p)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(p.id, 'install'))
return r
}
removePlugin(id: string): { ok: boolean; reason?: string } {
const r = this.plugins.remove(id)
if (r.ok) {
this.rebuildEventPoolsAfterRemoval(id)
this.out.forEach((o) => o.onPluginChange?.(id, 'remove'))
}
return r
}
setPluginEnabled(id: string, enabled: boolean): { ok: boolean; reason?: string } {
const r = this.plugins.setEnabled(id, enabled)
if (r.ok) this.out.forEach((o) => o.onPluginChange?.(id, enabled ? 'enable' : 'disable'))
return r
}
allEvents(): EventDef[] {
const list: EventDef[] = []
for (const pool of this.eventPools.values()) {
for (const e of pool) list.push(e)
}
return list
}
eventPoolIds(): string[] {
return [...this.eventPools.keys()]
}
private rebuildEventPoolsAfterRemoval(id: string): void {
void id
// 事件池卸载暂由插件 uninstall 自行处理;此处在 remove 后重置 core 保证可用
if (!this.eventPools.has('core')) this.eventPools.set('core', [])
}
sysEnabled(id: string): boolean {
@@ -525,7 +646,7 @@ export class World {
}
marryTo(aId: Id, bId: Id): boolean {
return arrangeWeddingPublic(this, aId, bId)
return arrangeWeddingBridge(this, aId, bId)
}
static create(opts: { seed: string; surname: string; familyName: string; motto: string; difficulty: 'easy' | 'normal' | 'hard' }): World {
@@ -542,18 +663,13 @@ function w2age(w: World, c: Character): number {
return w.ageOf(c)
}
function widowedOf(w: World, m: Character): boolean {
if (!m.spouseId) return false
const sp = w.state.members[m.spouseId]
return !!sp && !sp.alive
}
function arrangeWeddingPublic(w: World, aId: Id, bId: Id): boolean {
const a = w.memberById(aId)
const b = w.memberById(bId)
if (!a.alive || !b.alive) return false
if (a.spouseId && !widowedOf(w, a)) return false
if (b.spouseId && !widowedOf(w, b)) return false
function arrangeWeddingBridge(w: World, aId: Id, bId: Id): boolean {
const a = w.state.members[aId]
const b = w.state.members[bId]
if (!a || !b || !a.alive || !b.alive) return false
if (w.ageOf(a) < 16 || w.ageOf(b) < 16) return false
if (a.spouseId && !w.isWidowed(a)) return false
if (b.spouseId && !w.isWidowed(b)) return false
if (a.gender === b.gender) return false
if (a.fatherId && a.fatherId === b.fatherId) return false
if (a.motherId && a.motherId === b.motherId) return false