游戏生命周期
本文档说明 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
engine.createGame(options: CreateGameOptions): thiscreateGame 完整流程:
校验前置条件
- 引擎未销毁(
assertNotDestroyed) - 对局未创建(
gameCreated === false) - 玩家列表非空
- 座位号不重复
- 引擎未销毁(
解析玩家
- 对每个
CreateGamePlayerInit调用createPlayer,按seat ?? index + 1填充座位 - 同一局内座位号不可重复,否则抛错
- 对每个
写入初始 GameState
- 解析种子:
options.seed ?? config.seed ?? generateRandomSeed() - 通过
createGameState把数组形式的 players 转为以 id 为键的Record
- 解析种子:
wirePlugins
- 把所有 Plugin 的 rules / actions / events 注册到对应子系统(
RuleEngine/ActionExecutor/EventBus) - 重复 rule id 会以"插件 + rule id"信息重新抛出
- 事件处理器统一包裹在
destroyed检查中
- 把所有 Plugin 的 rules / actions / events 注册到对应子系统(
进入 setup 阶段 (
inSetupPhase = true)- 调用每个插件的
setup(context) - 标记
gameCreated = true startTick重置 tick 缓冲- 发出
GAME_CREATED事件(payload:{ gameId }) pumpIfIdle触发队列处理finishTick结束 tick
- 调用每个插件的
退出 setup 阶段 (
inSetupPhase = false)状态校验 — 调用
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
engine.start(): this把对局状态从 waiting 切换到 playing,并发出 GAME_STARTED 事件。
- 校验前置条件(未销毁 / 已创建 / 当前状态必须是
waiting) setState(prev => bumpVersion(setStatusUtil(prev, 'playing')))- 进入 setup 阶段 →
startTick→ emitGAME_STARTED→pumpIfIdle→finishTick - 退出 setup 阶段
maybeValidate('start')
GAME_STARTED 处理器中触发的链式 dispatch(例如初始发牌)不会被记入 ActionHistory,回放时通过重新执行 start 复现。
抛错场景
| 条件 | 错误信息 |
|---|---|
| 引擎已销毁 | EngineDestroyedError |
| 对局未创建 | Game has not been created yet; call createGame() first |
状态非 waiting | Cannot start game in status: ${status} |
dispatch
engine.dispatch(action: Action): DispatchResult顶级 vs 链式 dispatch
引擎通过 processing 标志位区分:
- 顶级 dispatch — 调用方直接调用
engine.dispatch(action)。同步处理整个 tick 并返回完整结果。 - 链式 dispatch — EventHandler 内调用
ctx.dispatch(action)。仅入队并返回占位结果,实际处理由外层 tick 完成。
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 的核心步骤:
RuleEngine 校验
ruleEngine.validate({ state, action, game: context })- 拒绝时直接返回,state 不变(
allowed: false, denyReason, denyCode)
ActionExecutor 执行
actionExecutor.execute(state, action, randomProvider)- 返回
{ state, events } - 执行抛错时返回
error,state 不变
bump state.version
this.state = { ...nextState, version: this.state.version + 1 }
同步触发 EventBus
- 把每个
EventInit通过eventFromInit补全为GameEvent(盖id/timestamp/stateVersion) - 推入
tickEvents缓冲并eventBus.emit(event) - EventHandler 内的 dispatch 进入队尾(由 pump 继续消费)
- 把每个
追加 ActionHistory(仅顶级 dispatch)
- 记录
{ action, stateVersionBefore, stateVersionAfter, timestamp } - 引擎按 1-based 自增分配
sequence
- 记录
状态校验 —
maybeValidate('dispatch')
tick 与队列机制
startTick
private startTick(): void {
this.tickEvents = []
this.tickProcessed = []
this.actionsThisTick = 0
this.topLevelActionIds = new Set()
}在每次顶级 dispatch 与 setup 阶段入口处调用,重置 tick 缓冲。
pump 主循环
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
private pumpIfIdle(): void {
if (!this.processing) {
this.pump()
}
}当引擎处于空闲(未在 pump 循环中)时启动 pump;否则由当前 pump 自然消费队列。setup 阶段使用。
maxActionsPerTick 熔断
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 覆盖:
new GameEngine({ maxActionsPerTick: 500 })DispatchResult
export interface DispatchResult {
ok: boolean
action: Action
denyReason?: string
denyCode?: string
error?: string
events: GameEvent[]
processedActions: ProcessedAction[]
}| 字段 | 说明 |
|---|---|
ok | 是否通过 Rule 校验且无异常 |
action | 派发的 Action |
denyReason | Rule 拒绝原因(拒绝时填充) |
denyCode | 稳定错误码(拒绝时填充,可选) |
error | 执行阶段抛错的 message(执行抛错时填充) |
events | 本 tick 内发出的事件 |
processedActions | 本 tick 内处理的所有 Action(含链式) |
链式 dispatch(非顶级)仅返回占位结果:
return {
action,
ok: true,
events: [],
processedActions: []
}状态校验(maybeValidate)
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 后校验(默认) |
快照与恢复
// 创建快照(深拷贝,隔离调用方引用)
const snapshot: GameSnapshot = engine.createSnapshot()
// 从快照恢复
engine.restoreSnapshot(snapshot)restoreSnapshot 实现:
- 对快照做深拷贝以隔离调用方持有的对象引用
- 清空 ActionQueue,避免已入队的链式 dispatch 被应用到恢复后的旧状态上
- 调用
maybeValidate('restoreSnapshot')
销毁
engine.destroy(): void清理 EventBus / Plugin / RuleEngine / 队列 / 历史 / 校验器,并设置 destroyed = true。后续任何公共 API 调用都会抛出 EngineDestroyedError。
完整示例
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()