Skip to content

Serialization ​

Server Runtime 的状态 / 事件序列化层:把引擎内部 GameState / GameEvent 转换为对外可见的脱敏形态。

概述 ​

序列化层是 Phase 6 信息隔离的核心防线:

  • 服务器内部 GameState 包含所有玩家的具体手牌、隐藏底牌等敏感数据。
  • 直接 JSON.stringify 发给客户端是禁止的。
  • 必须 serializeForPlayer(state, playerId) 仅暴露该玩家应见的信息。

不同 Plugin 有不同的 state 形状(zones / variables),因此每个游戏类型提供专属 Serializer 实现。通用 BaseGameStateSerializer 仅暴露最小公共字段。

模块 ​

模块说明
GameStateSerializer序列化器接口 + 通用基类
SerializerRegistry按 gameType 提供专属序列化器的注册表
UnoStateSerializerUNO 专属序列化器
DoudizhuStateSerializer斗地主专属序列化器
GameEventSerializer引擎事件 → Socket 事件的脱敏器

GameStateSerializer ​

序列化器接口与通用基类。

接口 ​

ts
interface GameStateSerializer {
  serializeForPlayer(state: GameState, playerId: string): PublicGameState
  serializePublic(state: GameState): PublicGameState
}
方法说明
serializeForPlayer(state, playerId)序列化为某玩家视角的 PublicGameState:包含 myHand(该玩家手牌的牌面数组);其他玩家仅暴露 cardCount;隐藏区域按规则决定是否暴露
serializePublic(state)序列化为完全公共视角(无 myHand):用于观战、列表等场景

BaseGameStateSerializer ​

ts
abstract class BaseGameStateSerializer implements GameStateSerializer {
  abstract serializeForPlayer(state: GameState, playerId: string): PublicGameState
  abstract serializePublic(state: GameState): PublicGameState
  protected baseState(state: GameState): Omit<PublicGameState, 'plugin' | 'myHand' | 'table'>
  protected playersView(state: GameState): PublicGameState['players']
  protected playerCardCount(state: GameState, playerId: string): number
}

通用基础序列化器:暴露所有游戏共有的最小字段。子类(如 UnoStateSerializer / DoudizhuStateSerializer)扩展 plugin / table / myHand 字段,并按规则裁剪隐藏信息。

设计为类而非纯函数:便于子类复用 protected 辅助方法(playersView / baseState)。

baseState() ​

ts
protected baseState(state: GameState): Omit<PublicGameState, 'plugin' | 'myHand' | 'table'>

公共最小字段:gameId / status / turn / stateVersion / winnerIds / players。

playersView() ​

ts
protected playersView(state: GameState): PublicGameState['players']

玩家列表视图:仅 id / name / seat / cardCount。不暴露任何玩家的具体手牌。

playerCardCount() ​

ts
protected playerCardCount(state: GameState, playerId: string): number

某玩家的手牌数量(通过 playerHandZoneId(playerId) 约定访问手牌 zone)。

SerializerRegistry ​

按 gameType 提供专属序列化器的注册表。

类 ​

ts
class SerializerRegistry {
  register(gameType: string, serializer: GameStateSerializer): this
  get(gameType: string): GameStateSerializer
  has(gameType: string): boolean
}

register() ​

ts
register(gameType: string, serializer: GameStateSerializer): this

注册某 gameType 的序列化器。

get() ​

ts
get(gameType: string): GameStateSerializer

取出该 gameType 的序列化器;未注册返回 DefaultGameStateSerializer(fallback)。

has() ​

ts
has(gameType: string): boolean

是否已注册该 gameType。

DefaultGameStateSerializer ​

ts
class DefaultGameStateSerializer extends BaseGameStateSerializer {
  serializeForPlayer(state: GameState, _playerId: string): PublicGameState
  serializePublic(state: GameState): PublicGameState
}

默认 fallback 序列化器:仅返回最小公共字段,无 plugin / table / myHand。

用于尚未注册专属序列化器的 gameType(如 SimpleGame),保证服务器在缺失 serializer 时仍能返回合法 PublicGameState(虽信息不全)。

createDefaultSerializerRegistry() ​

ts
function createDefaultSerializerRegistry(): SerializerRegistry

默认注册表:UNO 与 Doudizhu 已绑定专属序列化器。

ts
registry.register('uno', new UnoStateSerializer())
registry.register('doudizhu', new DoudizhuStateSerializer())

UnoStateSerializer ​

UNO 专属 GameState 序列化器。

类 ​

ts
class UnoStateSerializer extends BaseGameStateSerializer {
  serializeForPlayer(state: GameState, playerId: string): PublicGameState
  serializePublic(state: GameState): PublicGameState
}

暴露规则 ​

范围字段
公共当前色 / 是否待选色 / 弃牌堆顶 / 牌堆剩余张数 / 弃牌堆张数
仅本人手牌(牌面数组 [{ id, face }])
隐藏牌堆内部、其他玩家手牌

serializeForPlayer() ​

ts
serializeForPlayer(state: GameState, playerId: string): PublicGameState

返回 PublicGameState,含 plugin: { gameType: 'uno' }、myHand 与 table。

myHand 形如:

ts
[{ id: 'uno-red-3-a', face: 'number:3' }, { id: 'uno-wild-1', face: 'wild' }, ...]

客户端用 id 发起 PLAY_CARD。

table 形如:

ts
{
  currentColor: 'red' | null,
  pendingColor: boolean,
  topDiscard: 'number:3' | 'skip' | 'wild' | ... | null,
  deckCount: number,
  discardCount: number
}

serializePublic() ​

ts
serializePublic(state: GameState): PublicGameState

不含 myHand,其余同 serializeForPlayer。

DoudizhuStateSerializer ​

斗地主专属 GameState 序列化器。

类 ​

ts
class DoudizhuStateSerializer extends BaseGameStateSerializer {
  serializeForPlayer(state: GameState, playerId: string): PublicGameState
  serializePublic(state: GameState): PublicGameState
}

暴露规则 ​

范围字段
公共阶段 / 地主 / 农民 / 倍率 / 上次出牌玩家 / 上次牌型 / passCount / 弃牌堆顶 / 当前叫分人
仅本人手牌(牌面 + id 数组)
隐藏底牌 id(仅在阶段进入 PLAYING 后才暴露,因为 REVEAL_BOTTOM_CARDS 已派发)
隐藏其他玩家手牌、牌堆内部

table 字段 ​

ts
{
  phase: DoudizhuPhase,
  landlordId: string | null,
  landlordCandidateId: string | null,
  farmerIds: string[],
  multiplier: number,         // 缺省 1
  passCount: number,          // 缺省 0
  bidderIndex: number,       // 缺省 0
  bidderPassed: unknown[],   // 缺省 []
  lastPlayerId: string | null,
  lastCombination: { type, mainRank, cardIds } | null,
  bottomCardIds: string[],   // revealed 后暴露 id;BIDDING 阶段为 []
  bottomCardCount: number,
  bottomRevealed: boolean,
  discardTop: { id, face } | null,
  deckCount: number,
  bottomZoneCount: number
}

关键脱敏:底牌 id 列表仅在 phase >= PLAYING(即 REVEAL_BOTTOM_CARDS 已派发)后才暴露。BIDDING 阶段隐藏底牌 id,仅暴露张数(bottomCardCount)。

sanitizeCombination()(私有) ​

ts
private sanitizeCombination(combination: unknown): unknown

牌型对象内可能含具体卡牌引用,仅保留可对外字段:type / mainRank / cardIds。

GameEventSerializer ​

引擎 GameEvent 序列化器:在 Engine Event 广播到 Socket 前过滤敏感字段。

EventSanitizer ​

ts
type EventSanitizer = (payload: unknown) => unknown

sanitizer 返回 null 时表示抑制该事件(不广播)。

通用敏感字段 ​

ts
const SENSITIVE_KEYS = new Set([
  'internalState',
  'debug',
  'secret',
  'privateState',
  'fullState'
])

serialize 时递归剥离这些字段。

类 ​

ts
class GameEventSerializer {
  register(gameType: string, eventType: string, sanitizer: EventSanitizer): this
  get(gameType: string, eventType: string): EventSanitizer | undefined
  serialize(event: GameEvent, gameType: string): SocketGameEvent | null
}

register() ​

ts
register(gameType: string, eventType: string, sanitizer: EventSanitizer): this

注册某 gameType + eventType 的脱敏器。sanitizer 返回 null 时表示抑制该事件(不广播)。

get() ​

ts
get(gameType: string, eventType: string): EventSanitizer | undefined

取出某 gameType + eventType 的脱敏器;未注册返回 undefined。

serialize() ​

ts
serialize(event: GameEvent, gameType: string): SocketGameEvent | null

把内部 GameEvent 转换为对外 SocketGameEvent。

行为:

  1. 应用注册的 sanitizer(如有);sanitizer 返回 null 则直接返回 null(抑制广播)。
  2. 通用剥离 SENSITIVE_KEYS 中列出的字段(递归)。
  3. 构造 SocketGameEvent:gameId 留空(由调用方 GameEventPublisher 填充)、stateVersion / type / payload 来自原事件;若 event.playerId !== undefined 则一并填充。

createDefaultGameEventSerializer() ​

ts
function createDefaultGameEventSerializer(): GameEventSerializer

默认事件序列化器:注册斗地主敏感事件的脱敏规则。

gameTypeeventType脱敏行为
'doudizhu''DDZ_CARDS_DEALT'剥离 bottomCardIds(底牌未亮明前不可见);保留 handSize / bottomSize,客户端仍能感知发牌完成
ts
s.register('doudizhu', 'DDZ_CARDS_DEALT', (payload) => {
  if (!payload || typeof payload !== 'object') return payload
  const p = payload as Record<string, unknown>
  return {
    handSize: p.handSize,
    bottomSize: p.bottomSize
  }
})

PublicGameState ​

ts
interface PublicGameState {
  gameId: string
  status: string
  turn: number
  currentPlayerId?: string
  stateVersion: number
  winnerIds: string[]
  players: Array<{ id: string; name: string; seat: number; cardCount: number }>
  plugin?: Record<string, unknown>
  myHand?: unknown
  table?: unknown
}
字段类型说明
gameIdstring对局 ID
statusstring引擎 GameStatus(waiting / playing / paused / finished)
turnnumber当前回合数
currentPlayerId?string当前轮到的玩家 ID
stateVersionnumber状态版本号(单调递增)
winnerIdsstring[]获胜玩家 ID 列表
playersArray<{ id, name, seat, cardCount }>玩家列表(仅 cardCount,不含具体牌)
plugin?Record<string, unknown>Plugin 专属字段(如 { gameType: 'uno' })
myHand?unknown调用方自己的手牌(仅 serializeForPlayer 时填充)
table?unknown桌面公共信息(弃牌堆顶、上一手牌型等),由 Plugin 决定形状

GameSyncResponse ​

ts
interface GameSyncResponse {
  gameId: string
  stateVersion: number
  state: PublicGameState
}

game:sync 响应体。

数据流 ​

            engine.dispatch(action)
                    │
                    ▼
            EventBus.emit(event)
                    │
                    ▼
        ┌───────────────────────────┐
        │  GameEventPublisher.handle │
        └────────────┬──────────────┘
                     │
                     ▼
        GameEventSerializer.serialize(event, gameType)
                     │
            ┌────────┴────────┐
            ▼                 ▼
        sanitizer?       stripSensitive
        (按 gameType+eventType)  (递归剥离 SENSITIVE_KEYS)
            │                 │
            └────────┬────────┘
                     ▼
            SocketGameEvent (脱敏后)
                     │
                     ▼
        BroadcastPort.broadcastToGame('game:event', serialized)

        ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─ ─

        GameService.broadcastSyncToPlayers(session)
                     │
                     ▼
        SerializerRegistry.get(gameType)
                     │
            ┌────────┴────────┐
            ▼                 ▼
        serializeForPlayer  (当前玩家:含 myHand)
        serializePublic      (其他玩家:无 myHand)
            │                 │
            └────────┬────────┘
                     ▼
            PublicGameState (脱敏后)
                     │
                     ▼
        BroadcastPort.sendToPlayer('game:state', { gameId, stateVersion, state })

示例 ​

注册自定义序列化器 ​

ts
import {
  SerializerRegistry,
  createDefaultSerializerRegistry
} from 'decklet/server'
import type { GameStateSerializer } from 'decklet/server'

// 1) 使用默认注册表
const registry = createDefaultSerializerRegistry()
console.log(registry.has('uno'))       // true
console.log(registry.has('doudizhu'))  // true
console.log(registry.has('simple'))   // false → get() 返回 DefaultGameStateSerializer

// 2) 注册自定义序列化器
class MyGameSerializer implements GameStateSerializer {
  serializeForPlayer(state, playerId) { /* ... */ }
  serializePublic(state) { /* ... */ }
}
registry.register('mygame', new MyGameSerializer())

注册自定义事件脱敏器 ​

ts
import { GameEventSerializer } from 'decklet/server'

const serializer = new GameEventSerializer()

// 注册脱敏:抑制某事件广播
serializer.register('uno', 'UNO_INTERNAL_DEBUG', () => null)

// 注册脱敏:剥离敏感字段
serializer.register('uno', 'UNO_CARD_PLAYED', (payload) => {
  if (!payload || typeof payload !== 'object') return payload
  const p = payload as Record<string, unknown>
  return { playerId: p.playerId, cardType: p.cardType } // 不暴露 cardId / color
})

// serialize 返回 null 表示抑制
const suppressed = serializer.serialize(
  { type: 'UNO_INTERNAL_DEBUG', payload: {}, stateVersion: 1 } as any,
  'uno'
)
console.log(suppressed) // null

在 GameService 中的使用 ​

ts
import { createServer } from 'decklet/server'

const runtime = createServer()
const { gameService, roomService } = runtime

const room = await roomService.createRoom({ gameType: 'uno', maxPlayers: 4 }, 'p1')
await roomService.joinRoom(room.id, 'p2')
const { gameId } = await roomService.startRoom(room.id)

// 获取玩家视角(含 myHand)
const stateP1 = await gameService.getGameState(gameId, 'p1')
console.log(stateP1.myHand)        // [{ id, face }, ...]
console.log(stateP1.players)       // [{ id, name, seat, cardCount }, ...]
console.log(stateP1.table)         // { currentColor, pendingColor, topDiscard, deckCount, discardCount }
console.log(stateP1.plugin)        // { gameType: 'uno' }

// 获取公共视角(观战者,无 myHand)
const statePublic = await gameService.getGameState(gameId, 'observer-1')
console.log(statePublic.myHand)    // undefined
console.log(statePublic.table)     // 仍有公共桌面信息

测试信息隔离 ​

ts
// 验证:序列化后的 PublicGameState 不应包含其他玩家的具体手牌
import { createDefaultSerializerRegistry } from 'decklet/server'

const registry = createDefaultSerializerRegistry()
const serializer = registry.get('uno')

// 假设 state 是引擎内部 GameState(含所有玩家手牌)
const publicState = serializer.serializeForPlayer(state, 'p1')

// p1 自己的手牌可见
console.log(publicState.myHand) // [{ id, face }, ...]

// 其他玩家仅有 cardCount,无具体牌
for (const p of publicState.players) {
  console.log(p.id, p.cardCount) // 不存在 p.hand 字段
}

// 不暴露牌堆内部
console.log((publicState.table as any).deckCount) // 仅数量
// console.log((publicState.table as any).deckCards) // undefined

注意事项 ​

  • SerializerRegistry 与 PluginRegistry 平行:前者提供对外视图,后者提供游戏规则。两者 gameType 集合应当一致——若不一致,未注册 gameType 会 fallback 到 DefaultGameStateSerializer(仅最小公共字段)。
  • GameEventSerializer 未注册的 event 类型默认透传(假设插件已确保 payload 安全),但仍会递归剥离 SENSITIVE_KEYS。
  • myHand / table 字段使用 unknown 类型——具体形状由各 Plugin 的 Serializer 决定,客户端需按 gameType 约定解析。
  • UnoStateSerializer.tableView 中 currentColor 在游戏开始翻初始牌之前为 null(不是 undefined)。
  • DoudizhuStateSerializer 的 bottomCardIds 脱敏是关键防线——BIDDING 阶段隐藏,PLAYING 后才暴露,与事件层 DDZ_CARDS_DEALT 的脱敏互补。
  • serialize 返回 null 表示抑制广播——GameEventPublisher 收到 null 后跳过该事件,记录 debug 日志。