插件系统
本文档说明 Plugin 接口、生命周期(setup)、注册流程、规则/动作/事件注册、Plugin 如何访问 GameContext,以及如何开发自定义 Plugin。
GamePlugin 接口
ts
export interface GamePlugin {
id: string
name: string
version: string
setup?(context: GameContext): void
rules?: Rule[]
actions?: ActionHandler[]
events?: EventHandler[]
}| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
id | string | 是 | 插件唯一标识,用于去重注册与依赖识别 |
name | string | 是 | 人类可读的插件名 |
version | string | 是 | 语义化版本号,便于兼容性判断 |
setup | (context: GameContext) => void | 否 | 引擎初始化阶段执行的一次性钩子 |
rules | Rule[] | 否 | 该插件提供的规则列表,用于 Action 合法性校验 |
actions | ActionHandler[] | 否 | 该插件提供的 ActionHandler 列表 |
events | EventHandler[] | 否 | 该插件提供的 EventHandler 列表,由引擎订阅到 EventBus |
EventHandler 接口
ts
export interface EventHandler<TType extends string = string, TPayload = unknown> {
type: TType
handle(event: GameEvent<TType, TPayload>, context: GameContext): void
}type 设为 '*'(WILDCARD)表示订阅所有事件。引擎会将其订阅到 EventBus,并在匹配的 GameEvent 触发时调用 handle,同时传入完整事件元数据与共享的 GameContext。
生命周期
注册:engine.use(plugin)
ts
engine.use(plugin: GamePlugin): this- 必须在
createGame之前调用 - 通过
PluginManager.use注册,按调用顺序保留插入顺序 - 重复 id 抛出
DuplicatePluginError - 缺少
id/name/version必填字段抛出Error
wirePlugins(createGame 内)
在 createGame 阶段把所有 Plugin 的 rules / actions / events 注册到对应子系统:
ts
private wirePlugins(): void {
for (const p of this.plugins.getAll()) {
for (const r of p.rules ?? []) {
try {
this.ruleEngine.register(r)
} catch (e) {
if (e instanceof DuplicateRuleError) {
throw new Error(`Plugin '${p.id}' failed to register rule '${r.id}': ${e.message}`)
}
throw e
}
}
for (const a of p.actions ?? []) {
this.actionExecutor.register(a)
}
for (const e of p.events ?? []) {
this.eventBus.on(e.type, (event) => {
if (this.destroyed) return
e.handle(event, this.context)
})
}
}
}要点:
- 重复 rule id 会以"插件 + rule id"信息重新抛出,便于定位冲突来源
- 重复 action type 抛出
DuplicateActionHandlerError - 事件处理器统一包裹在
destroyed检查中,防止引擎销毁后仍触发 Plugin 逻辑
setup 钩子
ts
private inSetupPhase = true
try {
for (const p of this.plugins.getAll()) {
p.setup?.(this.context)
}
// ... emit GAME_CREATED, pump queue, finish tick
} finally {
this.inSetupPhase = false
}setup在createGame时执行一次(回放会再次执行,故应保持幂等)- setup 阶段的 dispatch 不会被记入 ActionHistory
- 同一个 plugin 的 setup 在每次
createGame调用时只会执行一次(但同一引擎实例只能createGame一次)
setup 的幂等性
回放会重新执行 createGame,因此 setup 内的写入应通过存在性判断保持幂等。例如 StandardDeckPlugin:
ts
setup(ctx) {
if (!ctx.state.zones[STANDARD_DECK_ZONE_ID]) {
ctx.addZone(createZone({ id: STANDARD_DECK_ZONE_ID, type: 'deck' }))
}
for (const s of SUITS) {
for (const r of RANKS) {
const id = `std-${s.suit}-${r}`
if (ctx.state.cards[id]) continue // 已存在则跳过
ctx.addCard(createCard({ id, type: 'standard', suit: s.suit, rank: r, /* ... */ }))
ctx.addCardToZone(id, STANDARD_DECK_ZONE_ID)
}
}
}Plugin 如何访问 GameContext
GameContext 是 Plugin 与引擎交互的唯一许可入口。Plugin 在 setup、EventHandler 中,以及(经由 RuleContext)规则中都会收到一个 GameContext。
Mutation primitive
| 方法 | 说明 | version |
|---|---|---|
moveCard(cardId, fromZoneId, toZoneId) | 在两个 zone 间移动一张牌 | +1 |
setVariable(key, value) | 写入自定义变量 | +1 |
getVariable<T>(key) | 读取自定义变量 | — |
addPlayer(player) | 新增玩家 | +1 |
removePlayer(playerId) | 移除玩家 | +1 |
addCard(card) | 新增卡牌 | +1 |
removeCard(cardId) | 移除卡牌 | +1 |
addZone(zone) | 新增 zone | +1 |
removeZone(zoneId) | 移除 zone | +1 |
addCardToZone(cardId, zoneId) | 把卡牌加入 zone | +1 |
removeCardFromZone(cardId, zoneId) | 从 zone 移除卡牌 | +1 |
setStatus(status) | 设置对局状态 | +1 |
setCurrentPlayer(playerId) | 设置当前玩家 | +1 |
setTurn(turn) | 设置回合序号 | +1 |
addWinner(playerId) | 添加获胜者(幂等) | +1 |
每次调用单独 state.version + 1,与 Action handler 路径不同(后者引擎统一 bump 一次)。
查询方法
| 方法 | 说明 | 抛错 |
|---|---|---|
getPlayer(playerId) | 按 id 查询玩家 | 玩家不存在 |
getZone(zoneId) | 按 id 查询 zone | zone 不存在 |
getCard(cardId) | 按 id 查询卡牌 | 卡牌不存在 |
引擎委托方法
| 方法 | 说明 |
|---|---|
dispatch(action) | 入队新 Action,由引擎在当前 tick 内顺序处理 |
emit(event) | 发出 { type, payload, playerId? },引擎补全元数据 |
drawCards(playerId, count) | 抽牌快捷方法(构造 DRAW_CARD action 并 dispatch) |
状态校验注册
| 方法 | 说明 |
|---|---|
addStateCheck(name, check) | 注册游戏专属的不变式校验,同名会抛错 |
removeStateCheck(name) | 移除此前注册的 state check |
随机源
ts
const direction: TurnDirection = ctx.getVariable('direction') ?? 1
const shuffled: T[] = ctx.random.shuffle(arr) // 必须使用 ctx.randomctx.random 是引擎的 RandomProvider,是回放能力的关键 —— Plugin 必须将所有随机决策通过它进行,严禁使用 Math.random。
如何开发自定义 Plugin
最小可用插件
ts
import type { GamePlugin } from 'decklet'
const MyPlugin: GamePlugin = {
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0'
}注册到引擎:
ts
const engine = new GameEngine()
engine.use(MyPlugin).createGame({ players: [{ id: 'p1', name: 'Alice' }] })完整插件
ts
import {
type GamePlugin, type Rule, type ActionHandler,
ok, deny, createAction
} from 'decklet'
// 1. 定义规则
const alwaysAllow: Rule = {
id: 'my.allow',
name: 'Always Allow',
appliesTo: ['*'], // 或 ['PLAY_CARD'] 限定作用类型
validate: () => ok()
}
const myTurnRule: Rule = {
id: 'my.turn',
name: 'Player Turn',
appliesTo: ['PLAY_CARD'],
validate(c) {
if (c.state.currentPlayerId !== c.action.playerId) {
return deny('not your turn', 'NOT_YOUR_TURN')
}
return ok()
}
}
// 2. 定义 ActionHandler(纯函数)
const myAction: ActionHandler = {
type: 'MY_ACTION',
execute(action, ctx) {
const next = ctx.state // 直接读取 state
// 用 stateUtils 做不可变更新,返回新 state
// 不要自行修改 state.version —— 引擎会统一 bump
return {
state: next,
events: [{ type: 'MY_ACTION_DONE', payload: { /* ... */ } }]
}
}
}
// 3. 定义 EventHandler(响应已发生事件)
const onCardPlayed = {
type: 'CARD_PLAYED',
handle(event, ctx) {
ctx.setVariable('lastPlayedAt', event.timestamp)
// 链式 dispatch:触发新 action
ctx.dispatch(createAction({
type: 'MY_ACTION',
payload: { /* ... */ }
}))
}
}
// 4. 装配为插件
export const MyPlugin: GamePlugin = {
id: 'my-plugin',
name: 'My Plugin',
version: '1.0.0',
rules: [alwaysAllow, myTurnRule],
actions: [myAction],
events: [onCardPlayed],
setup(ctx) {
ctx.setVariable('initialized', true)
ctx.addZone(createZone({ id: 'my-zone', type: 'custom' }))
// 注册游戏专属的不变式
ctx.addStateCheck('my-invariant', (state) => {
// 返回非空字符串数组 = 状态非法
return []
})
}
}ActionHandler 约束
- 必须是纯函数(相同输入产出相同输出)
- 不得直接修改入参 state,应通过
stateUtils.*做不可变更新后返回新 state - 不要自行修改
state.version或事件元信息,由 GameEngine 统一处理 - 所有随机性必须经
ctx.random,禁止使用Math.random以保证可回放
EventHandler 约束
handle接收完整GameEvent与共享的GameContext- 内部调用
ctx.dispatch的链式 action 进入队尾(非递归),受maxActionsPerTick约束 - 不会被记入 ActionHistory(只记录顶级 dispatch)
setup 钩子的典型用途
- 创建 zone(牌堆 / 弃牌堆 / 玩家手牌)
- 初始化自定义 variables(如方向、阶段标记)
- 生成卡牌并放入 zone
- 注册
addStateCheck不变式 - 配置其他插件的可选行为(如 DrawPlugin 的
draw.reshuffleFrom)
内置插件一览
| 插件 | id | 主要职责 |
|---|---|---|
StandardDeckPlugin | standard-deck | 创建 52 张 French deck;GAME_STARTED 时派发 SHUFFLE action |
TurnPlugin | turn | 按 seat 升序轮转;direction variable 控制方向;GAME_STARTED 选首位玩家 |
DrawPlugin | draw | 处理 DRAW_CARD;可选 draw.reshuffleFrom 自动回收弃牌堆 |
SimpleGamePlugin | simple-game | PLAY_CARD handler + 胜负判定;GAME_STARTED 时每人发 5 张 |
PokerRulesPlugin | poker-rules | 把 CombinationDetector / Comparator / Resolver 以 variable 注入 |
UnoPlugin | uno | 完整 UNO 规则 |
DoudizhuPlugin | doudizhu | 完整斗地主规则 |
PluginManager API
ts
class PluginManager {
use(plugin: GamePlugin): this // 注册插件
remove(pluginId: string): boolean // 按 id 移除
get(pluginId: string): GamePlugin | undefined // 按 id 查找
getOrThrow(pluginId: string): GamePlugin // 按 id 查找(抛错)
getAll(): GamePlugin[] // 浅拷贝列表(插入顺序)
has(pluginId: string): boolean // 是否已注册
clear(): void // 清空所有
get size(): number // 已注册数量
}