climb_stairs

工程設計文件 (EDD)

EDD — Engineering Design Document

Ladder Room Online 工程設計文件

Document Control

欄位內容
Versionv2.0
StatusActive
Date2026-04-21
AuthorAI EDD Agent(devsop-autodev STEP-07)
Based OnBRD v1.0 + PRD v1.0 + legacy-EDD v1.4 + legacy-ARCH v1.2
Stakeholders前端工程師、後端工程師、QA、DevOps

§1 Executive Summary

§1.1 系統目的

Ladder Room Online 是一款基於 HTML5 Canvas 的多人線上爬樓梯抽獎系統,採用 WebSocket 長連接驅動即時遊戲狀態同步,支援最多 50 名玩家共享同一房間。系統設計目標為取代 LINE 原生爬樓梯的功能限制,支援異地多人同房、主持人掌控揭曉節奏、結果公正可驗證。

§1.2 技術選型總覽

層級技術版本 / 備註
RuntimeNode.js20 LTS
語言TypeScriptstrict mode,前後端共用
HTTP ServerFastifyREST API (/api/rooms/*, /health, /ready)
WebSocketws(原生)ws npm package,maxPayload: 65536
快取 / 狀態Redis原子操作、Pub/Sub、房間持久化
前端框架無(Vanilla TypeScript + Vite)無 UI 框架,零依賴,HTML5 Canvas 渲染
Monorepo 結構npm workspacespackages/sharedpackages/serverpackages/client
測試Vitest + PlaywrightUnit/Integration(Vitest)+E2E(Playwright)
CI/CDGitHub Actionslint → audit → test → build → e2e → deploy
容器Docker(Distroless Node.js 20 / Nginx 1.27-alpine)多階段建構;server + client 各自獨立 image
編排Kubernetes(Traefik Ingress)Rancher Desktop 本機開發;HPA 為 Post-MVP
前端部署(本機)Nginx Pod(ladder-client:local)Dockerfile.client 建構,imagePullPolicy: Never
前端部署(生產)GitHub Pages CDNVite 建構產物,bundle < 150KB gzip
本機開發腳本`./scripts/dev-k8s.sh [up\down\restart\logs]`一鍵啟動完整 k8s 環境,http://ladder.local

client_type: web(瀏覽器端,HTML5 Canvas + Vanilla TypeScript)

§1.3 關鍵設計決策摘要

  1. Vanilla TypeScript + Vite(無 UI 框架)— 確保最小 JS bundle,無框架冗餘依賴
  2. WebSocket(ws 原生)(非 Socket.IO)— 雙向通訊,標準協定,無 polling fallback 需求
  3. Redis 作為唯一持久層(非 in-memory)— Pod 重啟後狀態存活,跨 Pod Pub/Sub 廣播
  4. Clean Architecture + packages/shared(核心邏輯前後端共用)— 算法一致性可驗證,結果可事後審計
  5. Mulberry32 PRNG + djb2 seed + Fisher-Yates(確定性 PRNG)— 100% 客戶端結果一致,無舞弊空間
  6. Kubernetes 兩 Pod 架構(server pod + client nginx pod)— 本機與生產環境統一,前後端分離部署

§2 System Architecture

§2.1 High-Level Architecture

系統採用四層架構:Client → Ingress → Fastify/WS Server → Redis。

§2.2 Component Diagram

graph TD
    subgraph Client["Client Browser"]
        TS["Vanilla TS + Vite"]
        Canvas["HTML5 Canvas"]
        WSClient["WsClient"]
        TS --> Canvas
        TS --> WSClient
    end

    subgraph Ingress["Traefik Ingress (ladder.local)"]
        NginxHTTP["HTTP /api/*\nFastify REST"]
        NginxWS["WebSocket /ws\nWS Upgrade"]
        NginxStatic["Static / (catch-all)\nClient Pod"]
    end

    subgraph K8sCluster["Kubernetes Cluster (namespace: ladder-room)"]
        SVC["Service: ladder-server-service\nClusterIP port:80→3000"]
        ClientSVC["Service: ladder-client-service\nClusterIP port:80"]

        subgraph ClientPod["Client Pod"]
            NginxPod["Nginx 1.27-alpine\nladder-client:local\nSPA static files"]
        end

        subgraph Pods["Fastify Pods (MVP: replicas=1)"]
            Pod1["Pod-1 Fastify+ws\nport:3000"]
        end

        SVC --> Pod1
        ClientSVC --> NginxPod

        subgraph Redis["Redis StatefulSet"]
            RedisMaster["Redis Master\nredis-0"]
            RedisReplica["Redis Replica\nredis-1 (Post-MVP)"]
            RedisMaster --> RedisReplica
        end

        Pod1 --> RedisMaster
        Pod1 --> |"PSUBSCRIBE room:*:events"| RedisMaster
    end

    WSClient --> |"WSS /ws?room=...&token=..."| NginxWS
    TS --> |"HTTPS /api/*"| NginxHTTP
    NginxHTTP --> |"sticky session"| SVC
    NginxWS --> |"sticky session"| SVC
    NginxStatic --> ClientSVC

§2.3 Data Flow Diagram

sequenceDiagram
    participant C as Client Browser
    participant N as Traefik Ingress
    participant F as Fastify Pod
    participant R as Redis

    Note over C,R: 建立房間 + 加入流程

    C->>N: POST /api/rooms { hostNickname, winnerCount }
    N->>F: HTTP forward
    F->>R: SETNX room:{code} (原子建立)
    R-->>F: OK / room data
    F-->>C: 201 { roomCode, playerId, sessionToken(JWT) }

    C->>N: POST /api/rooms/:code/players { nickname }
    N->>F: HTTP forward
    F->>R: HGET room:{code} → 驗證狀態、暱稱唯一
    F->>R: SADD room:{code}:players {playerId}
    R-->>F: OK
    F-->>C: 201 { playerId, sessionToken, colorIndex }

    C->>N: WSS /ws?room={code}&token={token}
    N->>F: WebSocket Upgrade(sticky session)
    F->>F: 驗證 JWT HS256 + kickedPlayerIds 攔截
    F->>R: HGET room:{code} load state
    R-->>F: Room JSON
    F-->>C: WS Connected
    F->>C: ROOM_STATE_FULL { room, players, ladder:null, selfPlayerId }

    Note over C,F: Host 開始遊戲

    C->>F: WS MSG: START_GAME
    F->>R: WATCH room:{code}
    F->>R: MULTI / SET status=running, seedSource=UUID, rowCount / EXEC
    R-->>F: EXEC OK (原子操作)
    F->>R: PUBLISH room:{code}:events ROOM_STATE(running, rowCount)
    R-->>F: 廣播至所有訂閱 Pod
    F-->>C: Broadcast ROOM_STATE { status: running, rowCount } (不含 seed/ladder)

    Note over C,F: Host 開始揭曉

    C->>F: WS MSG: BEGIN_REVEAL
    F->>F: 應用層計算 generateLadder + computeResults
    F->>R: Lua Script 原子寫入 ladder/results/status=revealing
    R-->>F: OK
    F->>R: PUBLISH room:{code}:events ROOM_STATE(revealing)
    F-->>C: Broadcast ROOM_STATE { status: revealing }

    Note over C,F: 逐步揭曉

    C->>F: WS MSG: REVEAL_NEXT
    F->>R: INCR room:{code}:revealedCount (原子)
    R-->>F: newCount
    F->>R: WATCH+MULTI/EXEC SET Room JSON (revealedCount=newCount)
    F->>R: PUBLISH REVEAL_INDEX { playerIndex, path, result }
    F-->>C: Broadcast REVEAL_INDEX

    Note over C,F: 結束本局

    C->>F: WS MSG: END_GAME (revealedCount === totalCount)
    F->>R: WATCH+MULTI/EXEC SET status=finished / TTL 延長 1h
    F->>R: PUBLISH ROOM_STATE { status: finished, seed, results[] }
    F-->>C: Broadcast ROOM_STATE (seed 首次公開)

§2.4 Deployment Architecture

Kubernetes 部署(namespace: ladder-room):

graph TD
    subgraph NS["Namespace: ladder-room"]
        subgraph IngressLayer["Ingress Layer"]
            ING["Traefik Ingress\ningressClassName: traefik\naffinity: cookie (sticky session)\nwebsocket-services: ladder-server-service"]
        end

        subgraph ClientLayer["Client Layer"]
            CDEP["Deployment: ladder-client\nreplicas: 1\nimage: ladder-client:local\nimagePullPolicy: Never"]
            CPOD["Nginx 1.27-alpine Pod\nport:80\nSPA static files"]
            CSVC["Service: ladder-client-service\nClusterIP port:80"]
            CDEP --> CPOD
            CSVC --> CPOD
        end

        subgraph AppLayer["Application Layer"]
            DEP["Deployment: ladder-server\nreplicas: 1 (MVP)\nimage: distroless/nodejs20\nimagePullPolicy: Never"]
            POD1["Pod-1 Fastify+ws port:3000"]
            DEP --> POD1
            HPA["HPA (Post-MVP)\nminReplicas:2 maxReplicas:10\nmetric: ws_connections target:200"]
            HPA --> DEP
        end

        subgraph ServiceLayer["Service Layer"]
            SVC["Service: ladder-server-service\nClusterIP port:80→3000"]
        end

        subgraph StatefulLayer["Redis"]
            RSTS["StatefulSet: redis\nMVP replicas:1"]
            RPOD0["redis-0 master port:6379"]
            RSVC["Service: redis-svc ClusterIP"]
            RSTS --> RPOD0
        end

        subgraph ConfigLayer["Config"]
            CM["ConfigMap\nREDIS_HOST PORT LOG_LEVEL CORS_ORIGIN"]
            SEC["Secret\nJWT_SECRET REDIS_PASSWORD"]
        end

        ING --> |"/api /ws /health /ready"| SVC
        ING --> |"/ (catch-all)"| CSVC
        SVC --> POD1
        POD1 --> RSVC
        RSVC --> RPOD0
        POD1 --> CM
        POD1 --> SEC
    end

本機開發環境(Rancher Desktop):


§3 Technology Stack

類別技術版本說明
RuntimeNode.js20 LTS後端執行環境
語言TypeScriptstrict mode前後端共用,.js 副檔名 ESM 輸出
HTTP FrameworkFastify4.xREST API,AJV Schema 驗證
WebSocketws8.x原生 WS,maxPayload: 65536(64KB)
快取 / 狀態Redis6+WATCH/MULTI/EXEC 原子操作、Pub/Sub、TTL
Redis Clientioredis5.xsingleton + duplicate(Pub/Sub 專用)
JWTjose5.xHS256 簽章,Web Crypto API 相容
前端框架Vanilla TypeScript無 UI 框架,零依賴
Build ToolVite5.x前端建構,HMR 開發伺服器
CanvasHTML5 Canvas 2D梯子渲染,requestAnimationFrame 驅動
Monoreponpm workspacespackages/shared、packages/server、packages/client
Unit TestVitest1.x前後端共用測試框架
Integration Testtestcontainers真實 Redis 容器整合測試
E2E TestPlaywright1.x全流程 E2E 測試
ContainerDockerDistroless Node.js 20(server)、Nginx 1.27-alpine(client)
OrchestrationKubernetes1.28+Rancher Desktop 本機;Traefik Ingress
CI/CDGitHub Actionslint → audit → test → build → e2e → deploy
Logpino9.xStructured JSON 日誌
Metricsprom-clientPrometheus 指標(ws_active_connections 等)

§4 Module Design

§4.1 Server Modules

整體目錄結構(Clean Architecture 分層):

packages/server/src/
├── infrastructure/
│   └── redis/
│       ├── IRoomRepository.ts    # 倉儲介面(IRoomRepository)
│       ├── RedisClient.ts        # ioredis singleton
│       └── RoomRepository.ts    # Redis CRUD + TTL + 原子操作;IRoomRepository 實作
├── application/
│   ├── services/
│   │   ├── RoomService.ts        # 業務邏輯協調(建立/加入/踢除/再玩一局)
│   │   └── GameService.ts        # 遊戲流程(START_GAME/BEGIN_REVEAL/REVEAL/END_GAME)
│   └── handlers/                 # (預留目錄,WS 路由邏輯內嵌於 main.ts)
├── domain/
│   └── errors/
│       └── DomainError.ts        # base typed error
├── container.ts                  # DI 工廠,組裝所有依賴
└── main.ts                       # 啟動入口:Fastify + ws.Server(含 JWT 驗證、速率限制、廣播邏輯)+ Pub/Sub 訂閱

實作說明(MVP): WebSocket 升級/驗證、HTTP 路由、Pub/Sub 廣播等邏輯目前集中於 main.ts(< 800 行);container.ts 組裝 DI;RoomService/GameService 封裝業務邏輯;Post-MVP 可按下列分拆方向重構:WsServer.ts(ws.Server 封裝)、PubSubBroker.ts(Pub/Sub)、presentation routes/schemas/plugins。

各模組單句職責:

模組職責
IRoomRepository.ts定義倉儲介面:Room CRUD 方法簽名,供 Service 層依賴注入
RedisClient.ts建立並匯出 ioredis 單例(含連線重試設定)
RoomRepository.ts實作 IRoomRepository:對 Redis 進行 Room 的 CRUD、TTL 管理;WATCH/MULTI/EXEC 原子操作;BEGIN_REVEAL 使用 Lua Script
RoomService.ts協調房間建立、加入、踢出等業務流程,呼叫 RoomRepository 並觸發廣播
GameService.ts協調遊戲開局(START_GAME)、開始揭曉(BEGIN_REVEAL)、逐一揭曉(REVEAL_NEXT)、全揭(REVEAL_ALL_TRIGGER)、結束(END_GAME)、再玩一局(PLAY_AGAIN)、重置房間(RESET_ROOM)流程
container.ts以工廠函式組裝所有依賴(DI 根),回傳完整注入樹,不含業務邏輯
main.ts啟動 Fastify(REST 路由 + AJV Schema + CORS + Rate Limit)、ws.Server(JWT 驗證、kickedPlayerIds 攔截 close 4003、Origin 驗證、心跳 ping/pong 30s、速率限制 60 msg/min),以及 Redis Pub/Sub 訂閱;含 graceful shutdown

§4.2 Client Modules

packages/client/src/
├── ui/
│   ├── lobby.ts              # 大廳頁面:建立/加入房間 UI 邏輯(含 localStorage 暱稱預填 + URL 房號解析)
│   ├── waitingRoom.ts        # 等待大廳:玩家列表、複製邀請連結、主持人控制
│   ├── game.ts               # 遊戲視圖:Canvas 容器、揭曉控制按鈕、結果展示
│   └── toast.ts              # Toast 通知元件(短暫提示訊息)
├── canvas/
│   ├── renderer.ts           # Canvas 2D 梯子繪製(rails、rungs、paths、winner stars);requestAnimationFrame 動畫驅動
│   └── colors.ts             # 玩家色彩系統:colorFromIndex / colorFromIndexDim(最多 50 色)
├── state/
│   ├── store.ts              # 客戶端房間狀態(基於 ROOM_STATE_FULL / ROOM_STATE 更新)
│   └── LocalStorageService.ts # localStorage 讀寫(playerId、ladder_last_nickname)
├── ws/
│   └── client.ts             # WebSocket 連線管理、指數退避重連(1/2/4/8/30s);事件分派至 UI
└── main.ts                   # 入口:初始化 WS client、store、UI 模組

localStorage Keys:

Key型別用途生命週期
playerIdstring (UUID v4)玩家身份識別,用於斷線重連永久(被踢除後 clearPlayerId)
ladder_last_nicknamestring (1-20 chars)記憶上次使用的暱稱,下次加入時自動預填永久(每次成功加入時更新)

邀請連結規格:

§4.3 Shared Package(packages/shared)

packages/shared/src/
├── use-cases/
│   ├── GenerateLadder.ts     # 梯子生成 use case(pure function)
│   ├── ValidateGameStart.ts  # N>=2, 1<=W<=N-1 驗證
│   └── ComputeResults.ts     # 路徑追蹤 → ResultSlot[]
├── prng/
│   ├── mulberry32.ts         # Mulberry32 PRNG 實作
│   ├── djb2.ts               # seed hash(string → uint32)
│   └── fisherYates.ts        # Fisher-Yates 洗牌
├── types/
│   └── index.ts              # 所有共用 TypeScript interface/type(Room、Player、LadderData 等)
└── index.ts                  # 公開匯出入口

主要匯出類型:

類型說明
RoomStatus`"waiting" \"running" \"revealing" \"finished"`
Player{ id, nickname, colorIndex, isHost, isOnline, joinedAt, result }
LadderData{ seed, seedSource, rowCount, colCount, segments }
LadderDataPublic{ rowCount, colCount, segments }(省略 seed,revealing 狀態使用)
PathStep`{ row, col, direction: "down" \"left" \"right" }`
ResultSlot{ playerIndex, playerId, startCol, endCol, isWinner, path }
ResultSlotPublicOmit<ResultSlot, "path">(REVEAL_ALL payload 使用,符合 64KB 限制)
Room完整房間物件(含 players, ladder, results, kickedPlayerIds 等)
ServerEnvelope<T>{ type: WsEventType, ts, payload: T }
ClientEnvelope<T>{ type: WsMsgType, ts, payload: T }

Import 規則:


§5 Key Algorithms

§5.1 Ladder Generation Algorithm

函式: packages/shared/src/use-cases/GenerateLadder.ts

核心邏輯:

export function generateLadder(seedSource: string, N: number): LadderData {
  const seed = djb2(seedSource);
  const rng = createMulberry32(seed);

  const rowCount = Math.min(Math.max(N * 3, 20), 60);
  const colCount = N;
  const maxBarsPerRow = Math.max(1, Math.round(N / 4));

  // Bar density — fewer possible positions → lower density to preserve randomness
  // N=2 has only 1 position; without skipping, every row would be identical
  const possiblePositions = N - 1;
  const barDensity = possiblePositions <= 1 ? 0.50
    : possiblePositions <= 2 ? 0.65
    : possiblePositions <= 3 ? 0.75
    : 0.90;

  const segments: LadderSegment[] = [];

  for (let row = 0; row < rowCount; row++) {
    if (rng() > barDensity) continue;           // skip row if rng > barDensity

    const usedCols = new Set<number>();
    let barsPlaced = 0;

    for (let attempt = 0; attempt < maxBarsPerRow; attempt++) {
      let col = Math.floor(rng() * (N - 1));

      // Retry: linear scan up to N-1 attempts to find a free column
      let found = false;
      for (let retry = 0; retry < N - 1; retry++) {
        const candidate = (col + retry) % (N - 1);
        if (!usedCols.has(candidate) && !usedCols.has(candidate + 1)) {
          col = candidate;
          found = true;
          break;
        }
      }

      if (!found) break;

      usedCols.add(col);
      usedCols.add(col + 1);
      segments.push({ row, col });
      barsPlaced++;
      if (barsPlaced >= maxBarsPerRow) break;
    }
  }

  return { seed, seedSource, rowCount, colCount, segments };
}

關鍵參數:

參數公式說明
rowCountclamp(N×3, 20, 60)N=2→20,N=10→30,N=21→60(最小 20,最大 60)
colCount= N含所有玩家(含 isOnline=false)
maxBarsPerRowmax(1, round(N/4))每 row 最多橫槓數
possiblePositionsN - 1可能的橫槓位置數量
barDensityN-1=1:0.50, N-1=2:0.65, N-1=3:0.75, N-1≥4:0.90每 row 出現橫槓的機率
Segment 衝突防護`usedCols.has(col) \\usedCols.has(col+1)`同 row 橫槓不重疊,最多重試 N-1 次

seed 生成:

§5.2 Path Calculation Algorithm

函式: packages/shared/src/use-cases/ComputeResults.ts

從頂部追蹤到底部,遇到橫槓段(segment)則切換欄位:

for (let startCol = 0; startCol < colCount; startCol++) {
  let col = startCol;
  const path: PathStep[] = [];
  for (let row = 0; row < rowCount; row++) {
    if (segmentSet.has(`${row}:${col}`)) {
      path.push({ row, col, direction: "right" });  // 右側有橫槓 → 向右
      col++;
    } else if (col > 0 && segmentSet.has(`${row}:${col - 1}`)) {
      path.push({ row, col, direction: "left" });   // 左側有橫槓 → 向左
      col--;
    } else {
      path.push({ row, col, direction: "down" });   // 無橫槓 → 向下
    }
  }
  paths.push(path);
  endCols.push(col);
}

中獎指派(Fisher-Yates bijection):

// Step 3: Fisher-Yates 洗牌決定哪些 endCol 為中獎(消耗 colCount 次 rng)
const indices = Array.from({ length: colCount }, (_, i) => i);
const shuffled = fisherYatesShuffle(indices, rng);
const winnerEndCols = new Set(shuffled.slice(0, winnerCount));

§5.3 Canvas Rendering

函式: packages/client/src/canvas/renderer.ts

繪製流程(drawLadder):

  1. Rails(垂直柱):從頂到底繪製每條垂直線(N 條)
  2. Rungs(橫段):依 segments[] 繪製所有橫槓(連接 col 與 col+1)
  3. Revealed paths(已揭曉路徑):依 PathStep[] 逐格繪製走線動畫
  1. Player names(玩家名稱):頂部各列顯示玩家暱稱
  2. Winner stars(中獎標記):中獎者 shadowBlur=10 金色光暈效果

顏色系統(packages/client/src/canvas/colors.ts):

FPS 目標:

PRNG 實作(djb2 + Mulberry32 + Fisher-Yates):

// djb2 hash
export function djb2(str: string): number {
  let hash = 5381;
  for (let i = 0; i < str.length; i++) {
    hash = (Math.imul(hash, 33) + str.charCodeAt(i)) | 0;
  }
  return hash >>> 0;
}

// Mulberry32 PRNG
export function createMulberry32(seed: number): () => number {
  let s = seed >>> 0;
  return function next(): number {
    s += 0x6d2b79f5;
    let t = Math.imul(s ^ (s >>> 15), 1 | s);
    t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
    return ((t ^ (t >>> 14)) >>> 0) / 0x100000000;
  };
}

// Fisher-Yates shuffle
export function fisherYatesShuffle<T>(arr: readonly T[], rng: () => number): T[] {
  const result = [...arr];
  for (let i = result.length - 1; i > 0; i--) {
    const j = Math.floor(rng() * (i + 1));
    [result[i], result[j]] = [result[j], result[i]];
  }
  return result;
}

§6 WebSocket Protocol

§6.1 Message Types

連線端點: WSS /ws?room={code}&token={sessionToken}

Server Upgrade 階段驗證 JWT token,失敗直接 403。

Server → Client 事件(WsEventType):

事件觸發時機說明
ROOM_STATE房間狀態變更廣播至所有玩家(摘要,不含 ladder/results)
ROOM_STATE_FULLWS 連線成功後 unicast含 ladder + results + selfPlayerId;新連線與重連均觸發
REVEAL_INDEX手動/自動揭曉單一玩家{ playerIndex, result(含 path), revealedCount, totalCount }
REVEAL_ALL一鍵全揭{ results: ResultSlotPublic[], room }(省略 path,符合 64KB 限制)
PLAYER_KICKED玩家被踢除unicast 給被踢玩家;WS close code 4003
SESSION_REPLACED同一 playerId 新連線登入發給被替換的舊連線
HOST_TRANSFERRED主持人轉移(Post-MVP)廣播新 hostId
ERROR操作失敗{ code, message };unicast 給觸發方

Client → Server 訊息(WsMsgType):

訊息說明
START_GAMEHost 開始遊戲(waiting→running)
BEGIN_REVEALHost 開始揭曉(running→revealing)
REVEAL_NEXTHost 手動揭曉下一位
REVEAL_ALL_TRIGGERHost 一鍵全揭
SET_REVEAL_MODE切換手動/自動模式;auto 時 intervalSec 必填(1-300 整數)
END_GAMEHost 結束本局(revealing→finished,需 revealedCount===totalCount)
PLAY_AGAINHost 再玩一局(finished→waiting)
RESET_ROOMHost 重置房間(任意狀態,清空結果回 waiting)
KICK_PLAYERHost 踢除玩家;payload: { targetPlayerId }
PING應用層心跳(Server 不回 PONG,僅維持連線)

訊息格式:

// Server → Client
interface ServerEnvelope<T = unknown> {
  readonly type: WsEventType;
  readonly ts: number;
  readonly payload: T;
}

// Client → Server
interface ClientEnvelope<T = unknown> {
  readonly type: WsMsgType;
  readonly ts: number;
  readonly payload: T;
}

安全限制:

§6.2 Connection Lifecycle

connect
  → JWT 驗證(main.ts ws.Server upgrade handler)
  → kickedPlayerIds 攔截(close 4003 if kicked)
  → Origin 驗證(CORS_ORIGIN 白名單)
  → WS session 建立(roomSessions map)
  → unicast ROOM_STATE_FULL

game loop
  JOIN_ROOM → ROOM_STATE(廣播)
  START_GAME → ROOM_STATE(running)
  BEGIN_REVEAL → ROOM_STATE(revealing)
  REVEAL_NEXT / auto-timer → REVEAL_INDEX(廣播)
  REVEAL_ALL_TRIGGER → REVEAL_ALL(廣播)
  END_GAME → ROOM_STATE(finished,seed 公開)

disconnect handling
  ws close event → player.isOnline = false → 廣播 ROOM_STATE
  60s grace period → HOST_TRANSFERRED(Post-MVP)
  最後一位斷線 5 分鐘 → EXPIRE room:{code} 300

reconnect
  帶 playerId → ROOM_STATE_FULL(狀態快照)
  同一 playerId 新連線 → SESSION_REPLACED 至舊連線
  kickedPlayerId → close 4003

WS 重連策略(Client)
  指數退避:1s / 2s / 4s / 8s / 30s(上限)
  5 次失敗後停止,顯示「無法連線」+ 手動重試按鈕

§7 Data Models

§7.1 Redis Key Schema

Redis Key類型內容TTL
room:{code}String(JSON)Room 完整物件waiting/running: 24h;finished: 1h;最後一人斷線: 5min
room:{code}:ladderString(JSON)LadderData(含 seed、seedSource、segments、results);BEGIN_REVEAL 時才創建同 room:{code}
room:{code}:revealedCountIntegerINCR 原子遞增,唯一計數真相來源同 room:{code}

§7.2 Room Object

interface Room {
  code: string;                          // 6-char room code
  status: RoomStatus;                    // waiting/running/revealing/finished
  hostId: string;                        // Host playerId
  readonly players: readonly Player[];   // max 50,含 isOnline=false 的斷線玩家
  winnerCount: number | null;            // W(1 <= W <= N-1);null 直到 Host 設定
  ladder: LadderData | null;             // null 直到 BEGIN_REVEAL
  results: readonly ResultSlot[] | null;
  revealedCount: number;                 // 已揭曉數(快照;唯一計數來源為 :revealedCount key)
  revealMode: "manual" | "auto";
  autoRevealIntervalSec: number | null;  // 1-300s;null 為手動模式
  readonly kickedPlayerIds: readonly string[];  // 本局被踢玩家 playerId,RESET_ROOM/PLAY_AGAIN 後清空
  createdAt: number;
  updatedAt: number;
}

§7.3 Player Object

interface Player {
  id: string;              // UUID v4
  nickname: string;        // 1-20 chars
  colorIndex: number;      // 0-49
  isHost: boolean;         // 派生:id === room.hostId
  isOnline: boolean;
  joinedAt: number;        // Unix ms
  result?: string | null;  // null 直到揭曉;winner/loser 字串由 GameService 寫入
}

§7.4 Game State 生命週期

stateDiagram-v2
    [*] --> waiting : POST /api/rooms 建立

    waiting --> waiting : player_joined / player_left / host updates prizes
    waiting --> running : host START_GAME\nN>=2 AND 1<=W<=N-1\n原子: seed生成(不含梯子)

    running --> revealing : host BEGIN_REVEAL\n此時原子生成 LadderData + ResultSlots\nbcast ROOM_STATE status:revealing

    revealing --> revealing : REVEAL_NEXT(手動)/ auto-timer(自動)\nrevealedCount < N\nbcast REVEAL_INDEX path result
    revealing --> revealing : SET_REVEAL_MODE 切換手動↔自動
    revealing --> revealing : REVEAL_ALL_TRIGGER\nbcast REVEAL_ALL(剩餘路徑)

    revealing --> finished : host END_GAME\n(revealedCount === totalCount)\nbcast ROOM_STATE status:finished 含 seed\nTTL 延長 1h

    finished --> waiting : host PLAY_AGAIN\n剔除 isOnline=false 玩家\n清空 kickedPlayerIds\nbcast ROOM_STATE waiting

    waiting --> waiting : host RESET_ROOM(任意狀態可觸發)\n清空結果與 kickedPlayerIds\nbcast ROOM_STATE waiting
    running --> waiting : host RESET_ROOM
    revealing --> waiting : host RESET_ROOM
    finished --> waiting : host RESET_ROOM

    waiting --> [*] : room TTL expired
    running --> [*] : all disconnected 5 min
    finished --> [*] : room TTL expired 1h

§8 API Design Overview

§8.1 HTTP REST Endpoints

所有端點掛載於 /api/(無版本前綴),回應格式為直接 JSON(無 success/data/error 包裝)。

JWT TTL:6 小時exp = iat + 21600)。

MethodPath描述成功碼主要錯誤碼
POST/api/rooms建立房間(hostNickname, winnerCount);回傳 { roomCode, playerId, token, room }201400 VALIDATION_ERROR, 409
GET/api/rooms/:code查詢房間公開摘要(unauthenticated);回傳 { code, status, playerCount, onlineCount, maxPlayers }200404 ROOM_NOT_FOUND
POST/api/rooms/:code/players加入房間(nickname);回傳 { playerId, token, room }201400/404/409
DELETE/api/rooms/:code/players/:playerId踢出玩家(需 Authorization token)204401/403/404
POST/api/rooms/:code/game/start開始遊戲(需 token)200400/401/409
POST/api/rooms/:code/game/reveal揭曉(需 token);body: `{ mode: "next" \"all" }`200400/401/409
POST/api/rooms/:code/game/reset重置房間回 waiting(需 token,任意狀態可用)200401/403/409
POST/api/rooms/:code/game/end結束本局(需 token;revealing 狀態,所有路徑已揭曉)200401/403/409
POST/api/rooms/:code/game/play-again再玩一局(需 token;finished 狀態限定)200401/403/409
GET/health健康檢查(liveness);回傳 { status, redis, wsCount, uptime }200
GET/ready就緒檢查(readiness);Redis 失敗時回傳 503200503

Rate Limiting:

§8.2 Authentication


§9 Performance Design

§9.1 Redis Usage

操作Redis 命令用途
房間建立SETNX(原子)確保 Room Code 唯一性
狀態讀取GET room:{code}取得完整 Room JSON
狀態更新WATCH + MULTI/EXEC樂觀鎖,防 concurrent update
BEGIN_REVEALLua Script原子寫入 ladder/results/status=revealing
revealedCount 遞增INCR room:{code}:revealedCount原子計數,防 race condition
跨 Pod 廣播PUBLISH/PSUBSCRIBE room:*:eventsat-most-once 廣播語意
Room Code 唯一性重試最多 10 次,超過回傳 ROOM_CODE_GENERATION_FAILED

TTL 策略:

房間狀態TTL
waiting / running24h(活動時 EXPIRE 重置)
finished1h(END_GAME 後 EXPIRE 更新)
最後一位玩家斷線5 分鐘(close event 觸發原子 EXPIRE 300)

Redis 記憶體估算(100 並發房間):

單房間:
  Room JSON (50 players × 200 bytes)  ~10 KB
  Ladder data (N=50, ~600 segments)   ~5 KB
  Results (50 players × 120 steps)    ~72 KB
  小計                                ~90 KB

100 房間:100 × 90 KB = 9 MB
Pub/Sub overhead: ~1 MB
總計 ~10 MB(maxmemory: 512mb 配置)

§9.2 WebSocket Scaling

MVP(單 Pod):

Post-MVP(HPA 多 Pod):

WebSocket QPS 推算:

加入房間高峰(前 5 分鐘):100 房 × 50 人 / 300s ≈ 17 HTTP QPS
揭示階段廣播:100 房 × 1 揭示/s × 50 人 = 5,000 WS 發送/s
Redis 操作:< 500 ops/s(Redis 可承受 100k+ ops/s)

§10 Security Design

§10.1 OWASP Top 10 對應措施

OWASP威脅對應措施
A01 Broken Access Control非 host 操作JWT HS256 驗證(jose),role 欄位;雙重驗證(JWT role + Redis room.hostId)
A02 Cryptographic Failures弱加密JWT HS256(RFC 7519);Redis TLS;Nginx HTTPS + HSTS max-age=31536000
A03 Injection輸入注入Fastify JSON Schema AJV 驗證;nickname AJV pattern ^[^\x00-\x1F\x7F]{1,20}$;roomCode 正則 [A-HJ-NP-Z2-9]{6}
A04 Insecure DesignWS 訊息洪泛ws maxPayload: 65536(64KB);per-connection rate limit 60 msg/min,超限 close 4029
A05 Security Misconfiguration過度曝露隱藏 Server header;CSP default-src 'self' connect-src wss://domain;k8s runAsNonRoot readOnlyRootFilesystem;GET /rooms/:code 僅回傳 RoomSummaryPayload(不含 hostId)
A06 Vulnerable Components舊依賴npm audit --audit-level=high 阻斷 PR;Dependabot 週更新;Distroless image 月重建
A07 Authentication Failures重複連線/踢除重連同 playerId 新連線觸發 SESSION_REPLACED;kickedPlayerIds 在 WS Upgrade 階段攔截(close 4003)
A08 Software IntegritySupply chainCI Docker image SHA256 digest;npm ci lockfile;actions pinned SHA
A09 Logging Failures無可觀測性pino structured log;fluent-bit DaemonSet;HTTP 5xx > 1%/5min 告警
A10 SSRF外部 HTTP 請求後端零 outbound HTTP;connect-src CSP 限制瀏覽器端

§10.2 Seed 防洩漏機制

狀態seed 暴露範圍
waiting / runningseed 不存在(尚未生成)
revealingseed 存在於 Redis,但 ROOM_STATE_FULL 以 LadderDataPublic(省略 seed)傳送客戶端
finishedseed 首次對客戶端公開(含於 ROOM_STATE 廣播);可供事後驗算

§10.3 JWT Security


§11 Error Handling Strategy

§11.1 HTTP 錯誤碼

錯誤碼HTTP說明
ROOM_NOT_FOUND404房間不存在或已過期
ROOM_FULL409已達 50 人上限
ROOM_NOT_ACCEPTING409房間狀態非 waiting(改用 409,房間可能 reset 後重新接受)
NICKNAME_TAKEN409nickname 重複
PLAYER_NOT_FOUND404玩家不存在
PLAYER_NOT_HOST403需要房主權限
AUTH_INVALID_TOKEN401JWT Token 無效
AUTH_TOKEN_EXPIRED401JWT Token 過期
INSUFFICIENT_PLAYERS400N < 2(開始遊戲時)
INSUFFICIENT_ONLINE_PLAYERS400再玩一局時在線玩家 < 2
PRIZES_NOT_SET400W 尚未設定
INVALID_PRIZES_COUNT400W < 1 或 W >= N
INVALID_STATE409操作不符合當前狀態
CANNOT_KICK_HOST400踢除操作目標為 Host 本身
INVALID_NICKNAME400暱稱格式不合法
ROOM_CODE_GENERATION_FAILED500Room Code 碰撞重試超過 10 次
TITLE_UPDATE_NOT_ALLOWED_IN_STATE409在非 waiting 狀態嘗試更新 title
INVALID_INTERVAL400intervalSec 不為有限數字或超出 1-300 範圍(WS ERROR,非 HTTP)
SYS_INTERNAL_ERROR500非預期錯誤
RATE_LIMIT429超過速率限制

§11.2 WebSocket 重連策略

場景行為
WS 中斷指數退避重連(1/2/4/8/30s max)
5 次重連失敗顯示「無法連線」,停止重連,手動重試按鈕
SESSION_REPLACEDModal 提示,跳回首頁
WS ERRORToast 對應訊息,保持連線
HTTP 4xxToast 顯示 error.message
HTTP 500Toast「系統錯誤」+ requestId

§11.3 Redis 失敗降級策略


§12 Testing Strategy

§12.1 測試金字塔

層級工具比例涵蓋範圍
Unit TestsVitest70%djb2、Mulberry32、Fisher-Yates、GenerateLadder、ValidateGameStart、ComputeResults、RoomRepository(mock Redis)、GameService(mock IRoomRepository)、HTTP Schemas(AJV)
Integration TestsVitest + testcontainers20%RoomRepository 對真實 Redis CRUD/TTL/原子 INCR;Pub/Sub PUBLISH/SUBSCRIBE;Fastify 路由 HTTP 請求;WS 連線建立、認證失敗(403)、ROOM_STATE_FULL 接收
E2E TestsPlaywright10%完整流程(2 玩家)建立→加入→開始→揭示→結束;踢除玩家;斷線重連;50 人上限

§12.2 覆蓋率目標

套件目標
packages/shared≥ 90%
packages/server(application/domain)≥ 80%
packages/client(Canvas 渲染邏輯、WS 事件解析)≥ 70%

§12.3 關鍵測試案例

類型測試案例
Unitdjb2 已知輸入輸出;Mulberry32 序列可重現;Fisher-Yates 無元素遺失
Property-based同 row 無重疊橫槓;所有玩家路徑 endCol 唯一(bijection)
Determinism相同 seed+N 兩次呼叫輸出完全一致(snapshot test)
EdgeN=2, N=50, seed=0, seed=0xFFFFFFFF;rowCount 三邊界值(N=3→20, N=10→30, N=21→60)
Securityseed 在 finished 前不出現在任何廣播 payload
Loadk6:100 房間 × 50 人 WS 並發,成功率 > 99.5%(加入);P95 延遲 < 2s

§12.4 測試基準線

現有 Vitest 測試:171 tests(截至 2026-04-21)

CI 覆蓋率閘門:

coverage-gate:
  runs-on: ubuntu-latest
  needs: [unit-test, integration-test]
  steps:
    - name: Check coverage >= 80%
      run: npx vitest --coverage --reporter=json

§13 Deployment & Operations

§13.1 Kubernetes 本機開發流程

Prerequisites:

一鍵操作:

# 啟動完整環境(build images + apply manifests)
./scripts/dev-k8s.sh up

# 停止並清理
./scripts/dev-k8s.sh down

# 重新 build 並 rolling restart
./scripts/dev-k8s.sh restart

# 串流所有 Pod 日誌
./scripts/dev-k8s.sh logs

手動步驟(等效):

# 1. build server image
docker build -t ladder-server:local -f Dockerfile .

# 2. build client image
docker build -t ladder-client:local -f Dockerfile.client .

# 3. load images to Rancher Desktop
nerdctl image load -i <(docker save ladder-server:local)
nerdctl image load -i <(docker save ladder-client:local)

# 4. apply k8s manifests
kubectl apply -f k8s/

# 5. 驗證
kubectl get pods -n ladder-room
curl http://ladder.local/health

§13.2 兩個獨立 Pod 架構

PodImagePort說明
ladder-server-*distroless/nodejs20(多階段建構)3000Fastify + ws,REST API + WebSocket
ladder-client-*nginx:1.27-alpine(多階段建構)80SPA 靜態資源服務,catch-all → index.html

§13.3 CI/CD Pipeline

graph TD
    Push["git push / PR opened"]

    subgraph CI["GitHub Actions: CI"]
        Checkout["checkout@v4 SHA-pinned"]
        Setup["setup-node@v4 node:20 cache:npm"]
        Install["npm ci --workspaces"]

        subgraph Parallel["Parallel Jobs"]
            Lint["lint\neslint + tsc --noEmit"]
            Audit["audit\nnpm audit --audit-level=high"]
            Unit["unit-test\nVitest + coverage v8"]
        end

        Integ["integration-test\ntestcontainers Redis\nVitest integration"]
        Build["build\nnpm run build\nDocker multi-stage\ndocker scout cves"]
        E2E["e2e\nDocker compose up\nPlaywright test"]
        Gate["coverage-gate\nfail if < 80%"]
        Release["tag-release\nmain only\ndocker push GHCR\ngit tag v{semver}"]
    end

    Push --> Checkout --> Setup --> Install --> Parallel
    Parallel --> Integ
    Parallel --> Build
    Integ --> Gate
    Build --> E2E
    E2E --> Release
    Gate --> Release

§13.4 環境變數

變數說明本地預設值
NODE_ENVdevelopment / productiondevelopment
PORTFastify 監聽埠3000
JWT_SECRETHS256 簽章金鑰(≥ 32 bytes)dev-secret-do-not-use-in-prod
REDIS_URLioredis 連線字串redis://localhost:6379
REDIS_PASSWORDRedis 驗證密碼(生產必填,k8s Secret 注入)—(本地不設定)
LOG_LEVELpino log leveldebug
CORS_ORIGIN允許的 HTTP Origin(WS Upgrade Origin 驗證)http://localhost:5173

Appendix: ADR(Architecture Decision Records)

ADR-001: Vanilla TypeScript over React/Vue

決策: 前端採用 Vanilla TypeScript + Vite,不使用任何 UI 框架。

理由:

取捨: 需手動管理 DOM 操作與事件處理,測試需自行模擬 Canvas API;後續加入 UI 框架的遷移成本相對較高。


ADR-002: WebSocket over SSE

決策: 即時通訊採用 WebSocket(ws 原生套件),不使用 Server-Sent Events(SSE)。

理由:

取捨: 需自行實作心跳、重連、房間廣播邏輯;MVP 實作集中於 main.ts(roomSessions Map + broadcast helper);複雜度可控。


ADR-003: Redis over In-Memory State

決策: 所有房間狀態儲存於 Redis,Pod 本地記憶體不快取業務狀態。

理由:

取捨: Redis 單節點為 MVP SPOF;Redis 失敗時所有操作降級(503);ioredis 內建重連機制在 Redis 重啟後自動恢復。


ADR-004: packages/shared 前後端共用算法

決策: PRNG(Mulberry32、djb2、Fisher-Yates)、GenerateLadder、ComputeResults 封裝於 packages/shared,前後端共用相同實作。

理由:

取捨: packages/shared 必須保持零 I/O(不得 import Node.js 內建模組),限制了可在共用層處理的邏輯。


ADR-005: JWT HS256 over Session Cookie

決策: Host 身份驗證採用 JWT HS256(jose 套件),而非 Session Cookie。

理由:

取捨: JWT 一旦簽發無法提前撤銷;host 轉移後舊 token 在 exp 前仍有效,須以 room.hostId 雙重驗證。WS 連線建立後不重驗 exp(MVP 取捨:連線生命週期通常遠低於 6h)。


ADR-006: Traefik over NGINX Ingress(Rancher Desktop 本機環境)

決策: 本機 Kubernetes 環境採用 Traefik Ingress(Rancher Desktop 內建),不採用 NGINX Ingress Controller。

理由:

取捨: 生產環境可能使用 NGINX Ingress;k8s manifest 中保留 NGINX 備選設定(已註解)供遷移參考。Post-MVP 升級時若切換 Ingress Controller,需驗證 sticky session 行為一致性。


EDD 版本:v2.0

生成時間:2026-04-21(devsop-autodev STEP-07:從 BRD v1.0 + PRD v1.0 + legacy-EDD v1.4 + legacy-ARCH v1.2 整合重建)

基於 BRD v1.0 + PRD v1.0 + legacy-EDD v1.4 + legacy-ARCH v1.3(Ladder Room Online)

變更追蹤

ECR-20260420-001:HOST copy 邀請 link(含 6 碼房號)+ localStorage 暱稱記憶,一鍵加入