Skip to content

Session ​

Server domain 层的会话相关实体:Room / GameSession / PlayerSession / GameActionQueue 与状态枚举。

概述 ​

会话层是 Server Runtime 的核心——承载玩家连接、房间生命周期、对局执行队列。

实体职责
Room玩家加入与开始游戏的房间容器
GameSession一局对局的生命周期包装器,持有 GameEngine
PlayerSession玩家与服务器之间的连接状态
GameActionQueue每会话 Action 串行化队列
GameSessionService / PlayerSessionService应用层服务(生命周期 / 连接管理)

状态枚举 ​

RoomStatus ​

ts
type RoomStatus = 'WAITING' | 'READY' | 'PLAYING' | 'FINISHED' | 'CLOSED'

Room 生命周期:

状态说明
WAITING已创建,等待玩家加入
READY人数满足,可开始
PLAYING已开局,关联的 GameSession 运行中
FINISHED关联的 GameSession 已结束
CLOSED房间关闭,不再可用

GameSessionStatus ​

ts
type GameSessionStatus = 'CREATED' | 'RUNNING' | 'FINISHED' | 'CLOSED'

GameSession 生命周期:

状态说明
CREATED已创建 Engine,尚未 start
RUNNINGengine.start() 已调用
FINISHEDengine.state.status === 'finished'
CLOSED主动销毁,释放资源

与引擎 GameStatus 解耦

引擎 GameStatus(waiting / playing / paused / finished)描述单局技术状态;RoomStatus / GameSessionStatus 是服务器侧生命周期概念。两者通过 GameSession 桥接(GameSession.engine.state.status 即对局技术状态)。

Room ​

玩家加入与开始游戏的房间容器。

RoomInit ​

ts
interface RoomInit {
  id: string
  gameType: string
  ownerId: string
  playerIds?: string[]
  maxPlayers: number
  minPlayers?: number
  status?: RoomStatus
  gameSessionId?: string
  createdAt?: number
  meta?: Record<string, unknown>
}

属性/字段 ​

名称类型说明
idreadonly string房间 ID
gameTypereadonly string游戏类型
ownerIdstring房主 ID(可变更)
playerIdsreadonly string[]玩家 ID 列表(可 push)
maxPlayersreadonly number最大玩家数
minPlayersreadonly number最少开始人数(缺省 2)
statusRoomStatus当前状态(缺省 WAITING)
gameSessionId?string关联的对局 ID
createdAtreadonly number创建时间戳
metareadonly Record<string, unknown>元数据

计算属性 ​

名称类型说明
currentPlayersnumberplayerIds.length
isFullbooleanplayerIds.length >= maxPlayers
canJoinbooleanstatus === 'WAITING' && !isFull
canStartboolean(status === 'WAITING' || status === 'READY') && playerIds.length >= minPlayers

方法 ​

ts
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(): RoomInit

GameSession ​

服务器侧一局对局的生命周期包装器。

GameSessionInit ​

ts
interface GameSessionInit {
  id: string
  roomId: string
  gameType: string
  engine: GameEngine
  playerIds: string[]
  status?: GameSessionStatus
  createdAt?: number
}

属性/字段 ​

名称类型说明
idreadonly string会话 ID
roomIdreadonly string所属房间 ID
gameTypereadonly string游戏类型
enginereadonly GameEngine引擎实例
playerIdsreadonly readonly string[]参与玩家 ID 列表(拷贝)
statusGameSessionStatus缺省 CREATED
createdAtreadonly number创建时间戳
updatedAtnumber最近更新时间戳

计算属性 ​

名称类型说明
stateVersionnumber引擎 state.version(单调递增)
engineStatusstring引擎当前 GameStatus

方法 ​

hasPlayer() ​

ts
hasPlayer(playerId: string): boolean

getProcessedResult() ​

ts
getProcessedResult(actionId: string): ActionResult | undefined

已处理过的 actionId 直接返回原结果,保证幂等。

recordProcessed() ​

ts
recordProcessed(actionId: string, result: ActionResult): void

记录已处理 actionId 与结果。超过上限(默认 500,DEFAULT_MAX_PROCESSED)时按 FIFO 驱逐最旧。成功与失败的 actionId 都会被记录——重复的失败请求也直接返回首次错误。

enqueue() ​

ts
enqueue<T>(task: () => Promise<T>): Promise<T>

串行执行 task;同会话内严格 FIFO,不同会话间互不阻塞。task 内部捕获所有异常并返回;仅系统级异常才会 reject。

drain() ​

ts
async drain(): Promise<void>

等待队列排空(用于优雅关闭)。

isFinished() / isClosed() ​

ts
isFinished(): boolean   // engine.status === 'finished' || status === 'FINISHED'
isClosed(): boolean     // status === 'CLOSED'

markFinished() ​

ts
markFinished(): void

标记为 FINISHED(由 GameService 在对局结束时调用)。

close() ​

ts
async close(): Promise<void>

销毁会话:先 drain() 队列,再 engine.destroy(),清空 processedActions,置 status = 'CLOSED'。

setPendingBroadcastEvents() / consumePendingBroadcastEvents() ​

ts
setPendingBroadcastEvents(events: GameEvent[]): void
consumePendingBroadcastEvents(): GameEvent[]

暂存 / 取出并清空一次顶级 dispatch 产生的全部事件(含链式)。

dispatch() ​

ts
dispatch(action: Action): DispatchResult

仅供 ActionExecutor 路径返回值构造使用——直接 engine.dispatch(action) 并返回结果。

PlayerSession ​

玩家与服务器之间的连接状态。

PlayerSessionInit ​

ts
interface PlayerSessionInit {
  playerId: string
  socketId?: string
  roomId?: string
  gameId?: string
  connected?: boolean
  lastConnectedAt?: number
  name?: string
}

属性/字段 ​

名称类型说明
playerIdreadonly string玩家身份(不变)
socketId?string当前 socket 连接 ID(断线清空)
roomId?string当前所在房间 ID
gameId?string当前对局 ID
connectedboolean是否已连接(缺省 false)
lastConnectedAtnumber最近连接时间戳
namestring显示名(缺省取 playerId)

方法 ​

ts
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(): PlayerSessionInit

GameActionQueue ​

每会话 Action 串行化队列。

设计原则 ​

  • 同一 GameSession 内任何时刻最多只有一个 Action 在执行。
  • 维护一个 tail Promise,每次 enqueue 都把新 task 链到 tail 末尾。
  • task 内部的 await 间隙不会让另一个 enqueue 的 task 提前执行——它们必须等当前 tail 完成。
  • 不同 GameSession 持有各自的队列实例,互不阻塞(不使用全局锁)。
ts
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 ​

ts
interface CreateSessionOptions {
  gameSessionId?: string
  seed?: number
}
字段类型说明
gameSessionId?string由调用方提供的 id;缺省自动生成 game_ 前缀 id
seed?number引擎种子;缺省由 GameEngine 自动生成

类 ​

ts
class GameSessionService {
  constructor(
    sessionRepository: GameSessionRepository,
    pluginRegistry: PluginRegistry
  )
}

方法 ​

createSession() ​

ts
async createSession(
  room: Room,
  options?: CreateSessionOptions
): Promise<GameSession>

创建一局对局。

流程:

  1. pluginRegistry.get(room.gameType) 取出完整 Plugin 栈;为空抛 ServerError('PLUGIN_NOT_FOUND', '...', 404)。
  2. 构造 GameEngine({ seed, gameId })。
  3. 依次 engine.use(plugin) 装配所有 Plugin。
  4. 把 Room 的 playerIds 转为 [{ id, name: id, seat: i+1 }],调用 engine.createGame({ players, gameId })。
  5. 包装为 GameSession 实体保存到仓储。

不调用 engine.start()

createSession 不调用 engine.start()——开始游戏由 GameService.startGame 触发,因为开始游戏是业务事件,需要广播 GAME_STARTED 给客户端。

getSession() / getSessionOrThrow() ​

ts
async getSession(id: string): Promise<GameSession | null>
async getSessionOrThrow(id: string): Promise<GameSession>

getSessionOrThrow 在未找到时抛 ServerError('GAME_NOT_FOUND', 'Game not found: ${id}', 404)。

findByRoomId() ​

ts
async findByRoomId(roomId: string): Promise<GameSession | null>

按房间 ID 反查会话。

listSessions() ​

ts
async listSessions(): Promise<GameSession[]>

返回所有活跃会话(供监控 / 健康检查)。

removeSession() ​

ts
async removeSession(id: string): Promise<void>

销毁会话:先 session.close()(drain + destroy engine),再从仓储删除。未找到则 no-op。

getMainPlugin() ​

ts
getMainPlugin(gameType: string): GamePlugin | undefined

取出主 Plugin(用于事件序列化器选择 gameType)。

PlayerSessionService ​

玩家连接状态管理。

DisconnectGraceOptions ​

ts
interface DisconnectGraceOptions {
  gracePeriodMs?: number
}

类 ​

ts
class PlayerSessionService {
  constructor(
    playerSessionRepository: PlayerSessionRepository,
    broadcaster: BroadcastPort,
    gracePeriodMs?: number        // 缺省 60_000
  )
}

方法 ​

connect() ​

ts
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() ​

ts
async disconnect(socketId: string): Promise<PlayerSession | null>

断线:根据 socketId 反查 PlayerSession,标记 connected=false。不删除 PlayerSession,便于在 grace period 内重连。广播 player:connection 事件(connected=false)。

bindRoom() / unbindRoom() ​

ts
async bindRoom(playerId: string, roomId: string): Promise<void>
async unbindRoom(playerId: string): Promise<void>

玩家加入 / 离开房间:更新 PlayerSession.roomId。

bindGame() / unbindGame() ​

ts
async bindGame(playerId: string, gameId: string): Promise<void>
async unbindGame(playerId: string): Promise<void>

玩家加入 / 离开对局:更新 PlayerSession.gameId。

getByPlayerId() / getBySocketId() ​

ts
async getByPlayerId(playerId: string): Promise<PlayerSession | null>
async getBySocketId(socketId: string): Promise<PlayerSession | null>

isPlayerConnected() ​

ts
async isPlayerConnected(playerId: string): Promise<boolean>

玩家是否当前在线(用于 GameService 推送 game:state 时跳过离线玩家)。

findTimedOutSessions() ​

ts
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 ​

ts
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 完成,此处仅为演示

玩家连接与断线 ​

ts
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 才会清理。