Session
Server domain 层的会话相关实体:Room / GameSession / PlayerSession / GameActionQueue 与状态枚举。
概述
会话层是 Server Runtime 的核心——承载玩家连接、房间生命周期、对局执行队列。
| 实体 | 职责 |
|---|---|
Room | 玩家加入与开始游戏的房间容器 |
GameSession | 一局对局的生命周期包装器,持有 GameEngine |
PlayerSession | 玩家与服务器之间的连接状态 |
GameActionQueue | 每会话 Action 串行化队列 |
GameSessionService / PlayerSessionService | 应用层服务(生命周期 / 连接管理) |
状态枚举
RoomStatus
type RoomStatus = 'WAITING' | 'READY' | 'PLAYING' | 'FINISHED' | 'CLOSED'Room 生命周期:
| 状态 | 说明 |
|---|---|
WAITING | 已创建,等待玩家加入 |
READY | 人数满足,可开始 |
PLAYING | 已开局,关联的 GameSession 运行中 |
FINISHED | 关联的 GameSession 已结束 |
CLOSED | 房间关闭,不再可用 |
GameSessionStatus
type GameSessionStatus = 'CREATED' | 'RUNNING' | 'FINISHED' | 'CLOSED'GameSession 生命周期:
| 状态 | 说明 |
|---|---|
CREATED | 已创建 Engine,尚未 start |
RUNNING | engine.start() 已调用 |
FINISHED | engine.state.status === 'finished' |
CLOSED | 主动销毁,释放资源 |
与引擎 GameStatus 解耦
引擎 GameStatus(waiting / playing / paused / finished)描述单局技术状态;RoomStatus / GameSessionStatus 是服务器侧生命周期概念。两者通过 GameSession 桥接(GameSession.engine.state.status 即对局技术状态)。
Room
玩家加入与开始游戏的房间容器。
RoomInit
interface RoomInit {
id: string
gameType: string
ownerId: string
playerIds?: string[]
maxPlayers: number
minPlayers?: number
status?: RoomStatus
gameSessionId?: string
createdAt?: number
meta?: Record<string, unknown>
}属性/字段
| 名称 | 类型 | 说明 |
|---|---|---|
id | readonly string | 房间 ID |
gameType | readonly string | 游戏类型 |
ownerId | string | 房主 ID(可变更) |
playerIds | readonly string[] | 玩家 ID 列表(可 push) |
maxPlayers | readonly number | 最大玩家数 |
minPlayers | readonly number | 最少开始人数(缺省 2) |
status | RoomStatus | 当前状态(缺省 WAITING) |
gameSessionId? | string | 关联的对局 ID |
createdAt | readonly number | 创建时间戳 |
meta | readonly Record<string, unknown> | 元数据 |
计算属性
| 名称 | 类型 | 说明 |
|---|---|---|
currentPlayers | number | playerIds.length |
isFull | boolean | playerIds.length >= maxPlayers |
canJoin | boolean | status === 'WAITING' && !isFull |
canStart | boolean | (status === 'WAITING' || status === 'READY') && playerIds.length >= minPlayers |
方法
hasPlayer(playerId: string): boolean
addPlayer(playerId: string): boolean // 已存在 no-op;已满抛错
removePlayer(playerId: string): boolean // 不存在 no-op
setOwner(playerId: string): void // 新房主必须在玩家列表中
setGameSession(gameSessionId: string | undefined): void
toJSON(): RoomInitGameSession
服务器侧一局对局的生命周期包装器。
GameSessionInit
interface GameSessionInit {
id: string
roomId: string
gameType: string
engine: GameEngine
playerIds: string[]
status?: GameSessionStatus
createdAt?: number
}属性/字段
| 名称 | 类型 | 说明 |
|---|---|---|
id | readonly string | 会话 ID |
roomId | readonly string | 所属房间 ID |
gameType | readonly string | 游戏类型 |
engine | readonly GameEngine | 引擎实例 |
playerIds | readonly readonly string[] | 参与玩家 ID 列表(拷贝) |
status | GameSessionStatus | 缺省 CREATED |
createdAt | readonly number | 创建时间戳 |
updatedAt | number | 最近更新时间戳 |
计算属性
| 名称 | 类型 | 说明 |
|---|---|---|
stateVersion | number | 引擎 state.version(单调递增) |
engineStatus | string | 引擎当前 GameStatus |
方法
hasPlayer()
hasPlayer(playerId: string): booleangetProcessedResult()
getProcessedResult(actionId: string): ActionResult | undefined已处理过的 actionId 直接返回原结果,保证幂等。
recordProcessed()
recordProcessed(actionId: string, result: ActionResult): void记录已处理 actionId 与结果。超过上限(默认 500,DEFAULT_MAX_PROCESSED)时按 FIFO 驱逐最旧。成功与失败的 actionId 都会被记录——重复的失败请求也直接返回首次错误。
enqueue()
enqueue<T>(task: () => Promise<T>): Promise<T>串行执行 task;同会话内严格 FIFO,不同会话间互不阻塞。task 内部捕获所有异常并返回;仅系统级异常才会 reject。
drain()
async drain(): Promise<void>等待队列排空(用于优雅关闭)。
isFinished() / isClosed()
isFinished(): boolean // engine.status === 'finished' || status === 'FINISHED'
isClosed(): boolean // status === 'CLOSED'markFinished()
markFinished(): void标记为 FINISHED(由 GameService 在对局结束时调用)。
close()
async close(): Promise<void>销毁会话:先 drain() 队列,再 engine.destroy(),清空 processedActions,置 status = 'CLOSED'。
setPendingBroadcastEvents() / consumePendingBroadcastEvents()
setPendingBroadcastEvents(events: GameEvent[]): void
consumePendingBroadcastEvents(): GameEvent[]暂存 / 取出并清空一次顶级 dispatch 产生的全部事件(含链式)。
dispatch()
dispatch(action: Action): DispatchResult仅供 ActionExecutor 路径返回值构造使用——直接 engine.dispatch(action) 并返回结果。
PlayerSession
玩家与服务器之间的连接状态。
PlayerSessionInit
interface PlayerSessionInit {
playerId: string
socketId?: string
roomId?: string
gameId?: string
connected?: boolean
lastConnectedAt?: number
name?: string
}属性/字段
| 名称 | 类型 | 说明 |
|---|---|---|
playerId | readonly string | 玩家身份(不变) |
socketId? | string | 当前 socket 连接 ID(断线清空) |
roomId? | string | 当前所在房间 ID |
gameId? | string | 当前对局 ID |
connected | boolean | 是否已连接(缺省 false) |
lastConnectedAt | number | 最近连接时间戳 |
name | string | 显示名(缺省取 playerId) |
方法
bindSocket(socketId: string): void // 重连时调用,socketId 更新、connected=true
unbindSocket(): void // 断线时调用,socketId 清空、connected=false
joinRoom(roomId: string): void
leaveRoom(): void
joinGame(gameId: string): void
leaveGame(): void
isDisconnectTimedOut(gracePeriodMs: number, now?: number): boolean
toJSON(): PlayerSessionInitGameActionQueue
每会话 Action 串行化队列。
设计原则
- 同一
GameSession内任何时刻最多只有一个 Action 在执行。 - 维护一个
tailPromise,每次enqueue都把新 task 链到 tail 末尾。 - task 内部的
await间隙不会让另一个enqueue的 task 提前执行——它们必须等当前 tail 完成。 - 不同
GameSession持有各自的队列实例,互不阻塞(不使用全局锁)。
type AsyncTask<T> = () => Promise<T>
class GameActionQueue {
enqueue<T>(task: AsyncTask<T>): Promise<T>
async drain(): Promise<void>
}enqueue()
入队一个 async 任务并返回其结果 Promise。任务抛出的异常会传递给返回的 Promise(调用方可 catch),但不会打断 tail 链——后续 enqueue 的任务仍会执行。
drain()
等待队列排空。用于优雅关闭:确保所有在途 Action 完成后再销毁 session。
GameSessionService
GameSession 实体的生命周期管理。
CreateSessionOptions
interface CreateSessionOptions {
gameSessionId?: string
seed?: number
}| 字段 | 类型 | 说明 |
|---|---|---|
gameSessionId? | string | 由调用方提供的 id;缺省自动生成 game_ 前缀 id |
seed? | number | 引擎种子;缺省由 GameEngine 自动生成 |
类
class GameSessionService {
constructor(
sessionRepository: GameSessionRepository,
pluginRegistry: PluginRegistry
)
}方法
createSession()
async createSession(
room: Room,
options?: CreateSessionOptions
): Promise<GameSession>创建一局对局。
流程:
pluginRegistry.get(room.gameType)取出完整 Plugin 栈;为空抛ServerError('PLUGIN_NOT_FOUND', '...', 404)。- 构造
GameEngine({ seed, gameId })。 - 依次
engine.use(plugin)装配所有 Plugin。 - 把 Room 的
playerIds转为[{ id, name: id, seat: i+1 }],调用engine.createGame({ players, gameId })。 - 包装为
GameSession实体保存到仓储。
不调用 engine.start()
createSession 不调用 engine.start()——开始游戏由 GameService.startGame 触发,因为开始游戏是业务事件,需要广播 GAME_STARTED 给客户端。
getSession() / getSessionOrThrow()
async getSession(id: string): Promise<GameSession | null>
async getSessionOrThrow(id: string): Promise<GameSession>getSessionOrThrow 在未找到时抛 ServerError('GAME_NOT_FOUND', 'Game not found: ${id}', 404)。
findByRoomId()
async findByRoomId(roomId: string): Promise<GameSession | null>按房间 ID 反查会话。
listSessions()
async listSessions(): Promise<GameSession[]>返回所有活跃会话(供监控 / 健康检查)。
removeSession()
async removeSession(id: string): Promise<void>销毁会话:先 session.close()(drain + destroy engine),再从仓储删除。未找到则 no-op。
getMainPlugin()
getMainPlugin(gameType: string): GamePlugin | undefined取出主 Plugin(用于事件序列化器选择 gameType)。
PlayerSessionService
玩家连接状态管理。
DisconnectGraceOptions
interface DisconnectGraceOptions {
gracePeriodMs?: number
}类
class PlayerSessionService {
constructor(
playerSessionRepository: PlayerSessionRepository,
broadcaster: BroadcastPort,
gracePeriodMs?: number // 缺省 60_000
)
}方法
connect()
async connect(
playerId: string,
socketId: string,
name?: string
): Promise<{ session: PlayerSession; reconnected: boolean }>建立 / 恢复玩家连接。
行为:
- 若
playerId已存在 PlayerSession:更新 socketId、connected=true(重连)。 - 否则创建新 PlayerSession。
bindSocket(socketId)+playerSessionRepository.save(session)。- 广播
player:connection事件(connected=true)到所在 room / game。 - 返回
{ session, reconnected };调用方据此 socket.join 到所属 room / game。
disconnect()
async disconnect(socketId: string): Promise<PlayerSession | null>断线:根据 socketId 反查 PlayerSession,标记 connected=false。不删除 PlayerSession,便于在 grace period 内重连。广播 player:connection 事件(connected=false)。
bindRoom() / unbindRoom()
async bindRoom(playerId: string, roomId: string): Promise<void>
async unbindRoom(playerId: string): Promise<void>玩家加入 / 离开房间:更新 PlayerSession.roomId。
bindGame() / unbindGame()
async bindGame(playerId: string, gameId: string): Promise<void>
async unbindGame(playerId: string): Promise<void>玩家加入 / 离开对局:更新 PlayerSession.gameId。
getByPlayerId() / getBySocketId()
async getByPlayerId(playerId: string): Promise<PlayerSession | null>
async getBySocketId(socketId: string): Promise<PlayerSession | null>isPlayerConnected()
async isPlayerConnected(playerId: string): Promise<boolean>玩家是否当前在线(用于 GameService 推送 game:state 时跳过离线玩家)。
findTimedOutSessions()
async findTimedOutSessions(now?: number): Promise<PlayerSession[]>扫描所有断线超时的 PlayerSession。超过 gracePeriodMs 仍未重连的玩家返回,由调用方决定后续动作(Phase 6 不自动结束游戏)。
关系图
RoomService
│
┌──────────┴──────────┐
▼ ▼
Room ◀──gameSessionId──▶ GameSession
│ │
│ │ engine
│ ▼
│ GameEngine
│ │
│ │ ActionQueue (串行)
│ ▼
│ engine.dispatch → EventBus
│ │
│ ▼
│ GameEventPublisher
│ │
▼ ▼
PlayerSession ◀──playerId─── PlayerSessionService
│
│ socketId
▼
SocketServer(BroadcastPort 实现)示例
创建会话并执行 Action
import { createServer } from 'decklet/server'
const runtime = createServer()
const { roomService, sessionService, gameService } = runtime
// 创建房间 + 加入玩家
const room = await roomService.createRoom({ gameType: 'uno', maxPlayers: 4 }, 'p1')
await roomService.joinRoom(room.id, 'p2')
// 直接通过 sessionService 创建会话(不通过 startGame,不会触发 engine.start)
const session = await sessionService.createSession(room, { seed: 12345 })
console.log(session.id, session.roomId, session.gameType, session.status) // 'CREATED'
console.log(session.stateVersion) // 引擎 state.version
// 手动执行 Action(同步 dispatch,跳过幂等检查)
// 通常通过 gameService.executeAction 完成,此处仅为演示玩家连接与断线
import { createServer } from 'decklet/server'
const runtime = createServer()
const { playerSessionService } = runtime
// 首次连接
const { session, reconnected } = await playerSessionService.connect(
'p1',
'socket-aaa',
'Alice'
)
console.log(reconnected) // false
console.log(session.connected) // true
// 断线(仅标记 connected=false,PlayerSession 保留)
await playerSessionService.disconnect('socket-aaa')
const after = await playerSessionService.getByPlayerId('p1')
console.log(after?.connected) // false
// 重连(同 playerId,不同 socketId)
const r2 = await playerSessionService.connect('p1', 'socket-bbb')
console.log(r2.reconnected) // true
console.log(r2.session.socketId) // 'socket-bbb'注意事项
GameSession不负责业务规则——只负责 Engine 引用、玩家列表、生命周期、Action 串行执行、状态访问。PlayerSession.playerId与socketId解耦:玩家断线 → 重连时 socketId 变化,但 playerId 保持不变。GameSession.enqueue是会话级串行化——不同 GameSession 之间互不阻塞,不要使用全局锁。GameSessionService.createSession不调用engine.start();GameService.startGame才会启动。- 幂等缓存上限为
DEFAULT_MAX_PROCESSED = 500,按 FIFO 驱逐。 PlayerSessionService.disconnect不删除 PlayerSession——这是断线重连机制的核心;只有玩家主动调用unbindGame/unbindRoom或removeSession才会清理。