Serialization
Server Runtime 的状态 / 事件序列化层:把引擎内部 GameState / GameEvent 转换为对外可见的脱敏形态。
概述
序列化层是 Phase 6 信息隔离的核心防线:
- 服务器内部
GameState包含所有玩家的具体手牌、隐藏底牌等敏感数据。 - 直接
JSON.stringify发给客户端是禁止的。 - 必须
serializeForPlayer(state, playerId)仅暴露该玩家应见的信息。
不同 Plugin 有不同的 state 形状(zones / variables),因此每个游戏类型提供专属 Serializer 实现。通用 BaseGameStateSerializer 仅暴露最小公共字段。
模块
| 模块 | 说明 |
|---|---|
GameStateSerializer | 序列化器接口 + 通用基类 |
SerializerRegistry | 按 gameType 提供专属序列化器的注册表 |
UnoStateSerializer | UNO 专属序列化器 |
DoudizhuStateSerializer | 斗地主专属序列化器 |
GameEventSerializer | 引擎事件 → Socket 事件的脱敏器 |
GameStateSerializer
序列化器接口与通用基类。
接口
interface GameStateSerializer {
serializeForPlayer(state: GameState, playerId: string): PublicGameState
serializePublic(state: GameState): PublicGameState
}| 方法 | 说明 |
|---|---|
serializeForPlayer(state, playerId) | 序列化为某玩家视角的 PublicGameState:包含 myHand(该玩家手牌的牌面数组);其他玩家仅暴露 cardCount;隐藏区域按规则决定是否暴露 |
serializePublic(state) | 序列化为完全公共视角(无 myHand):用于观战、列表等场景 |
BaseGameStateSerializer
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()
protected baseState(state: GameState): Omit<PublicGameState, 'plugin' | 'myHand' | 'table'>公共最小字段:gameId / status / turn / stateVersion / winnerIds / players。
playersView()
protected playersView(state: GameState): PublicGameState['players']玩家列表视图:仅 id / name / seat / cardCount。不暴露任何玩家的具体手牌。
playerCardCount()
protected playerCardCount(state: GameState, playerId: string): number某玩家的手牌数量(通过 playerHandZoneId(playerId) 约定访问手牌 zone)。
SerializerRegistry
按 gameType 提供专属序列化器的注册表。
类
class SerializerRegistry {
register(gameType: string, serializer: GameStateSerializer): this
get(gameType: string): GameStateSerializer
has(gameType: string): boolean
}register()
register(gameType: string, serializer: GameStateSerializer): this注册某 gameType 的序列化器。
get()
get(gameType: string): GameStateSerializer取出该 gameType 的序列化器;未注册返回 DefaultGameStateSerializer(fallback)。
has()
has(gameType: string): boolean是否已注册该 gameType。
DefaultGameStateSerializer
class DefaultGameStateSerializer extends BaseGameStateSerializer {
serializeForPlayer(state: GameState, _playerId: string): PublicGameState
serializePublic(state: GameState): PublicGameState
}默认 fallback 序列化器:仅返回最小公共字段,无 plugin / table / myHand。
用于尚未注册专属序列化器的 gameType(如 SimpleGame),保证服务器在缺失 serializer 时仍能返回合法 PublicGameState(虽信息不全)。
createDefaultSerializerRegistry()
function createDefaultSerializerRegistry(): SerializerRegistry默认注册表:UNO 与 Doudizhu 已绑定专属序列化器。
registry.register('uno', new UnoStateSerializer())
registry.register('doudizhu', new DoudizhuStateSerializer())UnoStateSerializer
UNO 专属 GameState 序列化器。
类
class UnoStateSerializer extends BaseGameStateSerializer {
serializeForPlayer(state: GameState, playerId: string): PublicGameState
serializePublic(state: GameState): PublicGameState
}暴露规则
| 范围 | 字段 |
|---|---|
| 公共 | 当前色 / 是否待选色 / 弃牌堆顶 / 牌堆剩余张数 / 弃牌堆张数 |
| 仅本人 | 手牌(牌面数组 [{ id, face }]) |
| 隐藏 | 牌堆内部、其他玩家手牌 |
serializeForPlayer()
serializeForPlayer(state: GameState, playerId: string): PublicGameState返回 PublicGameState,含 plugin: { gameType: 'uno' }、myHand 与 table。
myHand 形如:
[{ id: 'uno-red-3-a', face: 'number:3' }, { id: 'uno-wild-1', face: 'wild' }, ...]客户端用 id 发起 PLAY_CARD。
table 形如:
{
currentColor: 'red' | null,
pendingColor: boolean,
topDiscard: 'number:3' | 'skip' | 'wild' | ... | null,
deckCount: number,
discardCount: number
}serializePublic()
serializePublic(state: GameState): PublicGameState不含 myHand,其余同 serializeForPlayer。
DoudizhuStateSerializer
斗地主专属 GameState 序列化器。
类
class DoudizhuStateSerializer extends BaseGameStateSerializer {
serializeForPlayer(state: GameState, playerId: string): PublicGameState
serializePublic(state: GameState): PublicGameState
}暴露规则
| 范围 | 字段 |
|---|---|
| 公共 | 阶段 / 地主 / 农民 / 倍率 / 上次出牌玩家 / 上次牌型 / passCount / 弃牌堆顶 / 当前叫分人 |
| 仅本人 | 手牌(牌面 + id 数组) |
| 隐藏 | 底牌 id(仅在阶段进入 PLAYING 后才暴露,因为 REVEAL_BOTTOM_CARDS 已派发) |
| 隐藏 | 其他玩家手牌、牌堆内部 |
table 字段
{
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()(私有)
private sanitizeCombination(combination: unknown): unknown牌型对象内可能含具体卡牌引用,仅保留可对外字段:type / mainRank / cardIds。
GameEventSerializer
引擎 GameEvent 序列化器:在 Engine Event 广播到 Socket 前过滤敏感字段。
EventSanitizer
type EventSanitizer = (payload: unknown) => unknownsanitizer 返回 null 时表示抑制该事件(不广播)。
通用敏感字段
const SENSITIVE_KEYS = new Set([
'internalState',
'debug',
'secret',
'privateState',
'fullState'
])serialize 时递归剥离这些字段。
类
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()
register(gameType: string, eventType: string, sanitizer: EventSanitizer): this注册某 gameType + eventType 的脱敏器。sanitizer 返回 null 时表示抑制该事件(不广播)。
get()
get(gameType: string, eventType: string): EventSanitizer | undefined取出某 gameType + eventType 的脱敏器;未注册返回 undefined。
serialize()
serialize(event: GameEvent, gameType: string): SocketGameEvent | null把内部 GameEvent 转换为对外 SocketGameEvent。
行为:
- 应用注册的 sanitizer(如有);sanitizer 返回
null则直接返回null(抑制广播)。 - 通用剥离
SENSITIVE_KEYS中列出的字段(递归)。 - 构造
SocketGameEvent:gameId留空(由调用方GameEventPublisher填充)、stateVersion/type/payload来自原事件;若event.playerId !== undefined则一并填充。
createDefaultGameEventSerializer()
function createDefaultGameEventSerializer(): GameEventSerializer默认事件序列化器:注册斗地主敏感事件的脱敏规则。
| gameType | eventType | 脱敏行为 |
|---|---|---|
'doudizhu' | 'DDZ_CARDS_DEALT' | 剥离 bottomCardIds(底牌未亮明前不可见);保留 handSize / bottomSize,客户端仍能感知发牌完成 |
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
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
}| 字段 | 类型 | 说明 |
|---|---|---|
gameId | string | 对局 ID |
status | string | 引擎 GameStatus(waiting / playing / paused / finished) |
turn | number | 当前回合数 |
currentPlayerId? | string | 当前轮到的玩家 ID |
stateVersion | number | 状态版本号(单调递增) |
winnerIds | string[] | 获胜玩家 ID 列表 |
players | Array<{ id, name, seat, cardCount }> | 玩家列表(仅 cardCount,不含具体牌) |
plugin? | Record<string, unknown> | Plugin 专属字段(如 { gameType: 'uno' }) |
myHand? | unknown | 调用方自己的手牌(仅 serializeForPlayer 时填充) |
table? | unknown | 桌面公共信息(弃牌堆顶、上一手牌型等),由 Plugin 决定形状 |
GameSyncResponse
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 })示例
注册自定义序列化器
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())注册自定义事件脱敏器
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 中的使用
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) // 仍有公共桌面信息测试信息隔离
// 验证:序列化后的 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 日志。