GameService
游戏业务入口,协调会话生命周期、状态序列化、事件广播。
概述
GameService 是 Server Application 层的核心服务,负责:
- 开始游戏(从 Room 创建 GameSession、绑定事件 publisher、调用
engine.start) - 执行玩家 Action(串行化、幂等、广播 per-player state)
- 查询玩家视角的
PublicGameState - 结束 / 销毁游戏
不负责:
- 游戏规则(由 Plugin 封装)
- HTTP response / Socket frame(由 Controller / Gateway 处理)
GameServiceDeps
interface GameServiceDeps {
sessionService: GameSessionService
roomRepository: RoomRepository
serializerRegistry: SerializerRegistry
eventSerializer: GameEventSerializer
broadcaster: BroadcastPort
}| 字段 | 类型 | 说明 |
|---|---|---|
sessionService | GameSessionService | 会话生命周期管理 |
roomRepository | RoomRepository | 关联房间状态 |
serializerRegistry | SerializerRegistry | PublicGameState 转换 |
eventSerializer | GameEventSerializer | 事件脱敏 |
broadcaster | BroadcastPort | socket 推送端口 |
类
GameService
class GameService {
constructor(deps: GameServiceDeps)
}方法
startGame()
async startGame(room: Room): Promise<GameSession>开始游戏:从 Room 创建 GameSession、绑定事件 publisher、调用 engine.start。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
room | Room | 待开始的房间(需满足 room.canStart) |
返回值
Promise<GameSession>:创建并启动的会话。
行为
- 校验
room.canStart,不满足则抛ServerError('GAME_NOT_STARTED', ..., 400)。 sessionService.createSession(room):构造 engine 并engine.createGame()。- 创建
GameEventPublisher并attach(session.engine)订阅引擎事件。 room.setGameSession(session.id)+room.status = 'PLAYING'+roomRepository.save(room)。session.engine.start():触发GAME_STARTED事件链(publisher 实时广播)。session.status = 'RUNNING'。broadcaster.broadcastToRoom(room.id, 'game:started', { gameId, roomId, gameType, playerIds, stateVersion })。broadcastSyncToPlayers(session):向每位玩家发送初始game:sync。
executeAction()
async executeAction(
gameId: string,
playerId: string,
actionId: string,
actionSpec: { type: string; payload?: unknown }
): Promise<ActionResult>执行玩家 Action。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
playerId | string | 玩家 ID(由服务器注入,不可来自客户端 payload) |
actionId | string | 客户端 Action 幂等键 |
actionSpec | { type: string; payload?: unknown } | Action 类型与负载 |
返回值
Promise<ActionResult>:成功形态含 stateVersion;失败形态含 error.code / error.message。
行为
关键点:
- playerId 由服务器注入:来自调用方(Gateway 从
socket.data.playerId取),客户端 payload 中即使带playerId也会被丢弃。 - 同会话内 Action 严格 FIFO 串行:通过
session.enqueue进入会话级 ActionQueue。 - actionId 幂等:重复请求直接返回首次结果(队列外预检 + 队列内再次检查,防并发同 actionId)。
- 失败结果同样记入幂等缓存:重复的失败请求也直接返回首次错误。
- 成功后向每位玩家发送
game:state(per-player view)。
流程:
sessionService.getSessionOrThrow(gameId)取出会话;若session.isClosed()抛GAME_NOT_FOUND。- 校验
session.hasPlayer(playerId),否则抛PLAYER_NOT_IN_GAME。 - 幂等预检(enqueue 之外,避免无谓排队):
session.getProcessedResult(actionId)命中则直接返回。 session.enqueue(async () => { ... }):- 队列内再次检查幂等。
- 构造
Action,强制注入服务器侧playerId。 session.dispatch(action)→ 引擎同步执行(RuleEngine → ActionHandler → Effect → EventBus)。- 成功:构造
ActionResultSuccess,若engineStatus === 'finished'则session.markFinished()。 - 失败(
dispatchResult.ok === false):构造ActionResultFailure,error.code = denyCode ?? 'INVALID_ACTION'。 - 异常:构造
ActionResultFailure,error.code = 'INTERNAL_ERROR'。 session.recordProcessed(actionId, result)。- 成功时
broadcastSyncToPlayers(session)。
getGameState()
async getGameState(gameId: string, playerId: string): Promise<PublicGameState>取玩家视角的 PublicGameState。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
playerId | string | 玩家 ID |
返回值
Promise<PublicGameState>:
- 在游戏内的玩家:返回
serializeForPlayer(state, playerId)(含myHand)。 - 其他玩家(观战 / 未加入):返回
serializePublic(state)(无myHand)。
行为
通过 serializerRegistry.get(session.gameType) 取出该游戏类型的专属序列化器,调用对应方法。
sync()
async sync(gameId: string, playerId: string): Promise<GameSyncResponse>game:sync 响应:返回玩家视角的 PublicGameState + stateVersion。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
playerId | string | 玩家 ID |
返回值
Promise<GameSyncResponse>:{ gameId, stateVersion, state }。
行为
内部调用 getGameState 并包装为 GameSyncResponse。
getGameInfo()
async getGameInfo(gameId: string): Promise<{
gameId: string
roomId: string
gameType: string
playerIds: string[]
status: string
stateVersion: number
createdAt: number
updatedAt: number
}>取对局基础信息(不含敏感 state)。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
返回值
Promise<{ gameId, roomId, gameType, playerIds, status, stateVersion, createdAt, updatedAt }>:对局元信息。
finishGame()
async finishGame(gameId: string): Promise<void>结束游戏:标记 GameSession 为 FINISHED,更新 Room 状态。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
返回值
Promise<void>
行为
引擎内部已经因 Rule 判定进入 finished;本方法同步外部状态:
session.markFinished()。- 找到关联 Room,
room.status = 'FINISHED'+roomRepository.save(room)。 broadcaster.broadcastToGame(gameId, 'game:finished', { gameId, roomId, stateVersion, winnerIds })。
removeGame()
async removeGame(gameId: string): Promise<void>销毁会话:清理 publisher 订阅 + 委托 sessionService.removeSession。
参数
| 名称 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
返回值
Promise<void>
行为
- 取出 publisher 的
unsubscribe函数并调用(停止事件转发)。 sessionService.removeSession(gameId)(内部session.close()drain 队列 + destroy engine + 从仓储删除)。
broadcastSyncToPlayers()(私有)
private async broadcastSyncToPlayers(session: GameSession): Promise<void>向每位玩家发送 game:state(per-player view)。断线玩家不发送(其重连时通过 game:sync 恢复)。
行为
- 取出
serializer = serializerRegistry.get(session.gameType)。 - 遍历
session.playerIds:- 若
broadcaster.isPlayerConnected(playerId)为false则跳过。 - 否则
view = serializer.serializeForPlayer(state, playerId)。 broadcaster.sendToPlayer(playerId, 'game:state', { gameId, stateVersion, state: view })。
- 若
关键流程图
executeAction(gameId, playerId, actionId, actionSpec)
│
▼
sessionService.getSessionOrThrow(gameId)
│
├─ session.isClosed() → throw GAME_NOT_FOUND
├─ !session.hasPlayer(playerId) → throw PLAYER_NOT_IN_GAME
│
▼
session.getProcessedResult(actionId) // 幂等预检
│
├─ hit → 直接返回 cached
│
▼
session.enqueue(async () => {
session.getProcessedResult(actionId) // 队列内再检
│
▼
session.dispatch(action) // engine.dispatch 同步执行
│
├─ RuleEngine → 拒绝?
│ └─ result = { success: false, error: { code, message } }
│
├─ ActionHandler → Effect → EventBus → GameEventPublisher
│ │
│ └─ broadcaster.broadcastToGame('game:event', ...)
│
└─ result = { success: true, stateVersion }
│
▼
session.recordProcessed(actionId, result)
│
▼
result.success? broadcastSyncToPlayers(session)
│
└─ sendToPlayer('game:state', per-player view)
})示例
import { createServer } from 'decklet/server'
const runtime = createServer()
await runtime.start()
const { gameService, roomService } = runtime
// 1) 创建房间并加入 3 名玩家
const owner = await roomService.createRoom(
{ gameType: 'uno', maxPlayers: 4 },
'p1'
)
await roomService.joinRoom(owner.id, 'p2')
await roomService.joinRoom(owner.id, 'p3')
// 2) 开始游戏
const { room, gameId } = await roomService.startRoom(owner.id)
// 3) 执行玩家 Action
const result = await gameService.executeAction(
gameId,
'p1', // 由服务器注入的 playerId
'client_abc123', // 客户端生成的 actionId
{ type: 'PLAY_CARD', payload: { cardIds: ['<cardId>'] } }
)
console.log(result.success, result.stateVersion)
// 4) 查询玩家视角
const state = await gameService.getGameState(gameId, 'p1')
console.log(state.myHand) // 该玩家手牌
console.log(state.players) // 所有玩家(仅 cardCount)
// 5) 结束游戏(通常由 Rule 判定自动触发,此处为手动示例)
// await gameService.finishGame(gameId)
await runtime.close()注意事项
playerId必须由服务器注入——GameGateway从socket.data.playerId取,GameController从playerIdentityMiddleware注入的ctx.state.playerId取。客户端 payload 中即使带playerId也会被丢弃。executeAction是异步串行的——同一会话内 Action 严格 FIFO,不同会话间互不阻塞。- 幂等缓存有上限(默认 500 个 actionId,FIFO 驱逐);超长会话需注意可能的重复执行。
broadcastSyncToPlayers跳过断线玩家——其重连时通过game:sync恢复。engine.start()期间派发的初始事件(GAME_STARTED等)会被 publisher 广播到game:${gameId}房间,但此时玩家 socket 尚未加入该房间——这些初始事件会丢失。客户端通过game:started+game:state重建初始视图。