Skip to content

插件系统 ​

本文档说明 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[]
}
字段类型必填说明
idstring是插件唯一标识,用于去重注册与依赖识别
namestring是人类可读的插件名
versionstring是语义化版本号,便于兼容性判断
setup(context: GameContext) => void否引擎初始化阶段执行的一次性钩子
rulesRule[]否该插件提供的规则列表,用于 Action 合法性校验
actionsActionHandler[]否该插件提供的 ActionHandler 列表
eventsEventHandler[]否该插件提供的 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 查询 zonezone 不存在
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.random

ctx.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主要职责
StandardDeckPluginstandard-deck创建 52 张 French deck;GAME_STARTED 时派发 SHUFFLE action
TurnPluginturn按 seat 升序轮转;direction variable 控制方向;GAME_STARTED 选首位玩家
DrawPlugindraw处理 DRAW_CARD;可选 draw.reshuffleFrom 自动回收弃牌堆
SimpleGamePluginsimple-gamePLAY_CARD handler + 胜负判定;GAME_STARTED 时每人发 5 张
PokerRulesPluginpoker-rules把 CombinationDetector / Comparator / Resolver 以 variable 注入
UnoPluginuno完整 UNO 规则
DoudizhuPlugindoudizhu完整斗地主规则

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                                                // 已注册数量
}

下一步 ​