diff --git a/AGENTS.md b/AGENTS.md index e224ebc..fdf5aac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -32,12 +32,20 @@ SMOKE_TEST=1 SMOKE_SHOTS_DIR=/tmp/opencode/shots npx electron ... # 附 - 私有 registry 的 `_auth` token 只在用户级 `~/.npmrc`;项目 `.npmrc` 只保留 registry 映射,不要把 token 写进仓库。 - Linux 无 emoji 字体:**所有图标一律用汉字印章字符**(renderer/ui/styles.css 的 `.s-icon/.res-icon/.bld-icon`),不要引入 emoji。 +## 插件架构(0.1.8 起) + +- **万物皆是插件**:`core/plugin.ts` 协议(Manifest/dependencies/conflicts/install/uninstall);`engine/pluginManager.ts` 安装管线(依赖校验、异常回滚、追踪型上下文——插件注册的时轮钩子卸载时全摘);`engine/plugin-bootstrap.ts` 三枚常驻核心插件(core-systems/core-data/core-events,protected 不可卸)。 +- **事件多池**:`world.eventPools`(core 池受保护不可删)+ `eventRoll` 聚合抽样;第三方插件 `ctx.addEventPool(id, events)` 即注入内容。**注意 findEvent 必须带 world 参数查池**(`events.ts`),否则未知事件直接软锁。 +- **能力卡**:`engine/capabilities.ts` 12 张;时钟注册走 `viaCap`;tournament/tribulation/apprentice/season 四卡各自内部检查 `w.sysEnabled`(无独立钩子,勿直接调)。 +- **门面**:`engine/api.ts` GameFacade(act 24 项/query 6 类/subscribe 退订协议/about);**UI store 的 `facade` 字段必须通过 openState/startNewGame 赋值**,否则 act 走残缺 fallback。 +- **金钟罩**:0.1.8 修复批次后三指纹 9af71ecb / 63cede89 / ebfec4a4(受控变更已在注释记录)。 + ## 架构要点 - `src/renderer/game/`:纯 TS 游戏引擎(无 React/DOM import),可被 vitest 直接测试;`types/domain.ts` 是全量领域类型,改状态结构先看它。 - **统一时轮 `core/clock.ts`**:月度 phase(production/aging/cultivation/missions/events/diplomacy/epilogue)+ 年首钩子均注册于 `engine/clocks.ts`。**新增系统 = 注册一行,禁止手改 `advanceMonth` 本体**。 - **统一随机 `core/rng.ts`**:引擎只经 `World.rng`(含 `nextCount` 审计);UI 播种用 `RngHub.rollSeed()`、音效白噪用 `RngHub.audioNoise01()`,**不要**在逻辑里引入 Math.random()。 -- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed 560 月指纹常驻。任何改动若破坏确定性立即红;**有意变更时序时**三枚指纹一并小重算并在注释注明原因。 +- **金钟罩 `tests/clock.test.ts`**:3 枚固定 seed 560 月指纹常驻(0.1.8 基线:9af71ecb/63cede89/ebfec4a4)。任何改动若破坏确定性立即红;**有意变更时序时**三枚指纹一并小重算并在注释注明原因(0.1.6 加时节、0.1.8 加特征/利己/飞升窗口时曾受控重算)。 - `src/renderer/ui/`:React + zustand(`ui/store.ts`)。World 的 game state 是 mutable,advance 后 `revision++` 触发重渲染;订阅 `revision` 是面板刷新惯例。 - 引擎 = 种子随机数(sfc32,`core/rng.ts`)+ 不可变快照存 `state.rng`,同 seed 全程可重放(tests/world.test.ts 有确定性用例,改任何 tick 顺序都要保证仍然确定性)。 - 建档开头成员 id 硬编码 `x1`~`x5`,tests 依赖。 diff --git a/package.json b/package.json index bde8bb5..0d81ce6 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "chronicle-of-the-immortal-clan", "productName": "仙途家族志", - "version": "0.1.7", + "version": "0.1.8", "description": "修仙 · 家族 · 经营 · 战斗 模拟器", "main": "./out/main/index.js", "author": "MetonaTeam", diff --git a/src/main/index.ts b/src/main/index.ts index 951222b..44d3545 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -149,8 +149,13 @@ async function capture( function serveAppProtocol(): void { protocol.handle(SCHEME, (req) => { - const url = new URL(req.url) - let pathname = decodeURIComponent(url.pathname) + let pathname = '' + try { + const url = new URL(req.url) + pathname = decodeURIComponent(url.pathname) + } catch { + return new Response('bad request', { status: 400 }) + } if (pathname === '/') pathname = '/index.html' const target = normalize(join(RENDERER_ROOT, pathname)) + sep const resolved = normalize(join(RENDERER_ROOT, pathname)) diff --git a/src/renderer/game/core/biography.ts b/src/renderer/game/core/biography.ts index d24cdc9..fc707df 100644 --- a/src/renderer/game/core/biography.ts +++ b/src/renderer/game/core/biography.ts @@ -48,13 +48,12 @@ export function buildBiography(s: GameState, memberId: string): BioLine[] { } // 大比 - const tourneys = s.stats.tourneyHistory.filter((t) => true).slice(-1) - const hisTourney = tourneys.filter((t) => { + const hisTourneys = s.stats.tourneyHistory.filter((t) => { const battle = s.battles.find((b) => b.year === t.year && b.title.includes('大比')) return battle?.lines.some((l) => l.includes(c.name)) }) - if (hisTourney.length > 0) { - lines.push({ at: hisTourney[0].year, label: '大比', text: `${hisTourney[0].year}年,随队参加太虚大比,列第${hisTourney[0].rank}名。` }) + for (const t of hisTourneys) { + lines.push({ at: t.year, label: '大比', text: `${t.year}年,随队参加太虚大比,列第${t.rank}名。` }) } // 墓碑铭文 diff --git a/src/renderer/game/core/clock.ts b/src/renderer/game/core/clock.ts index c078ccd..2028157 100644 --- a/src/renderer/game/core/clock.ts +++ b/src/renderer/game/core/clock.ts @@ -38,14 +38,22 @@ export class GameClock { private monthly = new Map() private yearly: SystemHook[] = [] - register(phase: PhaseId, fn: SystemHook): void { + register(phase: PhaseId, fn: SystemHook): () => void { const list = this.monthly.get(phase) ?? [] list.push(fn) this.monthly.set(phase, list) + return () => { + const li = list.indexOf(fn) + if (li >= 0) list.splice(li, 1) + } } - onYearStart(fn: SystemHook): void { + onYearStart(fn: SystemHook): () => void { this.yearly.push(fn) + return () => { + const i = this.yearly.indexOf(fn) + if (i >= 0) this.yearly.splice(i, 1) + } } fireYearStart(w: World): void { @@ -56,7 +64,7 @@ export class GameClock { const report: PhaseStat[] = [] for (const phase of PHASE_ORDER) { const t0 = performance.now() - const fns = this.monthly.get(phase) ?? [] + const fns = [...(this.monthly.get(phase) ?? [])] for (const fn of fns) fn(w) const ms = performance.now() - t0 report.push({ phase, ms, count: fns.length }) @@ -69,4 +77,8 @@ export class GameClock { for (const list of this.monthly.values()) n += list.length return n + this.yearly.length } + + snapshot(): Array<{ phase: PhaseId; count: number }> { + return PHASE_ORDER.map((phase) => ({ phase, count: (this.monthly.get(phase) ?? []).length })) + } } diff --git a/src/renderer/game/core/names.ts b/src/renderer/game/core/names.ts index abd7579..3261f11 100644 --- a/src/renderer/game/core/names.ts +++ b/src/renderer/game/core/names.ts @@ -1,8 +1,8 @@ export const SURNAME_POOL = [ '林', '苏', '沈', '谢', '顾', '萧', '叶', '江', '秦', '裴', '柳', '陆', '云', '姜', '晏', '楚', '洛', '许', '宋', '薛', - '韩', '白', '纪', '容', '卫', '柳', '燕', '温', '孟', '阮', - '洛', '池', '顾', '岑', '傅', '虞', '尹', '霍', '曲', '齐' + '韩', '白', '纪', '容', '卫', '燕', '温', '孟', '阮', + '池', '岑', '傅', '虞', '尹', '霍', '曲', '齐' ] export const MALE_GIVEN = [ diff --git a/src/renderer/game/core/plugin.ts b/src/renderer/game/core/plugin.ts new file mode 100644 index 0000000..2ca9fad --- /dev/null +++ b/src/renderer/game/core/plugin.ts @@ -0,0 +1,55 @@ +import { GameClock, SystemHook } from './clock' +import { DataPack } from '../data/registry' +import { EventDef } from '../data/events' +import type { World } from '../engine/world' + +export type PluginKind = 'system' | 'data' | 'events' | 'content' + +export interface PluginHookGuard { + onLoad?(): void + onDisable?(): void + onEnable?(): void + onUninstall?(): void +} + +export interface CotycPlugin { + id: string + name: string + version: string + author?: string + description: string + kind: PluginKind + dependencies?: string[] + conflicts?: string[] + install(ctx: PluginContext): void + uninstall?(ctx: PluginContext): void + hooks?: PluginHookGuard + protected?: boolean +} + +export interface PluginContext { + world: World + clock: GameClock + register: (phase: Parameters[0], fn: SystemHook) => () => void + onYearStart: (fn: SystemHook) => () => void + addCapability: (cap: { id: string; name: string; version: string; desc: string }) => void + removeCapability: (id: string) => void + enableCapability: (id: string, enabled: boolean) => void + overridePack: (partial: Partial) => void + resetPack: () => void + addEventPool: (id: string, events: EventDef[]) => void + removeEventPool: (id: string) => void +} + +export interface PluginStatus { + id: string + name: string + version: string + kind: PluginKind + installed: boolean + enabled: boolean + protected: boolean + dependencies?: string[] +} + +export type PluginChange = { id: string; action: 'install' | 'remove' | 'enable' | 'disable' } diff --git a/src/renderer/game/data/aspirations.ts b/src/renderer/game/data/aspirations.ts index a2a89d4..5546a8a 100644 --- a/src/renderer/game/data/aspirations.ts +++ b/src/renderer/game/data/aspirations.ts @@ -1,5 +1,4 @@ -import { Element } from '../types/domain' -import { Character } from '../types/domain' +import { Element, type Character } from '../types/domain' import { techniqueById } from './techniques' export type AspirationEffectType = 'cult' | 'battle' | 'market' | 'field' | 'offspring' | 'rep' @@ -58,7 +57,7 @@ export const ASPIRATIONS: Record = { yun: { id: 'yun', name: '云游', - desc: '天地为庐:修炼 -3%,但多见奇景(际遇更频)。', + desc: '天地为庐:修炼 -3%,所见所闻略广。', icon: '云', effect: { type: 'cult', value: -0.03 } } @@ -71,15 +70,6 @@ export function aspirationById(id: string | undefined): AspirationDef | null { return ASPIRATIONS[id] ?? null } -export function countAspiration(type: AspirationEffectType, members: Character[]): number { - let n = 0 - for (const c of members) { - const def = aspirationById(c.aspiration) - if (def?.effect.type === type) n++ - } - return n -} - export function fitBonusOf(c: Character): { cult: boolean; battle: boolean } { const tech = techniqueById(c.techniqueId) if (!tech) return { cult: false, battle: false } diff --git a/src/renderer/game/data/npcs.ts b/src/renderer/game/data/npcs.ts index f22da2d..b300a07 100644 --- a/src/renderer/game/data/npcs.ts +++ b/src/renderer/game/data/npcs.ts @@ -15,7 +15,7 @@ export interface NpcFamilyDef { export const NPCS: NpcFamilyDef[] = [ { - id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修吕氏,剑意凛冽,最为孤傲。', + id: 'n-xuanying', name: '玄影沈氏', region: '北岳玄影峰', style: '剑修世家', desc: '隐于北岳的剑修沈氏,剑意凛冽,最为孤傲。', leaderRealm: 'foundation', initialPower: 240, powerGrowth: [4, 10], sells: ['weapon-qi', 'weapon-ling'], buys: ['lingcao', 'lingkuang'] }, diff --git a/src/renderer/game/data/realms.ts b/src/renderer/game/data/realms.ts index facd9ea..4feda1c 100644 --- a/src/renderer/game/data/realms.ts +++ b/src/renderer/game/data/realms.ts @@ -116,4 +116,8 @@ export interface TechniqueDef { desc: string } -export const TECHNIQUE_GRADE_NAMES = ['黄阶', '玄阶', '地阶', '天阶', '仙阶'] +export const TECHNIQUE_GRADE_NAMES = ['凡阶', '黄阶', '玄阶', '地阶', '天阶', '仙阶'] + +export function techniqueGradeName(grade: number): string { + return TECHNIQUE_GRADE_NAMES[grade] ?? '?' +} diff --git a/src/renderer/game/engine/api.ts b/src/renderer/game/engine/api.ts index 21b7d05..a75e359 100644 --- a/src/renderer/game/engine/api.ts +++ b/src/renderer/game/engine/api.ts @@ -66,7 +66,7 @@ export const ACT_CATALOG: Record (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 } diff --git a/src/renderer/game/engine/clocks.ts b/src/renderer/game/engine/clocks.ts index 8b690c1..bc0cc98 100644 --- a/src/renderer/game/engine/clocks.ts +++ b/src/renderer/game/engine/clocks.ts @@ -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[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 } diff --git a/src/renderer/game/engine/market.ts b/src/renderer/game/engine/market.ts index af45032..2099bee 100644 --- a/src/renderer/game/engine/market.ts +++ b/src/renderer/game/engine/market.ts @@ -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 = { 1: 120, 2: 300, 3: 700, 4: 1600 } const TECH_GRADE_BASE: Record = 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]) ) diff --git a/src/renderer/game/engine/pcgen.ts b/src/renderer/game/engine/pcgen.ts index a50b3d9..d27b5c9 100644 --- a/src/renderer/game/engine/pcgen.ts +++ b/src/renderer/game/engine/pcgen.ts @@ -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) diff --git a/src/renderer/game/engine/plugin-bootstrap.ts b/src/renderer/game/engine/plugin-bootstrap.ts new file mode 100644 index 0000000..922df5c --- /dev/null +++ b/src/renderer/game/engine/plugin-bootstrap.ts @@ -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() +] diff --git a/src/renderer/game/engine/pluginManager.ts b/src/renderer/game/engine/pluginManager.ts new file mode 100644 index 0000000..3de2d73 --- /dev/null +++ b/src/renderer/game/engine/pluginManager.ts @@ -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() + 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[0], fn: SystemHook) => { + const unsub = (target[prop] as (phase: Parameters[0], fn: SystemHook) => () => void)(phase, fn) + rt.unsubscribers.push(unsub) + return unsub + } + } + return (target as unknown as Record)[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 + } +} diff --git a/src/renderer/game/engine/systems/cultivation.ts b/src/renderer/game/engine/systems/cultivation.ts index d2395c6..3f0e1df 100644 --- a/src/renderer/game/engine/systems/cultivation.ts +++ b/src/renderer/game/engine/systems/cultivation.ts @@ -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 diff --git a/src/renderer/game/engine/systems/events.ts b/src/renderer/game/engine/systems/events.ts index ee3bdce..b4fc833 100644 --- a/src/renderer/game/engine/systems/events.ts +++ b/src/renderer/game/engine/systems/events.ts @@ -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) diff --git a/src/renderer/game/engine/systems/missions.ts b/src/renderer/game/engine/systems/missions.ts index 8457cf1..5fa593f 100644 --- a/src/renderer/game/engine/systems/missions.ts +++ b/src/renderer/game/engine/systems/missions.ts @@ -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 } diff --git a/src/renderer/game/engine/systems/production.ts b/src/renderer/game/engine/systems/production.ts index a9c21cf..ccd8d42 100644 --- a/src/renderer/game/engine/systems/production.ts +++ b/src/renderer/game/engine/systems/production.ts @@ -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 diff --git a/src/renderer/game/engine/systems/tournament.ts b/src/renderer/game/engine/systems/tournament.ts index cb17c49..dfff92b 100644 --- a/src/renderer/game/engine/systems/tournament.ts +++ b/src/renderer/game/engine/systems/tournament.ts @@ -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}` diff --git a/src/renderer/game/engine/systems/tribulation.ts b/src/renderer/game/engine/systems/tribulation.ts index b7b9097..8a1314d 100644 --- a/src/renderer/game/engine/systems/tribulation.ts +++ b/src/renderer/game/engine/systems/tribulation.ts @@ -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' } diff --git a/src/renderer/game/engine/world.ts b/src/renderer/game/engine/world.ts index 5c3c102..020b730 100644 --- a/src/renderer/game/engine/world.ts +++ b/src/renderer/game/engine/world.ts @@ -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 + plugins: PluginManager + private eventPools = new Map() 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): 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 diff --git a/src/renderer/game/storage/slots.ts b/src/renderer/game/storage/slots.ts index 754ed73..bf6a536 100644 --- a/src/renderer/game/storage/slots.ts +++ b/src/renderer/game/storage/slots.ts @@ -1,6 +1,7 @@ import { GameState, SaveMeta, SnapshotMeta, Id } from '../types/domain' const DB_PREFIX = 'cotyc-save-' +let seqCounter = 0 export interface SaveDbDriver { open(name: string): Promise @@ -38,7 +39,7 @@ export class SaveSlot { } async saveState(state: GameState, label: string): Promise { - const id = `${state.year}.${state.month}.${Date.now().toString(36)}` + const id = `${state.year}.${state.month}.${Date.now().toString(36)}-${(seqCounter++ % 1296).toString(36)}` const json = JSON.stringify(state) await this.driver.run( `INSERT INTO snapshot (id, year, month, savedAt, label, data) VALUES (?, ?, ?, ?, ?, ?)`, @@ -80,8 +81,11 @@ export class SaveSlot { async loadState(id?: string): Promise { const rows = await this.driver.all<{ data: string }>( - id ? `SELECT data FROM snapshot WHERE id = ?` : `SELECT data FROM snapshot ORDER BY rowid DESC LIMIT 1` - , id ? [id] : []) + id + ? `SELECT data FROM snapshot WHERE id = ?` + : `SELECT data FROM snapshot ORDER BY year DESC, month DESC, savedAt DESC LIMIT 1`, + id ? [id] : [] + ) if (!rows || rows.length === 0) return null return JSON.parse(rows[0]!.data) as GameState } diff --git a/src/renderer/ui/components/MemberCard.tsx b/src/renderer/ui/components/MemberCard.tsx index 9d10d02..67210a3 100644 --- a/src/renderer/ui/components/MemberCard.tsx +++ b/src/renderer/ui/components/MemberCard.tsx @@ -7,7 +7,9 @@ const STATE_LABEL: Record = { idle: { label: '无事', cls: '' }, meditation: { label: '闭关', cls: 'good' }, expedition: { label: '出探', cls: 'warn' }, - wounded: { label: '养伤', cls: 'bad' } + wounded: { label: '养伤', cls: 'bad' }, + apprentice: { label: '寄读', cls: 'gold' }, + closed: { label: '养息', cls: '' } } export function MemberCard({ c }: { c: Character }) { diff --git a/src/renderer/ui/components/MemberModal.tsx b/src/renderer/ui/components/MemberModal.tsx index 7e46801..b53c17a 100644 --- a/src/renderer/ui/components/MemberModal.tsx +++ b/src/renderer/ui/components/MemberModal.tsx @@ -27,6 +27,11 @@ function deadish(c: Character): boolean { return !c.alive } +function gradeShort(g: number): string { + const map: Record = { 0: '凡', 1: '黄', 2: '玄', 3: '地', 4: '天', 5: '仙' } + return map[g] ?? '?' +} + function pillCount(s: GameState, id: string): number { return s.family.inventory[id] ?? 0 } @@ -65,9 +70,19 @@ export function MemberModal({ member }: { member: Character }) {
境界进度{describeRealm(member.realm)} {Math.floor(member.realmProgress)}%
下一关= 100 ? 'gold' : ''}>{nextName}
战力{Math.round(power)}
-
状态{member.state === 'meditation' ? '闭关' : member.state === 'expedition' ? '外出' : member.state === 'wounded' ? '养伤' : '无事'}
+
状态 + {member.state === 'meditation' + ? '闭关' + : member.state === 'expedition' + ? '外出' + : member.state === 'wounded' + ? '养伤' + : member.state === 'apprentice' + ? '寄读' + : '无事'} +
气血{Math.round(member.health)}
-
功法{tech ? `${tech.name}(${['黄', '玄', '地', '天', '仙'][tech.grade]}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}
+
功法{tech ? `${tech.name}(${gradeShort(tech.grade)}阶)` : member.realm.major === 'mortal' ? '—' : '未习功法'}
法宝{member.equipment ? `${ITEMS[member.equipment].name}(+${Math.round((ARTIFACT_POWER[member.equipment] ?? 0) * 100)}%)` : '—'}
姻亲{member.spouseId ? s.members[member.spouseId]?.name ?? '' : member.spouseHouse ?? '未婚'}
diff --git a/src/renderer/ui/panels/ChroniclePanel.tsx b/src/renderer/ui/panels/ChroniclePanel.tsx index 18def0f..911a213 100644 --- a/src/renderer/ui/panels/ChroniclePanel.tsx +++ b/src/renderer/ui/panels/ChroniclePanel.tsx @@ -27,7 +27,7 @@ export default function ChroniclePanel() { const all = [...world.state.chronicle] const filtered = filter === 'all' ? all : all.filter((e) => e.category === filter) return filtered.slice().sort((a, b) => (b.year - a.year) || (b.month - a.month)) - }, [world, filter]) + }, [world, filter, revision]) if (!world) return null const battles = world.state.battles.slice().reverse() const battle = battles.find((b) => b.id === battleId) diff --git a/src/renderer/ui/panels/MarketPanel.tsx b/src/renderer/ui/panels/MarketPanel.tsx index c398f12..12440b4 100644 --- a/src/renderer/ui/panels/MarketPanel.tsx +++ b/src/renderer/ui/panels/MarketPanel.tsx @@ -1,7 +1,7 @@ import { useGameStore } from '../store' import { ITEMS } from '../../game/data/items' import { TECHNIQUES } from '../../game/data/techniques' -import { TECHNIQUE_GRADE_NAMES } from '../../game/data/realms' +import { techniqueGradeName } from '../../game/data/realms' import { marketPrice, buyItem, sellItem, buyTechnique } from '../../game/engine/market' import { useMemo, useState } from 'react' @@ -10,11 +10,11 @@ export default function MarketPanel() { const bump = useGameStore((s) => s.bump) const revision = useGameStore((s) => s.revision) void revision + const [tab, setTab] = useState<'goods' | 'tech'>('goods') if (!world) return null const w = world const fam = w.state.family const inv = fam.inventory - const [tab, setTab] = useState<'goods' | 'tech'>('goods') const goods = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'resource' || i.kind === 'pill'), []) const artifacts = useMemo(() => Object.values(ITEMS).filter((i) => i.kind === 'artifact'), []) @@ -110,7 +110,7 @@ export default function MarketPanel() { {tab === 'tech' && ( <>
- 藏书阁录存法帖,族人可修习。藏书阁等级决定可在坊市搜购的品阶(当前 {cangshuLv} 级,可购至{TECHNIQUE_GRADE_NAMES[Math.min(3, Math.max(0, cangshuLv))]})。 + 藏书阁录存法帖,族人可修习。藏书阁等级决定可在坊市搜购的品阶(当前 {cangshuLv} 级,可购至{techniqueGradeName(Math.min(4, Math.max(0, cangshuLv + 1)))})。 若藏书阁未建或等级不足,只可见基础法帖。
@@ -131,11 +131,11 @@ export default function MarketPanel() { .map((t) => { const owned = fam.techniques.includes(t.id) const p = t.grade <= 1 + cangshuLv - const priceT = [120, 300, 700, 1600, 3600][t.grade] ?? 300 + const priceT = techniqueGradeName(t.grade) === '?' ? 300 : [120, 300, 700, 1600][t.grade] ?? 300 return ( - + diff --git a/src/renderer/ui/panels/SettingsPanel.tsx b/src/renderer/ui/panels/SettingsPanel.tsx index 6a58280..c44d611 100644 --- a/src/renderer/ui/panels/SettingsPanel.tsx +++ b/src/renderer/ui/panels/SettingsPanel.tsx @@ -97,7 +97,7 @@ export default function SettingsPanel() { · 「外交」与四邻结好联姻;仇雠之族隔岁来犯,打得赢名望大涨,打不赢蚀钱伤丁。
· 「史书」自动记述繁华与凋零——百年之后,后人翻开这一卷家族志,见代代薪火、历历雪泥。 -
版本 0.1.0 · Chronicle of the Immortal Clan
+
版本 0.1.8 · Chronicle of the Immortal Clan
) } diff --git a/src/renderer/ui/panels/TerritoryPanel.tsx b/src/renderer/ui/panels/TerritoryPanel.tsx index 70d017d..8dcf035 100644 --- a/src/renderer/ui/panels/TerritoryPanel.tsx +++ b/src/renderer/ui/panels/TerritoryPanel.tsx @@ -7,9 +7,9 @@ export default function TerritoryPanel() { const bump = useGameStore((s) => s.bump) const revision = useGameStore((s) => s.revision) void revision + const ids = useMemo(() => Object.keys(BUILDINGS), []) if (!world) return null const fam = world.state.family - const ids = useMemo(() => Object.keys(BUILDINGS), []) const levelLabel = (l: number) => '·'.repeat(l) + '。'.repeat(5 - l) diff --git a/src/renderer/ui/screens/NewGame.tsx b/src/renderer/ui/screens/NewGame.tsx index d06827e..bf8af2c 100644 --- a/src/renderer/ui/screens/NewGame.tsx +++ b/src/renderer/ui/screens/NewGame.tsx @@ -17,7 +17,8 @@ export default function NewGame() { const rollNames = () => { setRandoming((r) => !r) - const s = SURNAME_POOL[Math.floor(Math.random() * SURNAME_POOL.length)] + const idx = Math.floor(RngHub.audioNoise01() * SURNAME_POOL.length) + const s = SURNAME_POOL[Math.min(SURNAME_POOL.length - 1, idx)] setSurname(s) setFamilyName(`${s}氏`) } diff --git a/src/renderer/ui/sound.ts b/src/renderer/ui/sound.ts index 76182ab..3b6ca02 100644 --- a/src/renderer/ui/sound.ts +++ b/src/renderer/ui/sound.ts @@ -110,7 +110,11 @@ export function sGong(): void { tone(660, 0.7, 0.03, 'sine', 0.06) } -/** 按钮:纸面轻叩 */ +let lastClick = 0 +/** 按钮:纸面轻叩(80ms 节流防连点爆 buffer) */ export function sClick(): void { + const now = performance.now() + if (now - lastClick < 80) return + lastClick = now noise(0.045, 0.03, 0, 4200) } diff --git a/src/renderer/ui/store.ts b/src/renderer/ui/store.ts index d261ced..522c1e9 100644 --- a/src/renderer/ui/store.ts +++ b/src/renderer/ui/store.ts @@ -60,6 +60,7 @@ export interface GameStore { closePaper: () => void toggleSound: () => void facade?: GameFacade + advancing: boolean act: (name: ActName, payload: import('../game/engine/api').ActPayload) => boolean } @@ -67,8 +68,21 @@ const LOG_CAP = 260 const lastPhaseStats = new Map() let logSeq = 1 +let toastTimer: ReturnType | null = null +function setNextToast(msg: string): void { + if (toastTimer) clearTimeout(toastTimer) + useGameStore.setState({ toast: msg }) + toastTimer = setTimeout(() => useGameStore.setState({ toast: undefined }), 4200) +} +function stopTimer(): void { + const st = useGameStore.getState() + if (st.timer) clearInterval(st.timer) + useGameStore.setState({ speed: 0, timer: null }) +} + export const useGameStore = create((set, get) => ({ facade: undefined as GameFacade | undefined, + advancing: false, screen: 'boot', world: null, slot: 1, @@ -124,34 +138,46 @@ export const useGameStore = create((set, get) => ({ startNewGame: async (opts, slot) => { const world = World.create(opts) - set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0 }) + set({ world, slot, screen: 'game', panel: 'family', logFeed: [], battleView: undefined, pendingEventId: undefined, pendingEventDef: undefined, revision: 1, speed: 0, gameOverReason: undefined, paperReport: undefined, toast: undefined, selectedMemberId: undefined, selectedMissionDef: undefined }) const st = get() st.world?.out.push(makeBus(st)) + const facade = new GameFacade(world, slot) + set({ facade }) await st.saveNow('开局') }, continueGame: async (slot) => { - const manager = getSlotManager() - const file = await getSaveSlot(slot) - await file.open() - const state = await file.loadState() - if (!state) throw new Error('找不到存档数据') - openState(state, slot) - await manager.updateSlotMeta(slot, metaFromState(state, slot)) + try { + const manager = getSlotManager() + const file = await getSaveSlot(slot) + await file.open() + const state = await file.loadState() + if (!state) throw new Error('找不到存档数据') + openState(state, slot) + await manager.updateSlotMeta(slot, metaFromState(state, slot)) + } catch (e) { + setNextToast(`读档失败:${String(e)}`) + useGameStore.setState({ screen: 'boot' }) + } }, loadSnapshot: async (slot, snapshotId) => { - const file = await getSaveSlot(slot) - await file.open() - const state = await file.loadState(snapshotId) - if (!state) throw new Error('找不到快照') - openState(state, slot) + try { + const file = await getSaveSlot(slot) + await file.open() + const state = await file.loadState(snapshotId) + if (!state) throw new Error('找不到快照') + openState(state, slot) + } catch (e) { + setNextToast(`读取快照失败:${String(e)}`) + } }, advance: async () => { const st = get() const w = st.world - if (!w || st.pendingEventId || w.state.gameOver) return + if (!w || st.pendingEventId || w.state.gameOver || st.advancing) return + set({ advancing: true }) try { const t0 = performance.now() w.advanceMonth() @@ -175,7 +201,10 @@ export const useGameStore = create((set, get) => ({ } catch { // 保存失败时不再叠加错误 } - set({ toast: '时日流转出现异常,已保存出险现场,可回档重来。', speed: 0 }) + setNextToast('时日流转出现异常,已保存出险现场,可回档重来。') + stopTimer() + } finally { + set({ advancing: false }) } }, @@ -192,10 +221,21 @@ export const useGameStore = create((set, get) => ({ } }, - onBattle: (log) => set({ battleView: log, revision: get().revision + 1 }), + onBattle: (log) => { + stopTimer() + set({ battleView: log, revision: get().revision + 1 }) + }, onPendingEvent: (id) => { const def = findEvent(id, get().world ?? undefined) + if (!def) { + // 未知事件(插件卸载/旧档漂移):跳过并解除软锁 + const w = get().world + if (w?.state.pendingEvent === id) w.state.pendingEvent = undefined + get().addLog({ id: logSeq++, kind: 'bad', text: `事件「${id}」未知,已跳过。`, year: get().world?.state.year ?? 0, month: get().world?.state.month ?? 0 }) + set({ pendingEventId: undefined, pendingEventDef: undefined }) + return + } set({ pendingEventId: id, pendingEventDef: def }) }, @@ -253,6 +293,9 @@ export const useGameStore = create((set, get) => ({ setSpeed: (v) => { const st = get() if (st.timer) clearInterval(st.timer) + if (st.speed === v && v === 0 && false) { + // noop + } if (v <= 0) { set({ speed: v, timer: null }) return @@ -261,9 +304,12 @@ export const useGameStore = create((set, get) => ({ if (v >= 3) v = 3 const timer = setInterval(() => { const cur = get() - if (!cur.pendingEventId && cur.world && !cur.world.state.gameOver) { - void cur.advance() + const w = cur.world + if (cur.speed <= 0 || !w || cur.pendingEventId || cur.battleView || cur.advancing || w.state.gameOver) { + // 速度被暂停/事件待决/战报展开/在途推进时静默轮空 + return } + void cur.advance() }, ms) set({ speed: v, timer }) }, @@ -282,25 +328,27 @@ export const useGameStore = create((set, get) => ({ const json = await file.exportAll() const meta = metaFromState(st.world.state, st.slot) const r = await window.api.exportSave(json, `${meta.name}-年${meta.year}年`) - if (r.ok) set({ toast: '存档已导出。' }) - else set({ toast: `导出失败:${r.error ?? ''}` }) + if (r.ok) setNextToast('存档已导出。') + else setNextToast(`导出失败:${r.error ?? ''}`) }, importSave: async (slot) => { if (!window.api) return const r = await window.api.importSave() if (!r.ok || !r.text) { - set({ toast: `导入失败:${r.error ?? ''}` }) + setNextToast(`导入失败:${r.error ?? ''}`) return } const file = await getSaveSlot(slot) await file.open() const ok = await file.importAll(r.text) if (ok) { - set({ toast: '导入成功,重新读取存档槽。' }) + setNextToast('导入成功,重新读取存档槽。') await get().refreshSlots() + const state = await file.loadState() + if (state) openState(state, slot) } else { - set({ toast: '导入失败:文件格式不正确。' }) + setNextToast('导入失败:文件格式不正确。') } }, @@ -326,8 +374,8 @@ function openState(state: GameState, slot: number): void { const st = useGameStore.getState() world.out.push(makeBus(st)) const facade = new GameFacade(world, slot) - world.out.push(facadebus(facade)) useGameStore.setState({ + facade, world, slot, screen: 'game', @@ -338,8 +386,20 @@ function openState(state: GameState, slot: number): void { pendingEventDef: undefined, revision: 1, speed: 0, - gameOverReason: state.gameOver?.reason + gameOverReason: state.gameOver?.reason || undefined, + paperReport: undefined, + toast: undefined, + selectedMemberId: undefined, + selectedMissionDef: undefined }) + if (state.pendingEvent && state.members) { + const def = findEvent(state.pendingEvent, world) + if (def) { + useGameStore.setState({ pendingEventId: state.pendingEvent, pendingEventDef: def }) + } else { + world.state.pendingEvent = undefined + } + } } function makeBus(st: GameStore): WorldEventBus { @@ -363,17 +423,6 @@ function makeBus(st: GameStore): WorldEventBus { } } -function facadebus(facade: GameFacade): WorldEventBus { - return { - onLog: () => undefined, - onChronicle: () => undefined, - onBattle: () => undefined, - onPendingEvent: () => undefined, - onGameOver: () => undefined, - onSystemChange: () => undefined - } -} - function actDirect(w: World, name: ActName, payload: import('../game/engine/api').ActPayload): boolean { if (name === 'legacy.resolve') return (w.resolveLegacyNow(), true) if (name === 'estate.rite') return w.ancestralRite() diff --git a/tests/audit-regression.test.ts b/tests/audit-regression.test.ts new file mode 100644 index 0000000..4be414a --- /dev/null +++ b/tests/audit-regression.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { newCharacter } from '../src/renderer/game/engine/pcgen' +import { Rng, seedToRng } from '../src/renderer/game/core/rng' +import { PACK } from '../src/renderer/game/data/registry' +import { buildApprentice } from '../src/renderer/game/engine/systems/tournament' +import { sendMission } from '../src/renderer/game/engine/systems/missions' +import { buyItem, marketPrice } from '../src/renderer/game/engine/market' +import { findEvent } from '../src/renderer/game/engine/systems/events' +import { runTournament } from '../src/renderer/game/engine/systems/tournament' +import { stateFingerprint, longRun } from './fingerprint.helper' +import { resetSaveBus, attachLogSink } from './world.helpers' + +function baseWorld(seed: string): World { + const w = World.create({ seed, surname: '佘', familyName: '佘家', motto: 'm', difficulty: 'normal' }) + resetSaveBus() + attachLogSink(w) + return w +} + +describe('审计回归:P0 修复固化', () => { + it('单亲 rollRoots 不再崩溃(pcgen TypeError 修复)', () => { + const r = new Rng(seedToRng('single')) + const dad = newCharacter(r, { name: '父', gender: 'male', generation: 1, bornYear: -30, age: 30, realm: { major: 'qi', minor: 1 } }) + dad.roots = { grade: 3, primary: '火', secondary: [] } + const child = newCharacter(r, { name: '子', gender: 'male', generation: 2, bornYear: 0, age: 0, father: dad }) + expect(child.roots.grade).toBeGreaterThanOrEqual(0) + }) + + it('插件事件池事件可被 findEvent 解析(软锁修复)', () => { + const w = baseWorld('aud-find') + w.installPlugin({ + id: 'aud-event-plugin', + name: '审计事件', + version: '1', + kind: 'events', + install(ctx) { + ctx.addEventPool('aud', [ + { id: 'id-evt-aud', name: '试炼', category: 'daily', weight: 1, text: '审计副本。', options: [{ label: '收下', eff: { rep: 1 } }] } + ]) + } + }) + // 直接模拟抽到该事件后 UI 路径解析 + const def = findEvent('id-evt-aud', w) + expect(def).toBeTruthy() + }) + + it('packOverride 经插件上下文真实生效(死实现修复)', () => { + const w = baseWorld('aud-pack') + w.installPlugin({ + id: 'aud-data-plugin', + name: '货币更替', + version: '1', + kind: 'data', + install(ctx) { + ctx.overridePack({ items: { ...PACK.current().items, lingcao: { ...PACK.current().items['lingcao'], basePrice: 500 } } }) + } + }) + expect(marketPrice(w, 'lingcao')).toBeGreaterThan(100) + expect(buyItem(w, 'lingcao', 2)).toBe(false) // 1000 > 800,双份买不起 + }) + + it('buildApprentice 空候选不再抛错(守卫修复)', () => { + const w = baseWorld('aud-appr') + for (const c of Object.values(w.state.members)) c.state = 'apprentice' + buildApprentice(w) // 此前 Rng.pick([]) 抛错 + expect(w.state.family.stones).toBe(800) + }) + + it('sendMission 无效 id(回档残留)不再抛错', () => { + const w = baseWorld('aud-squad') + const ok = sendMission(w, 'm-anmoku', ['x-not-exist']) + expect(ok).toBe(false) + }) + + it('tournament 越过能力开关后休赛(未启时无副作用)', () => { + const w = baseWorld('aud-tourney') + w.toggleSystem('tournament') + const before = w.state.battles.length + runTournament(w, ['x3']) + expect(w.state.battles.length).toBe(before) + }) + + it('防御性修补后金钟罩不变(行为等价确认)', () => { + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ebfec4a4') + }) +}) diff --git a/tests/clock.test.ts b/tests/clock.test.ts index afcafcd..70490af 100644 --- a/tests/clock.test.ts +++ b/tests/clock.test.ts @@ -6,11 +6,12 @@ import { stateFingerprint, longRun } from './fingerprint.helper' * 任何改动(重构日程/调平衡/加系统)若改变了确定性序列或结果,此测试立刻报红。 * 更新规则:仅当**有意**变更序列逻辑时,三枚 seed 指纹同版更新并注明原因。 */ -// 0.1.6 基线:GameClock 重构(行为等价)+ 时节系统(有意变更乘子)后重新固化 +// 0.1.8 基线:特征加成(自律/精研/急躁/云游生效)+ 飞升窗口永续 + market 利己折扣后重新固化 +// (0.1.6 时 GreenClock 重构行为等价;0.1.7 插件门面化零漂移) const GOLDEN: Record = { 'bell-seed-1': '9af71ecb', - 'bell-seed-2': '34f102fd', - 'bell-seed-3': '753aca71' + 'bell-seed-2': '63cede89', + 'bell-seed-3': 'ebfec4a4' } describe('金钟罩 · 长跑确定性指纹', () => { diff --git a/tests/data.test.ts b/tests/data.test.ts index 7a4c60b..2f6749e 100644 --- a/tests/data.test.ts +++ b/tests/data.test.ts @@ -117,8 +117,8 @@ describe('realms 境界表', () => { } }) - it('品阶名数量一致', () => { - expect(TECHNIQUE_GRADE_NAMES.length).toBe(5) + it('品阶名数量一致(含凡阶哨兵)', () => { + expect(TECHNIQUE_GRADE_NAMES.length).toBe(6) }) }) diff --git a/tests/facade-registry.test.ts b/tests/facade-registry.test.ts index a08ff28..70c4c98 100644 --- a/tests/facade-registry.test.ts +++ b/tests/facade-registry.test.ts @@ -162,14 +162,15 @@ describe('GameFacade 门面', () => { const f = new GameFacade(w, 1) const info = f.about() expect(info.title).toBe('仙途家族志') - expect(info.version).toContain('0.1.7') + expect(info.version).toContain('0.1.8') expect(info.modules).toBeGreaterThanOrEqual(11) expect(info.systems).toBeGreaterThan(0) + expect(info.plugins).toBeGreaterThanOrEqual(3) }) it('默认配置金钟罩不受门面化影响', () => { PACK.reset() expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb') - expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('753aca71') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ebfec4a4') }) }) diff --git a/tests/helpers/examplePlugin.ts b/tests/helpers/examplePlugin.ts new file mode 100644 index 0000000..77945cf --- /dev/null +++ b/tests/helpers/examplePlugin.ts @@ -0,0 +1,45 @@ +import { CotycPlugin } from '../src/renderer/game/core/plugin' + +/** 示例内容插件:注入事件池 + 一个护山能力(开发范本) */ +export const examplePlugin: CotycPlugin = { + id: 'demo-peaks', + name: '护山妖兽', + version: '0.1.0', + author: 'demo', + description: '示例插件:山鬼妖气事件与护山之力(+2% 战力)。', + kind: 'content', + install(ctx) { + ctx.addCapability({ id: 'demo-guardian', name: '护山之力', version: '0.1.0', desc: '示例:年首山灵庇佑,全族修为小幅精进。' }) + ctx.onYearStart((w) => { + if (w.sysEnabled('demo-guardian')) { + for (const c of w.aliveMembers()) { + c.realmProgress = Math.min(100, c.realmProgress + 1.5) + } + } + }) + ctx.addEventPool('demo-peaks', [ + { + id: 'ev-demo-guardian', + name: '山鬼怒吼', + category: 'daily', + weight: 3, + text: '夜半山鸣,护山兽影现身墙外——莫非是山中精怪在拜望?', + options: [{ label: '蒸饼供奉', hint: '声望+2', eff: { rep: 2 } }] + } + ]) + }, + uninstall(ctx) { + ctx.removeEventPool('demo-peaks') + ctx.removeCapability('demo-guardian') + } +} + +/** 依赖缺失的坏插件:应被拒绝安装 */ +export const brokenPlugin: CotycPlugin = { + id: 'demo-broken', + name: '依赖断链的插件', + version: '0.1.0', + kind: 'events', + dependencies: ['demo-not-exists'], + install() {} +} diff --git a/tests/plugin.test.ts b/tests/plugin.test.ts new file mode 100644 index 0000000..4f886e5 --- /dev/null +++ b/tests/plugin.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import { World } from '../src/renderer/game/engine/world' +import { GameFacade } from '../src/renderer/game/engine/api' +import { installCoreSystems, buildClock } from '../src/renderer/game/engine/clocks' +import { emptyClock } from '../src/renderer/game/engine/clocks' +import { CORE_PLUGINS } from '../src/renderer/game/engine/plugin-bootstrap' +import { examplePlugin, brokenPlugin } from './helpers/examplePlugin' +import { stateFingerprint, longRun } from './fingerprint.helper' +import { resetSaveBus, attachLogSink } from './world.helpers' + +function baseWorld(seed: string): World { + const w = World.create({ seed, surname: '夏', familyName: '夏家', motto: 'm', difficulty: 'normal' }) + resetSaveBus() + attachLogSink(w) + return w +} + +describe('PluginCore 插件协议', () => { + it('内置三插件常驻且受保护', () => { + const w = baseWorld('pl-a') + const list = w.pluginList() + expect(list.length).toBe(3) + for (const p of list) { + expect(p.installed).toBe(true) + expect(p.enabled).toBe(true) + expect(p.protected).toBe(true) + } + const ids = list.map((p) => p.id) + expect(ids).toContain('core-systems') + expect(ids).toContain('core-data') + expect(ids).toContain('core-events') + }) + + it('核心插件不可卸载(保护)', () => { + const w = baseWorld('pl-b') + const r = w.removePlugin('core-systems') + expect(r.ok).toBe(false) + }) + + it('依赖缺失拒绝安装;依赖满足则放行', () => { + const w = baseWorld('pl-c') + expect(w.installPlugin(brokenPlugin).ok).toBe(false) + expect(w.installPlugin(examplePlugin).ok).toBe(true) + expect(w.pluginList().length).toBe(4) + }) + + it('示例安装后:消息池注入生效 + 山神庇佑加值;卸载后两清', () => { + const w = baseWorld('pl-d') + w.installPlugin(examplePlugin) + expect(w.eventPoolIds()).toContain('demo-peaks') + const x5 = w.state.members['x5'] + x5.realm = { major: 'qi', minor: 1 } + const p0 = x5.realmProgress + // 推进到跨年触发年首庇佑 + for (let i = 0; i < 13; i++) w.advanceMonth() + expect(x5.realmProgress).toBeGreaterThan(p0) + // 事件可被 roll 出来(跨 40 月在内) + let seenDemo = false + for (let i = 0; i < 40 && !seenDemo; i++) { + w.advanceMonth() + if (w.state.pendingEvent === 'ev-demo-guardian') seenDemo = true + w.state.pendingEvent = undefined + } + expect(seenDemo).toBe(true) + // 卸载 + expect(w.removePlugin('demo-peaks').ok).toBe(true) + expect(w.eventPoolIds()).not.toContain('demo-peaks') + expect(w.pluginList().length).toBe(3) + }) + + it('remove 后时钟钩子计数恢复(真摘钩)', () => { + const w = baseWorld('pl-e') + const before = w.clock.subscriptionCount() + w.installPlugin(examplePlugin) + const mounted = w.clock.subscriptionCount() + expect(mounted).toBe(before + 1) + w.removePlugin('demo-peaks') + expect(w.clock.subscriptionCount()).toBe(before) + }) + + it('安装序确定性:同版本两次世界插件列表一致', () => { + const a = baseWorld('pl-f') + const b = baseWorld('pl-f') + expect(JSON.stringify(a.pluginList())).toBe(JSON.stringify(b.pluginList())) + }) + + it('默认管线金钟罩不受插件层影响', () => { + expect(stateFingerprint(longRun('bell-seed-1').state)).toBe('9af71ecb') + expect(stateFingerprint(longRun('bell-seed-2').state)).toBe('63cede89') + expect(stateFingerprint(longRun('bell-seed-3').state)).toBe('ebfec4a4') + }) + + it('facade 插件查询与 about.plugins', () => { + const w = baseWorld('pl-g') + const f = new GameFacade(w, 1) + const pl = f.query('plugins') as { list: { id: string }[] } + expect(pl.list.length).toBe(3) + expect(f.about().plugins).toBe(3) + }) + + it('订阅可捕获 plugin 生命周期事件', () => { + const w = baseWorld('pl-h') + const f = new GameFacade(w, 1) + const events: string[] = [] + const unsub = f.subscribe((e) => { + if (e.type === 'plugin') events.push((e as { action: string }).action) + }) + w.installPlugin(examplePlugin) + w.removePlugin('demo-peaks') + expect(events).toContain('install') + expect(events).toContain('remove') + unsub() + }) + + it('buildClock 兼容路径仍完整注册(测试/工具用)', () => { + const clock = buildClock() + expect(clock.subscriptionCount()).toBeGreaterThanOrEqual(10) + const empty = emptyClock() + expect(empty.subscriptionCount()).toBe(0) + void installCoreSystems + }) +})
《{t.name}》{TECHNIQUE_GRADE_NAMES[t.grade]} · {t.path}{techniqueGradeName(t.grade)} · {t.path} {t.desc} +{Math.round((t.expBonus) * 100)}% +{Math.round((t.powerBonus) * 100)}%