Skip to content

游戏生命周期 ​

本文档说明 GameEngine 对局的生命周期:waiting → playing → finished,以及 createGame / start / dispatch 期间的 setup phase 行为、tick 与队列、event handler 链式 dispatch、maxActionsPerTick 熔断等机制。

状态机 ​

        createGame              start                 addWinner + setStatus
            │                     │                          │
            ▼                     ▼                          ▼
        ┌────────┐ ──start──▶ ┌────────┐ ──GAME_WON──▶ ┌──────────┐
        │waiting │             │playing │                │ finished │
        └────────┘             └────────┘                └──────────┘
                                  │
                                  │  生命周期之外还可通过 ctx.setStatus('paused')
                                  ▼  暂停(业务侧干预用)
                              ┌────────┐
                              │ paused │
                              └────────┘

GameStatus 取值:

状态含义
waiting已创建未开始,玩家可加入
playing进行中,可正常 dispatch
paused暂停,外部业务可在此状态做快照或干预
finished已结束,winnerIds 已确定

createGame ​

ts
engine.createGame(options: CreateGameOptions): this

createGame 完整流程:

  1. 校验前置条件

    • 引擎未销毁(assertNotDestroyed)
    • 对局未创建(gameCreated === false)
    • 玩家列表非空
    • 座位号不重复
  2. 解析玩家

    • 对每个 CreateGamePlayerInit 调用 createPlayer,按 seat ?? index + 1 填充座位
    • 同一局内座位号不可重复,否则抛错
  3. 写入初始 GameState

    • 解析种子:options.seed ?? config.seed ?? generateRandomSeed()
    • 通过 createGameState 把数组形式的 players 转为以 id 为键的 Record
  4. wirePlugins

    • 把所有 Plugin 的 rules / actions / events 注册到对应子系统(RuleEngine / ActionExecutor / EventBus)
    • 重复 rule id 会以"插件 + rule id"信息重新抛出
    • 事件处理器统一包裹在 destroyed 检查中
  5. 进入 setup 阶段 (inSetupPhase = true)

    • 调用每个插件的 setup(context)
    • 标记 gameCreated = true
    • startTick 重置 tick 缓冲
    • 发出 GAME_CREATED 事件(payload: { gameId })
    • pumpIfIdle 触发队列处理
    • finishTick 结束 tick
  6. 退出 setup 阶段 (inSetupPhase = false)

  7. 状态校验 — 调用 maybeValidate('createGame')

setup 阶段的 dispatch ​

setup 阶段内由 event handler 触发的 dispatch 属于引擎初始化,不会被记入 ActionHistory。这是回放可行的关键之一 —— 回放通过重新执行 createGame + start 复现这些初始化派发。

抛错场景 ​

条件错误信息
引擎已销毁EngineDestroyedError
对局已创建Game already created; create a new GameEngine instance instead
玩家为空Cannot create a game with no players
座位重复Duplicate seat ${seat} for player ${id}

start ​

ts
engine.start(): this

把对局状态从 waiting 切换到 playing,并发出 GAME_STARTED 事件。

  1. 校验前置条件(未销毁 / 已创建 / 当前状态必须是 waiting)
  2. setState(prev => bumpVersion(setStatusUtil(prev, 'playing')))
  3. 进入 setup 阶段 → startTick → emit GAME_STARTED → pumpIfIdle → finishTick
  4. 退出 setup 阶段
  5. maybeValidate('start')

GAME_STARTED 处理器中触发的链式 dispatch(例如初始发牌)不会被记入 ActionHistory,回放时通过重新执行 start 复现。

抛错场景 ​

条件错误信息
引擎已销毁EngineDestroyedError
对局未创建Game has not been created yet; call createGame() first
状态非 waitingCannot start game in status: ${status}

dispatch ​

ts
engine.dispatch(action: Action): DispatchResult

顶级 vs 链式 dispatch ​

引擎通过 processing 标志位区分:

  • 顶级 dispatch — 调用方直接调用 engine.dispatch(action)。同步处理整个 tick 并返回完整结果。
  • 链式 dispatch — EventHandler 内调用 ctx.dispatch(action)。仅入队并返回占位结果,实际处理由外层 tick 完成。
ts
const wasTopLevel = !this.processing
if (wasTopLevel) {
  this.startTick()                  // 重置 tick 缓冲
  if (!this.inSetupPhase) {
    this.topLevelActionIds.add(action.id)  // 标记为顶级
  }
}
this.queue.push(action)
if (wasTopLevel) {
  this.pump()                       // 主循环消费队列
  this.finishTick()
}
return this.buildDispatchResult(action, wasTopLevel)

ActionHistory 记录策略 ​

引擎只记录顶级 dispatch,不记录 event handler 通过 ctx.dispatch 入队的链式 action。这是回放可行的关键:

当 ReplayEngine 重新派发一个顶级 action 时,引擎自身的 event handler 会自动复现相同的链式 action,因此历史中绝不能包含这些链式 action(否则回放会重复派发)。

setup 阶段方法(createGame / start)内的顶级 dispatch 也不会被记入历史(inSetupPhase = true 时跳过 topLevelActionIds.add),回放通过重新执行 start 复现。

processAction 流程 ​

处理单个 Action 的核心步骤:

  1. RuleEngine 校验

    • ruleEngine.validate({ state, action, game: context })
    • 拒绝时直接返回,state 不变(allowed: false, denyReason, denyCode)
  2. ActionExecutor 执行

    • actionExecutor.execute(state, action, randomProvider)
    • 返回 { state, events }
    • 执行抛错时返回 error,state 不变
  3. bump state.version

    • this.state = { ...nextState, version: this.state.version + 1 }
  4. 同步触发 EventBus

    • 把每个 EventInit 通过 eventFromInit 补全为 GameEvent(盖 id / timestamp / stateVersion)
    • 推入 tickEvents 缓冲并 eventBus.emit(event)
    • EventHandler 内的 dispatch 进入队尾(由 pump 继续消费)
  5. 追加 ActionHistory(仅顶级 dispatch)

    • 记录 { action, stateVersionBefore, stateVersionAfter, timestamp }
    • 引擎按 1-based 自增分配 sequence
  6. 状态校验 — maybeValidate('dispatch')

tick 与队列机制 ​

startTick ​

ts
private startTick(): void {
  this.tickEvents = []
  this.tickProcessed = []
  this.actionsThisTick = 0
  this.topLevelActionIds = new Set()
}

在每次顶级 dispatch 与 setup 阶段入口处调用,重置 tick 缓冲。

pump 主循环 ​

ts
private pump(): void {
  if (this.processing) return  // 防止重入
  this.processing = true
  try {
    while (this.queue.length > 0) {
      if (this.actionsThisTick >= this.config.maxActionsPerTick) {
        throw new ActionQueueOverflowError(this.config.maxActionsPerTick, this.queue.length)
      }
      const action = this.queue.shift()!
      this.actionsThisTick++
      const processed = this.processAction(action)
      this.tickProcessed.push(processed)
    }
  } finally {
    this.processing = false
  }
}

通过 processing 标志位防止 EventHandler 内的 dispatch 重新进入循环 —— 那种情况只是把新 Action 推入队尾,由当前循环继续消费,从而把"递归 dispatch"转化为"迭代处理",避免调用栈无限增长。

pumpIfIdle ​

ts
private pumpIfIdle(): void {
  if (!this.processing) {
    this.pump()
  }
}

当引擎处于空闲(未在 pump 循环中)时启动 pump;否则由当前 pump 自然消费队列。setup 阶段使用。

maxActionsPerTick 熔断 ​

ts
export class ActionQueueOverflowError extends Error {
  constructor(public readonly limit: number, public readonly remaining: number) {
    super(`maxActionsPerTick (${limit}) exceeded; ${remaining} actions remain in queue`)
    this.name = 'ActionQueueOverflowError'
  }
}

当一次 tick 内累计处理的 Action 数量超过 maxActionsPerTick(默认 1000)时抛出。用于打断 EventHandler 链式 dispatch 引发的意外无限循环。

可由 GameConfigInit.maxActionsPerTick 覆盖:

ts
new GameEngine({ maxActionsPerTick: 500 })

DispatchResult ​

ts
export interface DispatchResult {
  ok: boolean
  action: Action
  denyReason?: string
  denyCode?: string
  error?: string
  events: GameEvent[]
  processedActions: ProcessedAction[]
}
字段说明
ok是否通过 Rule 校验且无异常
action派发的 Action
denyReasonRule 拒绝原因(拒绝时填充)
denyCode稳定错误码(拒绝时填充,可选)
error执行阶段抛错的 message(执行抛错时填充)
events本 tick 内发出的事件
processedActions本 tick 内处理的所有 Action(含链式)

链式 dispatch(非顶级)仅返回占位结果:

ts
return {
  action,
  ok: true,
  events: [],
  processedActions: []
}

状态校验(maybeValidate) ​

ts
private maybeValidate(_phase: string): void {
  if (this.validationMode === 'none') return
  const result = this.stateValidator.validate(this.state)
  if (!result.valid) {
    throw new StateValidationError(
      `State validation failed after ${_phase}: ${result.errors.join('; ')}`,
      result.errors
    )
  }
}

在 createGame / start / dispatch / restoreSnapshot 后调用。_phase 仅作为错误信息上下文使用,不影响校验逻辑。

ValidationMode 取值:

模式行为
none从不校验(适用于生产热路径)
development与 always 相同(别名)
always在 createGame、start、dispatch、restoreSnapshot 后校验(默认)

快照与恢复 ​

ts
// 创建快照(深拷贝,隔离调用方引用)
const snapshot: GameSnapshot = engine.createSnapshot()

// 从快照恢复
engine.restoreSnapshot(snapshot)

restoreSnapshot 实现:

  • 对快照做深拷贝以隔离调用方持有的对象引用
  • 清空 ActionQueue,避免已入队的链式 dispatch 被应用到恢复后的旧状态上
  • 调用 maybeValidate('restoreSnapshot')

销毁 ​

ts
engine.destroy(): void

清理 EventBus / Plugin / RuleEngine / 队列 / 历史 / 校验器,并设置 destroyed = true。后续任何公共 API 调用都会抛出 EngineDestroyedError。

完整示例 ​

ts
import {
  GameEngine, StandardDeckPlugin, TurnPlugin, DrawPlugin, SimpleGamePlugin,
  createSimplePlayCardAction, playerHandZoneId
} from 'decklet'

const engine = new GameEngine({ seed: 123456 })

engine.subscribeAll((e) => console.log(`[event] ${e.type}`))

engine
  .use(StandardDeckPlugin)
  .use(TurnPlugin)
  .use(DrawPlugin)
  .use(SimpleGamePlugin)
  .createGame({
    players: [
      { id: 'p1', name: 'Alice', seat: 1 },
      { id: 'p2', name: 'Bob', seat: 2 }
    ]
  })
  .start()  // 触发 GAME_STARTED → 洗牌 + 初始发牌

while (engine.getState().status === 'playing') {
  const id = engine.getState().currentPlayerId!
  const hand = engine.getState().zones[playerHandZoneId(id)]!
  if (hand.cards.length === 0) break
  engine.dispatch(createSimplePlayCardAction(id, [hand.cards[0]]))
}

console.log(`winners: ${engine.getState().winnerIds.join(', ')}`)
engine.destroy()

下一步 ​