climb_stairs

Schema 文件

SCHEMA.md — Ladder Room Online Redis Schema Design

Version: v2.1

Date: 2026-04-21

Based on: EDD v2.0, PRD v1.4, legacy-SCHEMA v1.1

Redis is the sole persistence layer. There is no SQL database. All state lives in Redis key-value structures with explicit TTLs.


§1 Redis Data Structures

§1.1 Key Schema

Key 模式Redis TypeValueTTL用途
room:{code}String (JSON)序列化的 Room 物件(含 players 陣列)24h(每次狀態變更重設)房間主狀態
room:{code}:ladderString (JSON)LadderData JSON(seed、seedSource、segments)與主鍵同步梯子結構(BEGIN_REVEAL 時才創建)
room:{code}:revealedCountString (counter)整數字串,如 "0""N"與主鍵同步揭示進度原子計數器(INCR)
room:{code}:kickedSet{ playerId1, playerId2, ... }與主鍵同步踢除玩家 ID 集合,用於重連 WS Upgrade 檢查
room:{code}:sessionsHash{ playerId: sessionId }與主鍵同步追蹤活躍 WebSocket Session

範例鍵(房間代碼 ALPHA1):

room:ALPHA1
room:ALPHA1:ladder
room:ALPHA1:revealedCount
room:ALPHA1:kicked
room:ALPHA1:sessions

§1.2 TTL 策略

房間狀態TTL觸發條件
waiting / running / revealing24h(86400s)建立或任何狀態變更時重設
finished1h(3600s)轉換至 finished 狀態時(END_GAME EXEC 後)
所有玩家離線5min(300s)最後一個 WS 連線關閉時 EXPIRE
Pod 重啟維持現有 TTLRedis 鍵 TTL 在 Pod 重啟後仍有效,只有玩家重連時才重設

所有 room:{code}:* 子鍵與主鍵使用相同的 TTL,並在主鍵更新時透過 MULTI/EXEC 批次重設 EXPIRE。

§1.3 序列化策略

資料類型儲存方式
Room(複雜物件)JSON 字串,儲存在 Redis String 鍵
LadderData(含 seed、seedSource、segments)JSON 字串,儲存在 Redis String 鍵
revealedCountRedis 原生計數器(INCR),整數字串如 "3"
kickedPlayerIdsRedis Set,成員為 playerId 字串
sessionsRedis Hash,field 為 playerId,value 為 sessionId

§1.4 seed 公開時機(安全邊界)

房間狀態seed 存在於 Redisseed 傳給客戶端ladder 傳給客戶端
waiting不存在(尚未生成)否(null
running不存在(START_GAME 不生成梯子)否(null
revealing存在(BEGIN_REVEAL 時生成)LadderDataPublic(省略 seed/seedSource)
finished存在是(首次公開,含於 ROOM_STATE 廣播)LadderData(完整)

§2 TypeScript Types

以下型別均來自 packages/shared/src/types/index.ts,供前後端共用。

§2.1 RoomStatus

export type RoomStatus = 'waiting' | 'running' | 'revealing' | 'finished';

§2.2 Player

export interface Player {
  id: string;          // UUID v4,玩家身份識別
  nickname: string;    // 1-20 字元暱稱
  colorIndex: number;  // 0-49,對應客戶端顏色調色板
  isHost: boolean;     // 是否為房主
  isOnline: boolean;   // WebSocket 連線狀態
  joinedAt: number;    // Unix milliseconds
  result?: string | null;  // 遊戲結果(選填)
}

§2.3 LadderData

export interface LadderSegment {
  row: number;   // 行索引(0-based)
  col: number;   // 左側欄索引(0-based);橫槓連接 col 與 col+1
}

export interface LadderData {
  seed: number;         // Mulberry32 seed = djb2(seedSource) >>> 0 (uint32)
  seedSource: string;   // UUID v4,START_GAME 時生成,status=finished 前不對外公開
  rowCount: number;     // clamp(N*3, 20, 60)
  colCount: number;     // = N(所有玩家數,含 isOnline=false)
  segments: readonly LadderSegment[];  // 梯子橫槓位置陣列
}

// 在 revealing 狀態傳給客戶端(省略 seed/seedSource)
// 注意:LadderDataPublic 為伺服器層概念型別,不從 packages/shared 匯出;
// RoomStateFullPayload.ladder 實際型別為 LadderData | null,
// 伺服器在廣播前自行移除 seed/seedSource 欄位。
// LadderDataPublic 僅作為文件參考,不對應 shared 中任何匯出型別。
type LadderDataPublic = Omit<LadderData, 'seed' | 'seedSource'>;

§2.4 PathStep

export type PathDirection = 'down' | 'left' | 'right';

export interface PathStep {
  row: number;
  col: number;
  direction: PathDirection;
}

§2.5 ResultSlot

export interface ResultSlot {
  playerIndex: number;   // 玩家位置索引(0-based),對應 Canvas 列
  playerId: string;      // UUID v4,穩定身份識別
  startCol: number;      // 起始欄位(0-based)
  endCol: number;        // 終止欄位(0-based)
  isWinner: boolean;     // 是否獲獎(僅 win/lose,無命名獎項)
  path: readonly PathStep[];  // 長度 = rowCount;每步記錄 row, col, direction
}

// REVEAL_ALL payload 專用(省略 path,符合 64KB 限制)
// N=50, rowCount=60 時完整 path 約 150KB,超過 WebSocket maxPayload
// 注意:ResultSlotPublic 為伺服器層概念型別,不從 packages/shared 匯出。
type ResultSlotPublic = Omit<ResultSlot, 'path'>;

§2.6 Room

export interface Room {
  code: string;                          // 6 碼房間代碼,字元集 A-HJ-NP-Z2-9
  status: RoomStatus;
  hostId: string;                        // UUID v4,房主 playerId;host transfer 時可變更
  readonly players: readonly Player[];
  winnerCount: number | null;            // null 直到房主設定;PLAY_AGAIN 後若 W >= 新 N 則重設為 null
  ladder: LadderData | null;             // null 直到 BEGIN_REVEAL;Redis 中含完整資料
  results: readonly ResultSlot[] | null; // null 直到 BEGIN_REVEAL
  revealedCount: number;                 // Room JSON 內快照值;原子真相來源為 room:{code}:revealedCount
  revealMode: 'manual' | 'auto';
  autoRevealIntervalSec: number | null;  // 1-30s;manual 時為 null
  readonly kickedPlayerIds: readonly string[];  // 被踢玩家 UUID;冗余鏡像(正本為 Redis Set)
  createdAt: number;                     // Unix milliseconds
  updatedAt: number;                     // Unix milliseconds
}

§2.7 WebSocket Envelopes

export interface ServerEnvelope<T> {
  type: WsEventType;
  ts: number;    // Unix milliseconds
  payload: T;
}

export interface ClientEnvelope<T> {
  type: WsMsgType;
  ts: number;    // Unix milliseconds(客戶端時間戳,用於延遲量測)
  payload: T;
}

§2.8 HTTP DTOs

export interface CreateRoomRequest {
  hostNickname: string;   // 1-20 字元
  winnerCount?: number;   // 整數 >= 1
}

export interface CreateRoomResponse {
  roomCode: string;   // 6 碼房間代碼
  playerId: string;   // UUID v4
  token: string;      // JWT HS256,role: "host",TTL 6h
  room: Room;
}

export interface JoinRoomRequest {
  roomCode: string;
  nickname: string;   // 1-20 字元
}

export interface JoinRoomResponse {
  playerId: string;   // UUID v4
  token: string;      // JWT HS256,role: "player",TTL 6h
  room: Room;
}

§3 WebSocket Message Types

§3.1 WsEventType(Server → Client)

export type WsEventType =
  | 'ROOM_STATE'          // 任何房間狀態變更廣播(broadcast)
  | 'ROOM_STATE_FULL'     // WS 連線/重連時完整狀態(unicast)
  | 'REVEAL_INDEX'        // 逐一揭示單一玩家路徑(broadcast)
  | 'REVEAL_ALL'          // 一鍵全揭所有剩餘路徑(broadcast)
  | 'PLAYER_KICKED'       // 通知被踢玩家(unicast)
  | 'SESSION_REPLACED'    // 同一 playerId 新連線,舊連線被替換(unicast)
  | 'HOST_TRANSFERRED'    // 房主斷線 60s 後自動移交(broadcast)
  | 'ERROR';              // 操作失敗通知(unicast)

§3.2 WsMsgType(Client → Server)

export type WsMsgType =
  | 'START_GAME'          // Host only,waiting 狀態;生成 seedSource,不生成梯子
  | 'BEGIN_REVEAL'        // Host only,running 狀態;原子生成梯子 + 轉 revealing
  | 'REVEAL_NEXT'         // Host only,revealing 狀態;逐一揭示
  | 'REVEAL_ALL_TRIGGER'  // Host only,revealing 狀態;一鍵全揭
  | 'SET_REVEAL_MODE'     // Host only,revealing 狀態;切換 manual/auto 揭示
  | 'RESET_ROOM'          // 已棄用(由 PLAY_AGAIN 取代)
  | 'KICK_PLAYER'         // Host only,waiting 狀態;踢出指定玩家
  | 'PING';               // 應用層心跳(傳輸層心跳由 ws 內建 30s ping/pong 處理)

§3.3 完整 Server→Client Payload Union

以下型別均從 packages/shared/src/types/index.ts 匯出(export interface),供前後端共用。

// ROOM_STATE(broadcast)
export interface RoomStatePayload {
  code: string;
  status: RoomStatus;
  hostId: string;
  players: readonly Player[];
  winnerCount: number | null;
  revealedCount: number;
  revealMode: 'manual' | 'auto';
  autoRevealIntervalSec: number | null;
}

// ROOM_STATE_FULL(unicast,含完整 ladder/results)
// ladder 型別為 LadderData | null;伺服器在 revealing 狀態廣播前
// 自行移除 seed/seedSource 欄位,但 TypeScript 型別保持完整 LadderData。
export interface RoomStateFullPayload extends RoomStatePayload {
  ladder: LadderData | null;
  results: readonly ResultSlot[] | null;
  selfPlayerId: string;
}

// REVEAL_INDEX(broadcast)
export interface RevealIndexPayload {
  playerIndex: number;
  result: ResultSlot;      // 含完整 path(單一玩家,安全在 64KB 內)
  revealedCount: number;
  totalCount: number;
}

// REVEAL_ALL(broadcast)
// RevealAllPayload 為伺服器層型別,不從 packages/shared 匯出;
// 伺服器實作時使用 Omit<ResultSlot, 'path'>[] 構造,省略 path 以符合 64KB 限制。
interface RevealAllPayload {
  results: readonly Omit<ResultSlot, 'path'>[];   // 省略 path,符合 64KB 限制
}

// PLAYER_KICKED(unicast)
// PlayerKickedPayload 為伺服器層型別,不從 packages/shared 匯出。
interface PlayerKickedPayload {
  kickedPlayerId: string;
  reason: string;
}

// SESSION_REPLACED(unicast)
export interface SessionReplacedPayload {
  message: string;
}

// HOST_TRANSFERRED(broadcast)
export interface HostTransferredPayload {
  newHostId: string;
  reason: string;  // 實作值為 'disconnect_timeout',型別為寬鬆 string
}

// ERROR(unicast)
export interface ErrorPayload {
  code: string;
  message: string;
  // requestId 不在 shared 型別中;如需追蹤請求,由伺服器層自行擴展
}

額外匯出型別(packages/shared 存在但 §3 未列出)

// 房間摘要(用於大廳列表等場景)
export interface RoomSummaryPayload {
  code: string;
  status: RoomStatus;
  playerCount: number;
  onlineCount: number;
  maxPlayers: number;
}

§4 Validation Rules

§4.1 nickname(暱稱)

規則
最小長度1 字元
最大長度20 字元
字元限制禁止 null 字元與控制字元(\x00-\x1F\x7F
AJV pattern^[^\x00-\x1F\x7F]{1,20}$
唯一性同一房間內暱稱必須唯一(API 回傳 NICKNAME_TAKEN

§4.2 roomCode(房間代碼)

規則
長度固定 6 字元
字元集A-HJ-NP-Z2-9(排除易混淆字元 IO01,共 32 字元)
AJV pattern^[A-HJ-NP-Z2-9]{6}$
唯一性Redis SET NX;衝突時重試最多 10 次

§4.3 winnerCount(中獎名額)

規則
類型正整數
最小值1
最大值N - 1(N = 全部玩家數,含 isOnline=false)
設定時機建立房間時(POST /rooms)或 waiting 狀態時由房主調整
PLAY_AGAIN 後若 W >= 新玩家數,自動重設為 null

§4.4 autoRevealIntervalSec(自動揭示間隔)

規則
類型正整數
最小值1(秒)
最大值30(秒)
僅在 mode=auto 時有效mode=manual 時值為 null

§4.5 玩家數限制

規則
最小玩家數(開始遊戲)2
最大玩家數50
START_GAME 時 Nplayers 陣列全部長度(含 isOnline=false 的斷線玩家)

§4.6 JWT Token

屬性
算法HS256(HMAC-SHA256)
TTL6 小時(21600 秒)
Clock Skew30 秒容忍(WS Upgrade 驗證時)
Payload`{ playerId, roomCode, role: "host""player", exp, iat }`
密鑰來源環境變數 JWT_SECRET,最少 256-bit(32 bytes)
已知安全取捨JWT exp 僅在 WS Upgrade 時驗證;連線建立後不重驗 exp(MVP 接受)

§4.7 WebSocket 訊息

規則
maxPayload65536 bytes(64KB)
速率限制60 msg/min/connection
超限行為close 4029
格式UTF-8 JSON 字串(非 Binary frame)

SCHEMA 版本:v2.1

生成時間:2026-04-21(devsop-autodev STEP-09)

修訂時間:2026-04-21(devsop-autodev STEP-12 Schema Review Round 3)

基於 EDD v2.0 + PRD v1.4 + legacy-SCHEMA v1.1 + packages/shared/src/types/index.ts