client/game-view.feature
Feature: Game View UI (Canvas)
As a player or host, I want to see the ladder game rendered on canvas so I can watch the reveal animations
Background:
Given I am in the game view of an active room
# --- 遊戲開始時顯示梯子 ---
Scenario: Display ladder on game start with correct rail count
Given the game has started with 3 players
When I view the game canvas
Then I should see 3 vertical rails on the canvas
And each rail should use the dimmed color (colorFromIndexDim) for the player's color index
And each rail should have RAIL_WIDTH of 3px
Scenario: Player names displayed at top of canvas
Given the game has started with players "Alice", "Bob", "Carol"
When I view the game canvas
Then I should see "Alice", "Bob", and "Carol" displayed above their respective rails
And names should be rendered at 13px system-ui font
And my own name should be in bold purple (#a78bfa)
Scenario: Canvas uses dark background
When I view the game canvas
Then the canvas background should be #0f0f1a
And the canvas should adapt to devicePixelRatio for Retina displays
Scenario: Canvas padding matches specification
When I view the game canvas
Then the top padding should be 60px for player names
And the side padding should be 24px on each side
And the bottom padding should be 32px for result slots
Scenario: Horizontal rungs rendered between rails
Given a ladder with rungs defined by the seed
When I view the game canvas
Then the rungs should be rendered at RUNG_WIDTH of 2px
And rungs should have color #3a3a60
# --- 揭曉路徑動畫 ---
Scenario: Reveal path animation plays when host clicks next
Given the game state is "revealing"
And it is player "Bob"'s turn to be revealed
When the host clicks "下一位"
Then I should see an animated ball moving along "Bob"'s path
And the ball should have BALL_RADIUS of 8px with white core and player color shadow
Scenario: Path uses player's assigned color during animation
Given player "Bob" has colorIndex 1
When "Bob"'s path is being revealed
Then the animated path should use color hsl(7.2, 70%, 60%)
And the ball marker should glow with that color at shadowBlur 16
Scenario: Animating path is fully opaque
When a path is currently being animated
Then the path should render with globalAlpha of 1.0
Scenario: Reveal all paths via REVEAL_ALL broadcast
Given the game state is "revealing"
When the host clicks "全部揭曉"
Then all remaining paths should animate sequentially
And all animations should complete within 2 seconds
And if 2 seconds is exceeded the animation jumps to the final frame
Scenario: Host controls visible in revealing state
Given I am the host
And the game state is "revealing"
Then I should see the "下一位" button
And I should see the "全部揭曉" button with gold styling
And I should see the auto-reveal toggle switch
Scenario: Host begin reveal button visible in running state
Given I am the host
And the game state is "running"
Then I should see the "開始揭曉" button
And I should not see the "下一位" button
Scenario: Auto-reveal toggle controls interval input
Given I am the host and the auto-reveal toggle is off
Then the interval input should be disabled with opacity 0.4
When I enable the auto-reveal toggle
Then the interval input should become enabled
And the toggle should show aria-checked="true"
Scenario: Auto-reveal interval validation
Given I am the host and auto-reveal is enabled
When I enter 0 in the interval input
And I start auto-reveal
Then I should see an error "間隔須為 1~30 的整數"
# --- 中獎者以金色光暈高亮 ---
Scenario: Winner highlighted with gold glow effect
Given all paths are revealed
And "Alice" is the winner
When the winner is determined
Then "Alice"'s path should have shadowColor #ffd700 and shadowBlur 10
And a star "★" should appear at "Alice"'s bottom position in gold (#ffd700) at 11px
Scenario: Winner endpoint has gold glow
Given "Alice" is the winner
When her path reaches the bottom
Then the endpoint circle (radius 6px) should have a gold glow
# --- 已完成路徑半透明顯示 ---
Scenario: Completed paths are semi-transparent
Given "Bob"'s path has been fully revealed
When I view the canvas
Then "Bob"'s path should render with globalAlpha of 0.6
Scenario: Currently animating path is fully opaque
Given "Bob"'s path has been revealed (opacity 0.6)
And "Carol"'s path is currently being animated
When I view the canvas
Then "Carol"'s path should render with globalAlpha of 1.0
And "Bob"'s path should still render with globalAlpha of 0.6
Scenario: Multiple revealed paths are semi-transparent
Given 3 paths have been revealed for "Alice", "Bob", "Carol"
When I view the canvas
Then all 3 paths should render with globalAlpha of 0.6
And winner paths should additionally show the gold glow
# --- Canvas 視覺層次 ---
Scenario: Canvas renders visual layers in correct order
Given a game is in revealing state
When I view the canvas
Then the visual layers from bottom to top should be:
| Layer | Description |
| rails | Vertical tracks with dim color |
| rungs | Horizontal bars in #3a3a60 |
| revealed-paths | Player paths at opacity 0.6 |
| animating-path | Current path at opacity 1.0 |
| winner-glow | Gold shadow on winner path |
| ball-marker | Animated ball with glow |
| endpoints | Bottom circles |
| player-names | Names at top of rails |
| winner-star | Gold ★ at bottom of winner |
# --- 佈局與響應式 ---
Scenario: Desktop layout shows sidebar on the right
Given my viewport is 1024px or wider
When I view the game view
Then I should see the canvas on the left taking majority of width
And the sidebar should be on the right at 280px width
Scenario: Mobile layout stacks canvas and sidebar vertically
Given my viewport is narrower than 768px
When I view the game view
Then the canvas should take full width
And the sidebar results list should appear below the canvas
And the host controls should be fixed at the bottom
Scenario: Header shows room name, status badge, and connection dot
When I view the game view header
Then I should see the room name as the game title
And I should see a status badge matching the current game state
And I should see the connection status dot on the right
# --- 結果側欄(揭曉中即時更新)---
Scenario: Result list shows winner entry with crown
Given "Alice" has been revealed as a winner
When I view the sidebar result list
Then I should see "Alice" with a ♛ crown in gold
And the entry background should be #1c1600 with border #b8960a
Scenario: Result list shows non-winner entry
Given "Bob" has been revealed as a non-winner
When I view the sidebar result list
Then I should see "Bob" without a crown
And the entry background should be var(--bg) with dim text
Scenario: My own result entry is highlighted with purple border
Given my path has been revealed
When I view my entry in the result list
Then my entry should have a purple left border of 3px solid #6c63ff
And I should see "(你)" label next to my name
Scenario: Result items animate in sequentially
Given multiple players have been revealed
When I view the result list
Then each result item should fade in with translateY animation of 300ms
And each subsequent item should be delayed by 50ms
client/lobby.feature
Feature: Lobby UI
As a player or host, I want to create or join a game from the lobby
Background:
Given I am on the lobby page
# --- 建立房間 ---
Scenario: Create room from lobby as host
When I enter nickname "Alice" in the create-room form
And I click "建立房間"
Then I should see the waiting room
And my nickname "Alice" should be displayed in the player list
And I should see the host badge next to my name
Scenario: Create room with optional room title
When I enter nickname "Alice" in the create-room form
And I enter room title "年終抽獎 2026"
And I click "建立房間"
Then I should see the waiting room
And the room title "年終抽獎 2026" should be displayed
Scenario: Create room button shows loading state
When I enter nickname "Alice" in the create-room form
And I click "建立房間"
Then the button should display "建立中…" and be disabled during the request
Scenario: Create room failure shows error toast
Given the server returns a create-room error
When I enter nickname "Alice" in the create-room form
And I click "建立房間"
Then I should see an error toast "建立失敗,請重試"
And the nickname input should still contain "Alice"
# --- 加入房間 ---
Scenario: Join room from lobby
When I enter nickname "Bob" in the join-room form
And I enter room code "ABC123"
And I click "加入房間"
Then I should be in the waiting room with other players
Scenario: Join room with URL room parameter pre-filled
Given I navigate to the lobby with URL parameter "?room=K7NP3Q"
Then the room code input should be pre-filled with "K7NP3Q"
Scenario: Join button auto-enabled when both nickname and room code are filled
Given the room code input is pre-filled via URL parameter "K7NP3Q"
And the nickname input is pre-filled from localStorage "ladder_last_nickname" as "Carol"
Then the "加入房間" button should be enabled without further interaction
# --- 表單驗證 ---
Scenario: Empty nickname validation on create-room
When I click "建立房間" without entering a nickname
Then I should see an inline error message for the nickname field
And the "建立房間" button should be disabled
Scenario: Empty nickname validation on join-room
When I enter room code "ABC123"
And I click "加入房間" without entering a nickname
Then I should see an inline error message for the nickname field
And the "加入房間" button should be disabled
Scenario: Nickname exceeding 20 characters shows validation error
When I enter a nickname of 21 characters in the create-room form
Then I should see an inline error message "暱稱須為 1–20 個字元"
And the "建立房間" button should be disabled
Scenario: Room code field forces uppercase input
When I type "abc123" into the room code input
Then the room code input should display "ABC123"
Scenario: Duplicate nickname error from server
Given the server returns error "NICKNAME_TAKEN"
When I enter nickname "Alice" in the join-room form
And I enter room code "ABC123"
And I click "加入房間"
Then I should see an error toast "此暱稱已被使用,請換一個"
Scenario: Room not found error from server
Given the server returns error "ROOM_NOT_FOUND"
When I enter nickname "Bob" in the join-room form
And I enter room code "XXXXXX"
And I click "加入房間"
Then I should see an error toast "找不到此房間,請確認房間碼"
Scenario: Room full error from server
Given the server returns error "ROOM_FULL"
When I enter nickname "Bob" in the join-room form
And I enter room code "ABC123"
And I click "加入房間"
Then I should see an error toast "房間已滿,無法加入"
Scenario: Room not joinable when game already started
Given the server returns error "ROOM_NOT_JOINABLE"
When I enter nickname "Bob" in the join-room form
And I enter room code "ABC123"
And I click "加入房間"
Then I should see an error toast "此房間遊戲已開始,無法加入"
# --- 已有暱稱自動填入 ---
Scenario: Nickname auto-filled from localStorage
Given localStorage key "ladder_last_nickname" is set to "Carol"
When I visit the lobby page
Then the nickname input in the join-room form should be pre-filled with "Carol"
Scenario: Nickname is saved to localStorage after successful join
When I enter nickname "Dave" in the join-room form
And I enter room code "ABC123"
And I click "加入房間"
And the join is successful
Then localStorage key "ladder_last_nickname" should be set to "Dave"
client/play_again_ui.feature
# features/client/play_again_ui.feature
#
# BDD Feature: 再玩一場 — Client 端 UI 流程
#
# 對應規格:
# PDD §5.3 Game View — finished 狀態主持人控制面板(「再玩一局」按鈕 .btn-ghost)
# PDD §5.4 Result View — finished 全螢幕結果頁主持人限定「再玩一局」按鈕
# API §2.10 POST /api/rooms/:code/game/play-again
# API §3.6 WS PLAY_AGAIN / ROOM_STATE(waiting) 廣播
#
# 摘要:
# 遊戲結束(status=finished)後,房主看到「再玩一場」按鈕(非房主不顯示/disabled)。
# 房主點擊後 → 呼叫 POST /api/rooms/:code/game/play-again → 成功則 UI 切換至 waiting room。
# 所有玩家收到 WS PLAY_AGAIN 或 ROOM_STATE(waiting) 後同步切換至 waiting room UI。
# 含載入狀態防重複送出、以及 API 錯誤時 Toast 回饋。
Feature: Play Again UI Flow
As a host or player
I want the UI to correctly render and handle the "Play Again" action
So that the host can start a new round and all players transition back to the waiting room
Background:
# 前置條件:已有進行完畢的房間,所有玩家已透過 WS 連線
Given room "ROOM01" exists with status "finished"
And player "Alice" is the host of room "ROOM01"
And players "Bob" and "Carol" are non-host members of room "ROOM01"
And all three players have active WebSocket connections
# ---------------------------------------------------------------------------
# 1. Finished 頁面按鈕可見性
# 主持人看到「再玩一場」按鈕;非主持人不顯示(或為 disabled)
# 來源:PDD §5.3 finished 狀態控制面板、PDD §5.4 Result View
# ---------------------------------------------------------------------------
# 房主在 finished 頁面應看到「再玩一場」按鈕
@play-again-ui @visibility @host @P0
Scenario: Host sees "Play Again" button in the finished result view
# finished 狀態結果頁 Sidebar 底部,主持人限定顯示「再玩一局」.btn-ghost 按鈕
Given the game has finished and all players are viewing the result page
When Alice (the host) views the finished result page
Then Alice sees a "Play Again" button with class "btn-ghost"
And the "Play Again" button is enabled and clickable
# 非房主在 finished 頁面不應看到「再玩一場」按鈕
@play-again-ui @visibility @non-host @P0
Scenario: Non-host player does not see "Play Again" button in the finished result view
# 普通玩家的結果頁 Sidebar 底部不渲染「再玩一局」按鈕
Given the game has finished and all players are viewing the result page
When Bob (a non-host player) views the finished result page
Then Bob does not see a "Play Again" button
And no "Play Again" element is present in Bob's DOM
# ---------------------------------------------------------------------------
# 2. 房主點擊「再玩一場」→ 呼叫 POST /api/rooms/:code/game/play-again
# 來源:API §2.10
# ---------------------------------------------------------------------------
# 房主點擊按鈕後前端呼叫正確的 HTTP endpoint
@play-again-ui @http-call @P0
Scenario: Host clicks "Play Again" and the client sends POST play-again request
# 點擊後前端送出 POST /api/rooms/ROOM01/game/play-again,帶上 Authorization Bearer token
Given Alice is viewing the finished result page
And the "Play Again" button is visible and enabled
When Alice clicks the "Play Again" button
Then the client sends a POST request to "/api/rooms/ROOM01/game/play-again"
And the request includes "Authorization: Bearer <hostToken>" header
And the request body is empty or "{}"
# ---------------------------------------------------------------------------
# 3. 成功回應 → 切換至 waiting room UI
# 來源:API §2.10 200 OK、PDD §5.2 Waiting Room
# ---------------------------------------------------------------------------
# 伺服器回傳 200 OK,房主 UI 立即切換至等待室
@play-again-ui @success @navigation @P0
Scenario: Host UI transitions to waiting room after successful play-again response
# HTTP 200 → 前端應立即切換至等待室視圖,顯示等待大廳元素
Given Alice has clicked the "Play Again" button
When the server responds with HTTP 200 and status "waiting"
Then Alice's UI transitions to the waiting room view
And Alice sees the room code "ROOM01" displayed
And Alice sees the player list in waiting state
And Alice sees the "Start Game" button (host control)
And the "Play Again" button is no longer visible
# ---------------------------------------------------------------------------
# 4. 收到 WS PLAY_AGAIN 或 ROOM_STATE(waiting) → 所有玩家 UI 切換至 waiting room
# 來源:API §3.6 WS PLAY_AGAIN、ROOM_STATE broadcast
# ---------------------------------------------------------------------------
# 非房主玩家收到 WS ROOM_STATE(waiting) 廣播後切換至等待室
@play-again-ui @websocket @broadcast @P0
Scenario: All players transition to waiting room upon receiving ROOM_STATE(waiting) broadcast
# 伺服器廣播 ROOM_STATE(status="waiting") 後,所有連線玩家 UI 同步切換
Given the host has triggered play-again and the server broadcasts ROOM_STATE with status "waiting"
When Bob receives the ROOM_STATE WebSocket message with status "waiting"
Then Bob's UI transitions from the finished result page to the waiting room view
And Bob sees the waiting room with the player list
And Bob sees "Waiting for host to start…" message
# 收到 WS PLAY_AGAIN 事件(若前端實作 WS 路徑)也應觸發切換
@play-again-ui @websocket @play-again-event @P1
Scenario: All players transition to waiting room upon receiving PLAY_AGAIN WebSocket event
# 前端若監聽 PLAY_AGAIN WS 事件,收到後同樣切換至等待室
Given all players are viewing the finished result page
When the server broadcasts a PLAY_AGAIN WebSocket event to all connected clients
Then Alice's UI transitions to the waiting room view
And Bob's UI transitions to the waiting room view
And Carol's UI transitions to the waiting room view
# 多人同步:所有在線玩家在同一廣播後同時切換
@play-again-ui @websocket @sync @P1
Scenario: All online players synchronously switch to waiting room view
# 確保所有在線玩家(含主持人)在收到 ROOM_STATE(waiting) 後同步切換
Given the host triggers play-again and the broadcast is sent
When all connected clients receive the ROOM_STATE broadcast with status "waiting"
Then Alice's view shows the waiting room
And Bob's view shows the waiting room
And Carol's view shows the waiting room
And no player remains on the finished result page
# ---------------------------------------------------------------------------
# 5. 載入狀態:點擊後按鈕 disabled 防止重複送出
# 來源:PDD §5.4 behaviour、PDD §8.4 Loading States
# ---------------------------------------------------------------------------
# 點擊後按鈕立即 disabled,防止重複請求
@play-again-ui @loading @P0
Scenario: "Play Again" button becomes disabled immediately after click to prevent duplicate submission
# 點擊後立即設為 disabled(opacity: 0.4; cursor: not-allowed),不允許第二次點擊
Given Alice is viewing the finished result page
And the "Play Again" button is enabled
When Alice clicks the "Play Again" button
Then the "Play Again" button becomes disabled immediately
And the button has attribute "disabled" or CSS class that prevents pointer events
And no duplicate POST requests are sent if Alice clicks again while the request is pending
# 請求進行中按鈕顯示載入中文字或樣式
@play-again-ui @loading @in-progress @P1
Scenario: "Play Again" button shows loading state while the request is in flight
# 可選:按鈕文字改為「載入中…」或加上 spinner,提示使用者請求進行中
Given Alice has clicked the "Play Again" button
And the POST request to play-again is in progress
Then the "Play Again" button is in a loading/pending state
# (按鈕應顯示禁用樣式;文字可為「再玩一場」或「載入中…」依實作決定)
# 請求完成(成功或失敗)後按鈕恢復可互動
@play-again-ui @loading @recovery @P1
Scenario: "Play Again" button returns to enabled state if the request fails
# 請求失敗後按鈕應恢復啟用,讓房主可以重試
Given Alice has clicked the "Play Again" button
And the "Play Again" button is in disabled/loading state
When the server responds with HTTP 403 "PLAYER_NOT_HOST"
Then the "Play Again" button returns to enabled state
And Alice can click the button again
# ---------------------------------------------------------------------------
# 6. 錯誤處理:API 回傳 400/403 → 顯示 Toast 錯誤訊息
# 來源:API §2.10 Error Responses、PDD §4.5 Toast、PDD §8.3 錯誤 UI 層次
# ---------------------------------------------------------------------------
# HTTP 403 PLAYER_NOT_HOST → 顯示 Toast 錯誤,UI 不切換
@play-again-ui @error @P0
Scenario: API returns 403 PLAYER_NOT_HOST and the client shows an error toast
# 若操作者 token 不是房主(理論上不應發生,但仍需防禦),顯示 error toast
Given Alice's token unexpectedly returns PLAYER_NOT_HOST
When Alice clicks the "Play Again" button and the server returns HTTP 403
Then an error Toast is displayed with message containing "操作失敗" or "不是房主"
And the Toast has class "toast-error"
And the UI remains on the finished result page
And the "Play Again" button returns to enabled state
# HTTP 400 INSUFFICIENT_ONLINE_PLAYERS → 顯示 Toast 錯誤,UI 不切換
@play-again-ui @error @P0
Scenario: API returns 400 INSUFFICIENT_ONLINE_PLAYERS and the client shows an error toast
# 在線玩家 < 2 時點擊「再玩一場」,server 回 400,前端顯示 toast 提示
Given only Alice is online in room "ROOM01" at the moment
When Alice clicks the "Play Again" button and the server returns HTTP 400 "INSUFFICIENT_ONLINE_PLAYERS"
Then an error Toast is displayed with message containing "在線玩家不足" or "至少需要 2 位"
And the Toast has class "toast-error"
And the UI remains on the finished result page
And the "Play Again" button returns to enabled state
# HTTP 409 INVALID_STATE → 顯示 Toast 錯誤,UI 不切換(防禦性)
@play-again-ui @error @P1
Scenario: API returns 409 INVALID_STATE and the client shows an error toast
# 若房間狀態已因競態條件改變,server 回 409,前端顯示 toast 提示
Given room "ROOM01" status has changed unexpectedly before the request arrives
When Alice clicks the "Play Again" button and the server returns HTTP 409 "INVALID_STATE"
Then an error Toast is displayed with message containing "狀態錯誤" or "請重新整理"
And the Toast has class "toast-error"
And the UI remains on the current page
And the "Play Again" button returns to enabled state
# Toast 自動消失(3 秒後)且不堆疊
@play-again-ui @error @toast-behavior @P1
Scenario: Error toast from play-again failure auto-dismisses after 3 seconds
# play-again 錯誤 toast 遵守全域 Toast 行為規範(PDD §4.5)
Given an error toast was shown due to a play-again API failure
When 3 seconds have elapsed
Then the error Toast automatically fades out and is removed from the DOM
# ---------------------------------------------------------------------------
# 7. 手機版(< 768px)佈局:「再玩一場」按鈕位於底部固定控制欄
# 來源:PDD §5.4 手機版 Result View layout
# ---------------------------------------------------------------------------
@play-again-ui @responsive @mobile @P1
Scenario: On mobile viewport "Play Again" button appears in the bottom control bar
# 手機版(< 768px)finished 頁面:Canvas 上方、得獎名單下方、底部顯示「再玩一局」
Given the viewport width is 375px (mobile)
And the game has finished
When Alice (the host) views the finished result page on mobile
Then the "Play Again" button is rendered in the bottom control bar area
And the button is fully visible without requiring horizontal scroll
# ---------------------------------------------------------------------------
# 8. 整合流程:finished → play-again → waiting → 下一局可開始
# 完整 UI 狀態機轉換驗證
# ---------------------------------------------------------------------------
@play-again-ui @integration @full-flow @P1
Scenario: Full UI flow from finished result page through play-again back to waiting room
# 完整流程:結果頁 → 點擊「再玩一場」→ 等待室 → 主持人可設定並開始新局
Given the game has finished with 3 online players
And all players are on the finished result page
# Step 1: 按鈕點擊 → 載入狀態
When Alice clicks the "Play Again" button
Then the "Play Again" button becomes disabled
And the client sends POST to "/api/rooms/ROOM01/game/play-again"
# Step 2: 成功回應 → UI 切換
When the server responds with HTTP 200 and ROOM_STATE(status="waiting")
Then Alice's UI transitions to the waiting room view
And Bob's UI transitions to the waiting room view
And Carol's UI transitions to the waiting room view
# Step 3: 等待室功能可正常使用
And Alice sees the room code "ROOM01"
And Alice sees "Bob" and "Carol" in the player list
And Alice can set winnerCount and click "Start Game" to begin a new round
client/result-view.feature
Feature: Result View UI
As a player or host, I want to see the final results clearly so I can confirm the winners and decide next steps
Background:
Given the game state is "finished"
And I am viewing the result view
# --- 全部路徑揭曉後顯示完整結果 ---
Scenario: Show all paths after reveal-all
Given all paths were revealed via REVEAL_ALL
When I view the canvas on the result page
Then all player paths should be visible on the canvas
And each path should render with globalAlpha of 0.6
And winner paths should have gold glow (shadowColor: #ffd700, shadowBlur: 10)
Scenario: Full-screen result layout shows canvas and winner list
When I view the result page
Then I should see the canvas on the left (or top on mobile)
And I should see the winner list sidebar on the right
And the header should show the room name with "FINISHED" status badge
Scenario: Winner list shows ranked results with crowns
Given the game finished with winners "Alice" and "Bob", non-winner "Carol"
When I view the winner list
Then I should see "Alice" ranked 1st with ♛ crown in gold
And I should see "Bob" ranked 2nd with ♛ crown in gold
And I should see "Carol" ranked 3rd without a crown in dim text
Scenario: Winner entries have gold background styling
Given "Alice" is a winner
When I view "Alice"'s result entry
Then the entry background should be #1c1600
And the entry border should be #b8960a
Scenario: Non-winner entries have neutral styling
Given "Carol" is not a winner
When I view "Carol"'s result entry
Then the entry background should be var(--bg)
And the text should use var(--text-dim)
Scenario: My own result entry is highlighted
Given I am player "Bob" and I am a winner
When I view the result list
Then my entry should have border-left: 3px solid #6c63ff
And I should see "(你)" label next to my name in color #6c63ff
Scenario: Result list is scrollable for large player count
Given 20 players participated in the game
When I view the result list
Then the result list should be scrollable
And all players should be listed with correct rank order
Scenario: Gold glow continuously displayed on winner paths in canvas
Given the game is finished
When I view the canvas
Then winner paths should continuously show shadowColor #ffd700 and shadowBlur 10
And winner endpoints should show gold glow circles
Scenario: Winner star markers displayed at bottom of winner paths
Given "Alice" is a winner
When I view the canvas
Then a gold ★ should be displayed at the bottom of "Alice"'s rail
And the star should be rendered at 11px system-ui font
Scenario: Mobile result view stacks canvas above result list
Given my viewport is narrower than 768px
When I view the result page
Then the canvas should appear at the top with max-height of 50vh
And the result list should appear below the canvas with max-height of 200px and horizontal scroll
# --- 再玩一局 ---
Scenario: Host sees replay button in finished state
Given I am the host
When I view the result page
Then I should see the "再玩一局" button with ghost styling
Scenario: Replay game resets to waiting room
Given I am the host
When I click "再玩一局"
Then the room should reset to "waiting" state
And I should be redirected to the waiting room
And offline players should be removed from the player list
Scenario: Replay game clears kicked player list
Given player "Dave" was kicked in the previous round
When I click "再玩一局" as the host
Then "Dave" should no longer be in the kicked players list
And "Dave" can rejoin the room with a fresh nickname
Scenario: Replay with insufficient online players shows error
Given only 1 player is currently online
When I click "再玩一局" as the host
Then I should see an error toast "在線玩家不足(至少需要 2 位),無法開始新局"
And the room state should remain "finished"
Scenario: Non-host does not see replay button
Given I am not the host
When I view the result page
Then I should not see the "再玩一局" button
Scenario: Winners count resets if it exceeds new player count after replay
Given winners count was 3 and only 2 players are online after replay
When the host initiates replay
Then the winners count should be reset to null
And I should see a toast "中獎名額已重設,請重新設定"
# --- 回大廳 ---
Scenario: Return to lobby navigates to home page
When I click "回首頁" or navigate to the lobby
Then I should be on the lobby page
And the lobby page should show the create-room and join-room forms
Scenario: Kicked player sees return-to-lobby button
Given I have been kicked by the host
When I receive the kick notification
Then I should see the message "你已被主持人移出房間"
And I should see a "回首頁" button
And clicking "回首頁" should navigate me to the lobby
# --- 結果項目動畫 ---
Scenario: Result items animate in with fadeSlideIn
When the result list renders for the first time
Then each result item should animate with opacity 0 to 1 and translateY 8px to 0
And animation duration should be 300ms ease
And each item should be delayed by an additional 50ms per rank position
client/waiting-room.feature
Feature: Waiting Room UI
As a player or host, I want to see the waiting room so I can track who has joined and start the game
Background:
Given I am in the waiting room of room "K7NP3Q"
# --- 顯示房間碼供分享 ---
Scenario: Display room code for sharing
Then I should see the room code "K7NP3Q" displayed prominently with accent color
And the room code should have letter-spacing of 0.25em
Scenario: Copy invite link via clipboard API
When I click the room code box or "複製邀請連結" button
Then the invite URL "{origin}/?room=K7NP3Q" should be copied to clipboard
And I should see a toast "已複製!"
And the button should revert to its original text after 1.5 seconds
Scenario: Fallback input shown when clipboard API is unavailable
Given the Clipboard API is not available
When I click "複製邀請連結"
Then I should see a text input pre-filled with "{origin}/?room=K7NP3Q"
And the input text should be fully selected for manual copy
# --- 顯示已加入的所有玩家 ---
Scenario: Show all joined players
Given players "Alice", "Bob", and "Carol" have joined the room
Then I should see 3 player entries in the player list
And each player should show their colored player dot
And "Alice" the host should have a host badge
Scenario: Offline player shows offline label
Given player "Carol" is disconnected
Then I should see "Carol" in the player list with an "離線" label
And the player dot for "Carol" should have opacity 0.35
Scenario: Host sees kick button on hover for other players
Given I am the host
And player "Bob" is in the player list
When I hover over "Bob"'s player entry
Then I should see a red kick button for "Bob"
Scenario: Host cannot see kick button for themselves
Given I am the host
When I view my own player entry
Then I should not see a kick button next to my name
Scenario: Non-host does not see kick buttons
Given I am not the host
When I view the player list
Then I should not see any kick buttons
# --- 主持人開始遊戲(2+ 玩家就緒)---
Scenario: Host can start game when 2 or more players are present
Given I am the host
And 2 players are in the room
And the winners count is set to 1
When I click "開始遊戲"
Then the game should start and transition to the game view
Scenario: Start game button disabled when fewer than 2 players
Given I am the host
And only 1 player is in the room
Then the "開始遊戲" button should be disabled
Scenario: Start game button disabled when winners count is not set
Given I am the host
And 3 players are in the room
And the winners count input is empty
Then the "開始遊戲" button should be disabled
Scenario: Host sets winners count
Given I am the host
And 3 players are in the room
When I enter 1 in the winners count input
Then the "開始遊戲" button should become enabled
Scenario: Winners count validation rejects out-of-range values
Given I am the host
And 3 players are in the room
When I enter 3 in the winners count input
And I click "開始遊戲"
Then I should see an error "中獎名額須介於 1 到玩家數減 1 之間"
# --- 非主持人無法開始遊戲 ---
Scenario: Non-host cannot start game - button not visible
Given I am not the host
Then I should not see the "開始遊戲" button
And I should see the message "等待主持人開始…"
Scenario: Non-host does not see game settings panel
Given I am not the host
Then I should not see the winners count input
And I should not see the game settings card
# --- 玩家人數即時更新 ---
Scenario: Player count updates in real-time when new player joins
Given I am in the waiting room with 2 players
When a new player "Dave" joins the room
Then the player list should update within 2 seconds to show 3 players
And "Dave" should appear in the player list
Scenario: Player count updates in real-time when player disconnects
Given I am in the waiting room with 3 players
When player "Carol" disconnects
Then "Carol" should show the "離線" label within 2 seconds
And the player count should reflect the updated online status
Scenario: Connection status dot reflects WebSocket state
Given my WebSocket connection is active
Then the connection dot should be green
When my WebSocket connection drops
Then the connection dot should be red
When the WebSocket is reconnecting
Then the connection dot should be gold with a pulse animation
client-canvas.feature
# features/client-canvas.feature
Feature: 梯子 Canvas 渲染行為
As a 玩家
I want to 在各個遊戲階段看到符合狀態的 Canvas 畫面
So that 我能清楚掌握遊戲進行狀況並正確辨識自己的結果
Background:
Given 我已加入房間 "EPS6"
And Canvas 元素已掛載於頁面上
# ---------------------------------------------------------------------------
# waiting 狀態 — 佔位顯示
# ---------------------------------------------------------------------------
@canvas @waiting @P1
Scenario: 房間狀態為 waiting 時 Canvas 顯示佔位畫面
Given 房間狀態為 "waiting"
And 尚未生成梯子資料
When Canvas 完成初次渲染
Then Canvas 顯示灰色佔位軌道,對應每位已加入的玩家
And Canvas 上方顯示每位玩家的暱稱標籤
And Canvas 不顯示任何橫槓(rung)或路徑
And Canvas 底部不顯示結果標籤
@canvas @waiting @P2
Scenario: waiting 狀態下新玩家加入時 Canvas 即時新增軌道
Given 房間狀態為 "waiting",已有 2 名玩家
When 第 3 名玩家加入房間
Then Canvas 在不重新載入頁面的情況下新增第 3 條軌道
And 新軌道對應第 3 名玩家的暱稱與顏色
# ---------------------------------------------------------------------------
# running 狀態 — 顯示完整梯子框架
# ---------------------------------------------------------------------------
@canvas @running @P0
Scenario: 房間狀態切換為 running 時 Canvas 渲染完整梯子結構
Given 房間狀態從 "waiting" 切換為 "running"
And 伺服器廣播包含 ladderData 的 ROOM_STATE
When Canvas 接收到新的梯子資料
Then Canvas 顯示所有垂直軌道,數量等於玩家人數
And Canvas 顯示所有水平橫槓(rung),位置與伺服器資料一致
And 每條軌道以對應玩家的顏色繪製
And 尚未揭曉的路徑不顯示彩色高亮
@canvas @running @P1
Scenario: running 狀態下 Canvas 隨視窗大小調整仍維持正確比例
Given 房間狀態為 "running",梯子已完整渲染
When 使用者縮放瀏覽器視窗寬度
Then Canvas 重新計算列間距與行間距
And 梯子結構保持正確比例,不出現截斷或溢出
# ---------------------------------------------------------------------------
# revealing 狀態 — 揭曉動畫
# ---------------------------------------------------------------------------
@canvas @revealing @P0
Scenario: 觸發揭曉時 Canvas 播放路徑動畫
Given 房間狀態為 "running",梯子已渲染
When 主持人觸發揭曉,Canvas 接收到 REVEAL_INDEX 事件
Then Canvas 沿對應玩家的行進路徑播放移動動畫
And 動畫期間路徑以彩色高亮繪製
And 動畫完成後路徑保持彩色高亮靜止顯示
And 動畫時間約為 1.5 秒
@canvas @revealing @P0
Scenario: 我的路徑揭曉時顯示金色高亮以示區分
Given 房間狀態為 "running"
When 我的路徑(自身玩家)被揭曉
Then Canvas 以金色(#ffd700)繪製我的路徑
And 其他玩家的路徑以各自的指定顏色繪製
@canvas @revealing @P1
Scenario: 揭曉動畫進行中不允許重疊觸發第二條路徑
Given 第一條路徑正在播放揭曉動畫
When 第二個 REVEAL_INDEX 事件到達
Then 第一條動畫繼續播放至完成
And 第二條路徑於第一條動畫結束後開始播放
@canvas @revealing @P1
Scenario: 重連時房間已揭曉部分路徑,Canvas 直接顯示靜態結果不重播動畫
Given 房間狀態為 "revealing",已揭曉 3 條路徑
When 我重新連線並接收 ROOM_STATE_FULL
Then Canvas 直接以靜態方式繪製已揭曉的 3 條路徑
And Canvas 不播放任何動畫
# ---------------------------------------------------------------------------
# finished 狀態 — 結果顯示
# ---------------------------------------------------------------------------
@canvas @finished @P0
Scenario: 所有路徑揭曉完成後 Canvas 顯示最終結果標籤
Given 所有玩家的路徑均已揭曉
When Canvas 完成最後一條路徑的渲染
Then Canvas 底部每條軌道終點顯示「中獎」或「未中獎」標籤
And 中獎標籤以金色顯示
And 未中獎標籤以灰色顯示
@canvas @finished @P1
Scenario: finished 狀態下我的結果欄位以明顯樣式標示
Given 所有路徑均已揭曉,房間狀態為 "finished"
When 畫面渲染完成
Then 我的路徑終點標籤比其他玩家更加突出(粗框或額外標記)
client-error.feature
# features/client-error.feature
Feature: 錯誤狀態的 UI 回饋行為
As a 玩家或主持人
I want to 在操作失敗時立刻看到清楚的錯誤提示
So that 我能理解發生了什麼問題並採取正確的後續行動
# ---------------------------------------------------------------------------
# 加入房間 — 輸入驗證錯誤
# ---------------------------------------------------------------------------
@error @join @P0
Scenario: 暱稱為空時送出加入請求,顯示 inline 錯誤提示
Given 我在首頁的加入房間表單
When 我未填寫暱稱直接點擊「加入房間」
Then 暱稱欄位旁顯示「請輸入暱稱」錯誤提示
And 不送出任何 HTTP 請求
And 焦點移至暱稱輸入欄位
@error @join @P0
Scenario: 房間碼為空時送出加入請求,顯示 inline 錯誤提示
Given 我在首頁的加入房間表單,已輸入暱稱 "Alice"
When 我未填寫房間碼直接點擊「加入房間」
Then 房間碼欄位旁顯示「請輸入房間碼」錯誤提示
And 不送出任何 HTTP 請求
@error @join @P0
Scenario: 房間不存在時顯示 Toast 錯誤訊息
Given 我在首頁輸入暱稱 "Bob" 與房間碼 "XXXX"
When 我點擊「加入房間」,伺服器回傳錯誤碼 "ROOM_NOT_FOUND"
Then 頁面顯示 error Toast「找不到此房間」
And 頁面維持在首頁視圖
@error @join @P0
Scenario: 暱稱已被使用時顯示 Toast 錯誤訊息
Given 房間 "EPS6" 中已有暱稱為 "Carol" 的玩家
When 我以暱稱 "Carol" 嘗試加入房間 "EPS6"
And 伺服器回傳錯誤碼 "NICKNAME_TAKEN"
Then 頁面顯示 error Toast「此暱稱已被使用,請換一個」
And 頁面維持在首頁視圖
@error @join @P1
Scenario: 房間已滿時顯示 Toast 錯誤訊息
Given 房間 "EPS6" 已達玩家上限
When 我嘗試加入房間 "EPS6"
And 伺服器回傳錯誤碼 "ROOM_FULL"
Then 頁面顯示 error Toast「房間已滿,無法加入」
And 頁面維持在首頁視圖
@error @join @P1
Scenario: 房間狀態非 waiting 時玩家無法加入並看到提示
Given 房間 "EPS6" 狀態為 "running"
When 我嘗試加入房間 "EPS6"
And 伺服器回傳錯誤碼 "ROOM_NOT_JOINABLE"
Then 頁面顯示 error Toast「遊戲已開始,無法加入」
And 頁面維持在首頁視圖
# ---------------------------------------------------------------------------
# 建立房間 — 輸入驗證錯誤
# ---------------------------------------------------------------------------
@error @create @P0
Scenario: 暱稱為空時建立房間,顯示 inline 錯誤提示
Given 我在首頁的建立房間表單
When 我未填寫暱稱直接點擊「建立房間」
Then 暱稱欄位旁顯示「請輸入暱稱」錯誤提示
And 不送出任何 HTTP 請求
# ---------------------------------------------------------------------------
# 遊戲操作錯誤(主持人)
# ---------------------------------------------------------------------------
@error @host @P0
Scenario: 人數不足時主持人開始遊戲,顯示 Toast 錯誤
Given 等待大廳只有主持人一人
When 主持人點擊「開始遊戲」
And 伺服器回傳錯誤碼 "INSUFFICIENT_PLAYERS"
Then 頁面顯示 error Toast「人數不足(至少需要 2 位玩家)」
And 房間視圖維持在等待大廳
@error @host @P0
Scenario: 中獎名額未設定時主持人開始遊戲,顯示 Toast 錯誤
Given 等待大廳有 3 名玩家但中獎名額尚未設定
When 主持人點擊「開始遊戲」
And 伺服器回傳錯誤碼 "PRIZES_NOT_SET"
Then 頁面顯示 error Toast「請先設定中獎名額」
And 房間視圖維持在等待大廳
@error @host @P1
Scenario: 主持人設定非法中獎名額,顯示 Toast 錯誤
Given 等待大廳有 5 名玩家
When 主持人設定中獎名額為 0
And 伺服器回傳錯誤碼 "INVALID_PRIZES_COUNT"
Then 頁面顯示 error Toast「中獎名額須介於 1 到玩家數減 1 之間」
And 中獎名額欄位恢復可編輯狀態
@error @host @P1
Scenario: 非主持人嘗試操作主持人功能,顯示 Toast 錯誤
Given 我是普通玩家
When 我嘗試透過 WebSocket 發送主持人專屬指令
And 伺服器回傳錯誤碼 "FORBIDDEN"
Then 頁面顯示 error Toast,說明操作不被允許
# ---------------------------------------------------------------------------
# 被踢出房間
# ---------------------------------------------------------------------------
@error @kicked @P0
Scenario: 被主持人踢出房間時顯示提示並返回首頁
Given 我在房間 "EPS6" 的等待大廳
When 主持人將我踢出,伺服器發送 PLAYER_KICKED 事件
Then 頁面顯示 error Toast「你已被踢出房間」
And 畫面切換至首頁(lobby 視圖)
And localStorage 中的 token 被清除
# ---------------------------------------------------------------------------
# WebSocket 傳輸層錯誤
# ---------------------------------------------------------------------------
@error @ws @P1
Scenario: WebSocket 未連線時嘗試送出訊息,顯示 Toast 錯誤
Given 我的 WebSocket 連線已中斷
When 我嘗試觸發任何需要 WebSocket 的操作
Then 頁面顯示 error Toast「WebSocket not connected」或類似提示
And 操作不被執行
@error @ws @P1
Scenario: 伺服器回傳通用 ERROR 事件時顯示錯誤訊息
Given 我在遊戲頁面
When 伺服器發送 ERROR 事件,其 payload 包含 message="Unexpected error occurred"
Then 頁面顯示 error Toast 顯示 "Unexpected error occurred"
# ---------------------------------------------------------------------------
# Toast 行為規格
# ---------------------------------------------------------------------------
@error @toast @P1
Scenario: Toast 錯誤訊息在 3.5 秒後自動消失
Given 頁面顯示一條 error Toast
When 3.5 秒後
Then Toast 自動淡出並從畫面移除
@error @toast @P2
Scenario: 新 Toast 出現時取代舊 Toast(不堆疊)
Given 頁面正在顯示第一條 Toast 訊息
When 第二條 Toast 觸發
Then 第一條 Toast 立即被第二條取代
And 畫面上同時只顯示一條 Toast
client-navigation.feature
# features/client-navigation.feature
Feature: 頁面流程與視圖切換
As a 玩家或主持人
I want to 在不同遊戲階段被引導到對應的頁面
So that 我能看到符合當前狀態的 UI,並依直覺完成每個操作步驟
# ---------------------------------------------------------------------------
# 首頁(Lobby)
# ---------------------------------------------------------------------------
@navigation @lobby @P0
Scenario: 首次進入應用程式時顯示首頁
Given 使用者開啟應用程式首頁
When 頁面載入完成
Then 顯示「建立房間」與「加入房間」兩個操作入口
And 不顯示梯子 Canvas、玩家列表或結果列表
@navigation @lobby @P0
Scenario: 主持人填寫暱稱後建立房間,進入等待大廳
Given 使用者在首頁輸入暱稱 "Alice"
When 使用者點擊「建立房間」
And 伺服器回傳房間碼 "EPS6" 與 hostToken
Then 畫面切換至等待大廳視圖
And 等待大廳顯示房間碼 "EPS6"
And 玩家列表中顯示 "Alice"(主持人標記)
@navigation @lobby @P0
Scenario: 玩家輸入房間碼與暱稱後加入房間,進入等待大廳
Given 使用者在首頁輸入暱稱 "Bob" 與房間碼 "EPS6"
When 使用者點擊「加入房間」
And 伺服器回傳 playerToken
Then 畫面切換至等待大廳視圖
And 等待大廳顯示房間碼 "EPS6"
And 玩家列表中顯示 "Bob"
# ---------------------------------------------------------------------------
# 等待大廳(Waiting Room)
# ---------------------------------------------------------------------------
@navigation @waiting @P0
Scenario: 等待大廳顯示目前已加入的玩家清單
Given 我已進入房間 "EPS6" 的等待大廳
When 等待大廳頁面渲染完成
Then 顯示所有已加入玩家的暱稱與上線狀態
And 顯示房間碼供玩家分享
@navigation @waiting @P1
Scenario: 等待大廳即時更新當其他玩家加入
Given 我在等待大廳,目前房間有 2 名玩家
When 第 3 名玩家加入房間,伺服器廣播 ROOM_STATE
Then 玩家列表即時更新,顯示第 3 名玩家
And 不需手動重新整理頁面
@navigation @waiting @P0
Scenario: 主持人點擊「開始遊戲」後所有人切換至遊戲頁面
Given 等待大廳有 3 名玩家且中獎名額已設定
When 主持人點擊「開始遊戲」
And 伺服器廣播 ROOM_STATE(status="running",含 ladderData)
Then 主持人畫面切換至遊戲頁面
And 所有玩家畫面同步切換至遊戲頁面
And 遊戲頁面顯示完整梯子 Canvas
# ---------------------------------------------------------------------------
# 遊戲頁面(Game View)
# ---------------------------------------------------------------------------
@navigation @game @P0
Scenario: 遊戲頁面顯示房間碼、狀態標籤、玩家列表與 Canvas
Given 房間狀態為 "running"
When 遊戲頁面渲染完成
Then 頁面標題區域顯示房間碼
And 顯示目前狀態標籤(例如「遊戲進行中」)
And 右側或下方顯示玩家列表
And 中央顯示梯子 Canvas
@navigation @game @P1
Scenario: 主持人在遊戲頁面看到揭曉控制按鈕,玩家不顯示
Given 房間狀態為 "running"
When 主持人的遊戲頁面渲染完成
Then 主持人看到「揭曉下一個」等操作按鈕
When 普通玩家的遊戲頁面渲染完成
Then 普通玩家不看到任何揭曉控制按鈕
# ---------------------------------------------------------------------------
# 結果頁面 / 揭曉完成
# ---------------------------------------------------------------------------
@navigation @results @P0
Scenario: 所有路徑揭曉完成後頁面顯示最終結果
Given 房間狀態為 "revealing",所有路徑逐一揭曉中
When 最後一條路徑揭曉完成
Then Canvas 顯示所有路徑的最終靜態結果
And 結果列表顯示每位玩家的中獎或未中獎狀態
And 我的結果以明顯方式標示
@navigation @results @P1
Scenario: 主持人可在結果頁面選擇返回首頁或結束房間
Given 所有路徑已揭曉完成
When 主持人查看結果頁面
Then 顯示「返回首頁」或「結束房間」操作按鈕
# ---------------------------------------------------------------------------
# 頁面刷新與重新進入
# ---------------------------------------------------------------------------
@navigation @refresh @P1
Scenario: 玩家刷新頁面後根據 localStorage 中的 token 恢復至正確視圖
Given 我已加入房間 "EPS6" 且 localStorage 存有 myToken 與 myPlayerId
When 我刷新瀏覽器頁面
Then 前端以 localStorage 中的 token 重新建立 WebSocket 連線
And 根據伺服器回傳的房間狀態自動切換至對應視圖(waiting 或 game)
@navigation @refresh @P1
Scenario: localStorage 無 token 時刷新頁面顯示首頁
Given 瀏覽器 localStorage 中無任何 token 資料
When 使用者開啟或刷新頁面
Then 直接顯示首頁視圖
And 不顯示任何載入中或錯誤訊息
client-reconnect.feature
# features/client-reconnect.feature
Feature: 前端斷線重連 UI 行為
As a 玩家
I want to 在網路中斷時看到明確提示,並在重連後恢復遊戲畫面
So that 我不會因短暫網路波動而感到困惑或失去遊戲進度
Background:
Given 我已加入房間 "EPS6" 並處於遊戲頁面
# ---------------------------------------------------------------------------
# 斷線時的 UI 提示
# ---------------------------------------------------------------------------
@reconnect @offline @P0
Scenario: WebSocket 斷線時顯示連線中斷 overlay
Given 我的 WebSocket 連線正常
When WebSocket 連線意外中斷
Then 頁面顯示連線中斷提示(例如灰色 overlay 或 Toast 訊息)
And 提示文字說明「連線中斷,嘗試重連中…」
And 連線狀態指示點變為灰色或紅色
@reconnect @offline @P1
Scenario: 斷線期間遊戲畫面保持顯示,不清空 Canvas
Given 我在房間 "EPS6" 的遊戲頁面,梯子已渲染
When WebSocket 連線中斷
Then Canvas 繼續顯示已渲染的梯子畫面
And 不切換至其他頁面或顯示空白畫面
@reconnect @offline @P1
Scenario: 斷線時其他玩家的上線狀態指示器更新為離線
Given 玩家 "Bob" 在玩家列表中顯示為上線
When 伺服器廣播 ROOM_STATE,其中 "Bob" 的 isOnline 為 false
Then 玩家列表中 "Bob" 的上線指示器變為灰色或顯示離線標記
# ---------------------------------------------------------------------------
# 自動重連進度提示
# ---------------------------------------------------------------------------
@reconnect @retry @P0
Scenario: 前端自動嘗試重連並顯示倒數提示
Given WebSocket 連線已中斷
When 前端開始自動重連流程(第 1 次嘗試)
Then 頁面提示「正在嘗試重新連線(第 1 / 3 次)…」或類似文字
And 使用者無需手動操作
@reconnect @retry @P1
Scenario: 達到最大重連次數後提示使用者手動重新整理
Given WebSocket 連線已中斷且自動重連已嘗試 3 次
When 第 3 次重連仍然失敗
Then 頁面提示「連線失敗,請重新整理頁面」
And 顯示「重新整理」按鈕或連結
# ---------------------------------------------------------------------------
# 重連成功後的 UI 恢復
# ---------------------------------------------------------------------------
@reconnect @restore @P0
Scenario: 重連成功後 overlay 消失,UI 恢復正常
Given 連線中斷 overlay 正在顯示
When WebSocket 重新連線成功
Then 連線中斷 overlay 自動消失
And 連線狀態指示點變回綠色
And 遊戲頁面恢復正常操作狀態
@reconnect @restore @P0
Scenario: 重連後接收 ROOM_STATE_FULL 並恢復至正確視圖
Given 我斷線時房間狀態為 "running"
When 重連成功,伺服器發送 ROOM_STATE_FULL(status="running")
Then 頁面維持在遊戲視圖
And Canvas 重新渲染包含最新狀態的梯子畫面
And 玩家列表更新為最新的上線狀態
@reconnect @restore @P1
Scenario: 重連時房間已進入 revealing 狀態,直接顯示靜態已揭曉結果
Given 我斷線時房間狀態為 "running"
When 重連成功,伺服器發送 ROOM_STATE_FULL(status="revealing",revealedCount=3)
Then Canvas 直接顯示 3 條已揭曉路徑的靜態畫面
And 不播放已完成路徑的動畫
And 結果列表顯示 3 筆已揭曉結果
@reconnect @restore @P1
Scenario: 重連後我的 isOnline 狀態更新,其他玩家可見
Given 我已成功重連至房間 "EPS6"
When 伺服器廣播 ROOM_STATE(我的 isOnline=true)
Then 其他玩家的畫面中,我的玩家標籤顯示為上線
# ---------------------------------------------------------------------------
# Session 被替換時的 UI 提示
# ---------------------------------------------------------------------------
@reconnect @session-replaced @P0
Scenario: 在同一 playerId 從其他裝置登入時,目前頁面顯示 SESSION_REPLACED 提示
Given 我在裝置 A 上正在進行遊戲
When 我在裝置 B 以相同帳號登入同一房間
Then 裝置 A 的頁面顯示 Toast 訊息「你的帳號已在其他地方登入」
And 裝置 A 的 WebSocket 連線被關閉
And 裝置 A 的畫面切換回首頁(lobby 視圖)
And 裝置 A 的 localStorage token 被清除或失效
@reconnect @session-replaced @P1
Scenario: SESSION_REPLACED 後使用者可重新輸入暱稱加入房間
Given 裝置 A 已因 SESSION_REPLACED 被踢回首頁
When 使用者在裝置 A 重新輸入暱稱並加入房間
Then 使用者可正常加入或重新連接房間
# ---------------------------------------------------------------------------
# 主持人斷線時的 UI 提示
# ---------------------------------------------------------------------------
@reconnect @host-offline @P1
Scenario: 主持人斷線時玩家頁面顯示主持人離線提示
Given 我是普通玩家,主持人目前上線
When 伺服器廣播 ROOM_STATE,主持人的 isOnline 為 false
Then 頁面顯示提示「主持人暫時離線,等待重連中」
And 揭曉操作按鈕(若存在)變為停用狀態
@reconnect @host-offline @P1
Scenario: 主持人重連後提示消失,操作恢復正常
Given 主持人離線提示正在顯示
When 伺服器廣播 ROOM_STATE,主持人的 isOnline 恢復為 true
Then 主持人離線提示自動消失
And 揭曉操作按鈕恢復啟用狀態
game-flow.feature
# features/game-flow.feature
Feature: 遊戲流程控制
As a 主持人
I want to 設定中獎名額並在玩家就緒後開始遊戲
So that 伺服器生成確定性樓梯結構並讓所有客戶端同步進入揭曉流程
Background:
Given 房間碼 "BETA23" 已存在且狀態為 "waiting"
And 主持人已透過 hostToken 驗證身份
# ---------------------------------------------------------------------------
# AC-H02-1 — 設定合法 W,伺服器接受並廣播 ROOM_STATE
# ---------------------------------------------------------------------------
@AC-GAME-WIN-001 @P0
Scenario: 主持人設定合法中獎名額
Given 房間有 5 名玩家(N=5,含主持人)
When 主持人設定中獎名額 W=2(1 ≤ W ≤ N-1=4)
Then 伺服器接受設定
And 所有在線玩家收到廣播的 ROOM_STATE
And ROOM_STATE payload 中 winnerCount 為 2
# ---------------------------------------------------------------------------
# AC-H02-2 — W 超出範圍 → INVALID_PRIZES_COUNT
# ---------------------------------------------------------------------------
@AC-GAME-WIN-002 @P0
Scenario Outline: 主持人設定非法中獎名額時收到 INVALID_PRIZES_COUNT 錯誤
Given 房間有 <N> 名玩家(含主持人)
When 主持人設定中獎名額 W=<W>
Then 伺服器回傳錯誤碼 "INVALID_PRIZES_COUNT"
And 錯誤訊息包含「中獎名額須介於 1 到玩家數減 1 之間」
And 房間 winnerCount 維持不變
Examples:
| N | W | 說明 |
| 5 | 0 | W=0,低於下限 |
| 5 | -1 | W<0,負數 |
| 5 | 5 | W=N,等於上限違規 |
| 5 | 6 | W>N,超過玩家數 |
# ---------------------------------------------------------------------------
# AC-H03-1 — 成功開始遊戲:狀態轉為 running,廣播樓梯資料
# ---------------------------------------------------------------------------
@AC-GAME-START-001 @P0
Scenario: 主持人滿足條件後成功開始遊戲
Given 房間有 3 名玩家(N=3)且 winnerCount=1 已設定
When 主持人點擊「開始遊戲」
Then HTTP 回應狀態碼為 200
And 回應包含 LadderData(含 seed、seedSource、rowCount、segments)
And 伺服器廣播 ROOM_STATE,status 為 "running"
And 所有客戶端收到完全一致的 ladderMap
# ---------------------------------------------------------------------------
# AC-H03-2 — W 未設定 → PRIZES_NOT_SET
# ---------------------------------------------------------------------------
@AC-GAME-START-002 @P0
Scenario: W 尚未設定時主持人開始遊戲被拒絕
Given 房間有 3 名玩家(N=3)且 winnerCount 尚未設定(為 null)
When 主持人點擊「開始遊戲」
Then 伺服器回傳錯誤碼 "PRIZES_NOT_SET"
And 錯誤訊息包含「請先設定中獎名額」
And 房間狀態維持 "waiting"
# ---------------------------------------------------------------------------
# AC-H03-4 — N < 2 → INSUFFICIENT_PLAYERS
# ---------------------------------------------------------------------------
@AC-GAME-START-003 @P0
Scenario: 房間只有主持人一人時開始遊戲被拒絕
Given 房間只有主持人一人(N=1)
When 主持人點擊「開始遊戲」
Then 伺服器回傳錯誤碼 "INSUFFICIENT_PLAYERS"
And 前端顯示「人數不足(至少需要 2 位玩家)」
And 房間狀態維持 "waiting"
# ---------------------------------------------------------------------------
# AC-H03-5 — rowCount 邊界值驗證(N=3→20, N=10→30, N=21→60)
# ---------------------------------------------------------------------------
@AC-GAME-START-004 @P0
Scenario Outline: 遊戲開始後 rowCount 符合 clamp(N*3, 20, 60) 公式
Given 房間有 <N> 名玩家且 winnerCount 已合法設定
When 主持人成功開始遊戲
Then 伺服器廣播的 ROOM_STATE 中 ladder.rowCount 為 <expected_rowCount>
Examples:
| N | expected_rowCount | 說明 |
| 2 | 20 | clamp(6, 20, 60)=20 |
| 3 | 20 | clamp(9, 20, 60)=20 |
| 10 | 30 | clamp(30, 20, 60)=30 |
| 20 | 60 | clamp(60, 20, 60)=60 |
| 21 | 60 | clamp(63, 20, 60)=60 |
| 50 | 60 | clamp(150, 20, 60)=60 |
game-lifecycle.feature
Feature: Game Lifecycle
As a host
I want to manage the full game lifecycle from start to finish
So that all players can participate in a fair and synchronized lottery
Background:
Given a room with code "GAME01" exists and status is "waiting"
And the host is authenticated with a valid hostToken
# ---------------------------------------------------------------------------
# AC-H03-1 — Start game with valid state transitions to running
# ---------------------------------------------------------------------------
Scenario: Start game with 2 or more players
# AC-H03-1
Given room has 3 players and winnerCount is set to 1
When host sends { type: "START_GAME" } via WebSocket
Then all players receive ROOM_STATE with status "running"
And the broadcast contains rowCount equal to clamp(N*3, 20, 60)
And seed and ladderData are NOT included in the broadcast
# ---------------------------------------------------------------------------
# AC-H03-1 — Ladder data is generated at BEGIN_REVEAL, not START_GAME
# ---------------------------------------------------------------------------
Scenario: Ladder data is generated when host triggers BEGIN_REVEAL
# AC-H03-1, FR-04-1
Given room status is "running" with 3 players and winnerCount 1
When host sends { type: "BEGIN_REVEAL" } via WebSocket
Then room status transitions to "revealing"
And all players receive ROOM_STATE_FULL with ladderData (without seed)
And all players see identical ladder structure and bar positions
# ---------------------------------------------------------------------------
# AC-H03-2 — Start game fails when winnerCount is not set
# ---------------------------------------------------------------------------
Scenario: Start game rejected when winnerCount is not set
# AC-H03-2
Given room has 3 players and winnerCount is null
When host sends { type: "START_GAME" } via WebSocket
Then host receives ERROR event with code "PRIZES_NOT_SET"
And room status remains "waiting"
# ---------------------------------------------------------------------------
# AC-H03-4 — Start game rejected when player count is insufficient
# ---------------------------------------------------------------------------
Scenario: Start game rejected when only host is in the room
# AC-H03-4
Given room has only the host (N=1) and winnerCount is set to 1
When host sends { type: "START_GAME" } via WebSocket
Then host receives ERROR event with code "INSUFFICIENT_PLAYERS"
And the message indicates at least 2 players are required
And room status remains "waiting"
# ---------------------------------------------------------------------------
# AC-H03-5 — rowCount formula clamp(N*3, 20, 60) boundary values
# ---------------------------------------------------------------------------
Scenario Outline: rowCount follows clamp(N*3, 20, 60) formula
# AC-H03-5
Given room has <N> players and winnerCount is validly set
When host starts the game successfully
Then broadcast rowCount equals <expected_rowCount>
Examples:
| N | expected_rowCount | note |
| 2 | 20 | clamp(6, 20, 60)=20 |
| 3 | 20 | clamp(9, 20, 60)=20 |
| 10 | 30 | clamp(30, 20, 60)=30 |
| 21 | 60 | clamp(63, 20, 60)=60 |
# ---------------------------------------------------------------------------
# AC-H04-1 — BEGIN_REVEAL transitions state to revealing
# ---------------------------------------------------------------------------
Scenario: Begin reveal transitions room from running to revealing
# AC-H04-1
Given room status is "running" with 3 players and winnerCount 1
When host sends { type: "BEGIN_REVEAL" } via WebSocket
Then room status transitions to "revealing"
And all players receive ROOM_STATE broadcast with status "revealing"
And the operation completes within 1 second
# ---------------------------------------------------------------------------
# AC-H04-2 — Reveal one player path manually
# ---------------------------------------------------------------------------
Scenario: Reveal one player's path manually
# AC-H04-2
Given room status is "revealing" with 3 players and revealedCount is 0
When host sends { type: "REVEAL_NEXT" } via WebSocket
Then all players receive REVEAL_INDEX event
And REVEAL_INDEX payload contains playerIndex, result, and revealedCount=1
And all clients render the player's path animation synchronously
# ---------------------------------------------------------------------------
# AC-H06-1, AC-H06-2 — Reveal all paths simultaneously
# ---------------------------------------------------------------------------
Scenario: Reveal all remaining paths simultaneously with REVEAL_ALL_TRIGGER
# AC-H06-1, AC-H06-2
Given room status is "revealing" with 4 players and revealedCount is 1
When host sends { type: "REVEAL_ALL_TRIGGER" } via WebSocket
Then all players receive REVEAL_ALL event
And REVEAL_ALL payload contains all remaining 3 unrevealed results
And all clients render the animations within 2 seconds
And host then sends END_GAME to finalize the game
And room status transitions to "finished"
And all players receive ROOM_STATE with status "finished" containing complete results and seed
# ---------------------------------------------------------------------------
# AC-H04-4 — END_GAME after all paths revealed transitions to finished
# ---------------------------------------------------------------------------
Scenario: Game completion with winner announcement after all paths revealed
# AC-H04-4, US-P04
Given room status is "revealing" with 3 players and all paths revealed (revealedCount=3)
When host sends { type: "END_GAME" } via WebSocket
Then room status transitions to "finished"
And all players receive ROOM_STATE with status "finished"
And the broadcast includes complete winner list and seed
And each player's screen correctly shows their own result (winner or loser)
# ---------------------------------------------------------------------------
# AC-H04-4b — END_GAME rejected when paths not fully revealed
# ---------------------------------------------------------------------------
Scenario: END_GAME rejected when not all paths are revealed
# AC-H04-4b
Given room status is "revealing" with 3 players and revealedCount is 1
When host sends { type: "END_GAME" } via WebSocket
Then host receives ERROR event with code "END_GAME_REQUIRES_ALL_REVEALED"
And room status remains "revealing"
# ---------------------------------------------------------------------------
# AC-H08-1 — Play again resets room to waiting after finished
# ---------------------------------------------------------------------------
Scenario: Play again resets room to waiting with online players only
# AC-H08-1
Given room status is "finished" with 3 players (1 offline)
When host sends { type: "PLAY_AGAIN" } via WebSocket
Then offline players are removed from the player list
And room status resets to "waiting" with the remaining online players
And all remaining players receive ROOM_STATE with status "waiting"
And kickedPlayerIds is cleared
# ---------------------------------------------------------------------------
# AC-H08-3 — Play again rejected when insufficient online players
# ---------------------------------------------------------------------------
Scenario: Play again rejected when fewer than 2 online players remain
# AC-H08-3
Given room status is "finished" with only 1 online player remaining
When host sends { type: "PLAY_AGAIN" } via WebSocket
Then host receives ERROR event with code "INSUFFICIENT_ONLINE_PLAYERS"
And room status remains "finished"
# ---------------------------------------------------------------------------
# AC-H03-6 — Duplicate START_GAME rejected after game has started
# ---------------------------------------------------------------------------
Scenario: Duplicate START_GAME is rejected without resetting state
# AC-H03-6
Given room status is already "running"
When host sends { type: "START_GAME" } again via WebSocket
Then host receives ERROR event with code "INVALID_STATE"
And room status remains "running"
And seed and ladderMap are not regenerated
host-actions.feature
# features/host-actions.feature
Feature: 主持人管理操作
As a 主持人
I want to 踢除特定玩家並在一局結束後發起再玩一局
So that 能移除不當參與者並用同一房間繼續下一輪抽獎
Background:
Given 主持人已透過 hostToken 驗證身份
# ---------------------------------------------------------------------------
# AC-H07-1 — waiting 狀態踢除玩家 → 廣播 PLAYER_KICKED
# ---------------------------------------------------------------------------
@AC-HOST-KICK-001 @P1
Scenario: 主持人在 waiting 狀態踢除玩家後廣播 PLAYER_KICKED
Given 房間碼 "DELTA5" 存在且狀態為 "waiting"
And 房間內有玩家 "Tom"(playerId="player-tom-uuid")
When 主持人送出 KICK_PLAYER(targetPlayerId="player-tom-uuid")
Then 伺服器廣播 PLAYER_KICKED 事件給所有連線客戶端
And PLAYER_KICKED payload 包含 kickedPlayerId="player-tom-uuid"
And "Tom" 的客戶端收到後跳轉至「你已被主持人移出房間」頁面
And 玩家列表即時更新,不再顯示 "Tom"
# ---------------------------------------------------------------------------
# AC-H07-2 — 被踢玩家嘗試重連 → PLAYER_KICKED 錯誤
# ---------------------------------------------------------------------------
@AC-HOST-KICK-002 @P1
Scenario: 被踢玩家嘗試以相同 playerId 重連時被拒絕
Given 房間碼 "DELTA5" 中 playerId="player-tom-uuid" 已存在於 kickedPlayerIds
When "Tom" 嘗試以相同 playerId="player-tom-uuid" 重新建立 WebSocket 連線
Then WS Upgrade 被伺服器以 close code 4003 拒絕
And 前端顯示「你已被移出此房間,無法重新加入」
# ---------------------------------------------------------------------------
# AC-H07-3 — running 或之後狀態踢除玩家 → INVALID_STATE
# ---------------------------------------------------------------------------
@AC-HOST-KICK-003 @P1
Scenario Outline: 非 waiting 狀態踢除玩家時系統拒絕並回傳 INVALID_STATE
Given 房間碼 "DELTA5" 存在且狀態為 "<room_status>"
And 房間內有玩家 "Jerry"(playerId="player-jerry-uuid")
When 主持人送出 KICK_PLAYER(targetPlayerId="player-jerry-uuid")
Then 伺服器回傳錯誤碼 "INVALID_STATE"
And 房間狀態維持 "<room_status>"
And 玩家 "Jerry" 的連線不受影響
Examples:
| room_status |
| running |
| revealing |
| finished |
# ---------------------------------------------------------------------------
# AC-HOST-KICK-SELF — 主持人嘗試踢除自己時系統拒絕並回傳 CANNOT_KICK_HOST
# ---------------------------------------------------------------------------
@AC-HOST-KICK-SELF @P1
Scenario: 主持人嘗試踢除自己時收到 CANNOT_KICK_HOST 錯誤
Given 房間碼 "DELTA5" 存在且狀態為 "waiting"
And 主持人的 playerId 為 "host-player-uuid"
When 主持人送出 KICK_PLAYER(targetPlayerId="host-player-uuid",即自己)
Then 伺服器回傳錯誤碼 "CANNOT_KICK_SELF"
And 主持人仍在房間玩家列表中
And 房間狀態維持 "waiting"
# ---------------------------------------------------------------------------
# AC-H07-4 — 踢除後 playerId 持久化存入 Redis kickedPlayerIds
# ---------------------------------------------------------------------------
@AC-HOST-KICK-004 @P1
Scenario: 踢除操作完成後被踢玩家的 playerId 存入 Redis kickedPlayerIds
Given 房間碼 "DELTA5" 存在且狀態為 "waiting"
And 房間內有玩家 "Sam"(playerId="player-sam-uuid")
When 主持人成功踢除 "Sam"
Then Redis 中房間資料的 kickedPlayerIds 包含 "player-sam-uuid"
And 任何以 playerId="player-sam-uuid" 嘗試重連的請求均回傳 PLAYER_KICKED 錯誤
# ---------------------------------------------------------------------------
# AC-H07-5 — RESET_ROOM 後 kickedPlayerIds 清空
# ---------------------------------------------------------------------------
@AC-HOST-KICK-005 @P1
Scenario: 再玩一局後 kickedPlayerIds 被清空
Given 房間碼 "DELTA5" 狀態為 "finished"
And kickedPlayerIds 包含 ["player-sam-uuid", "player-tom-uuid"]
When 主持人送出 RESET_ROOM
Then 新局初始化完成後 Redis 中 kickedPlayerIds 為空陣列 []
And 前一局被踢玩家可使用新暱稱加入新局
When the host resets the game with "RESET_ROOM"
And the previously kicked player joins with a new nickname "NewPlayer"
Then the join response status should be 201
# ---------------------------------------------------------------------------
# AC-H08-1 — 再玩一局:剔除離線玩家後重置為 waiting
# ---------------------------------------------------------------------------
@AC-HOST-RESET-001 @P1
Scenario: 主持人發起再玩一局後系統剔除離線玩家並重置房間
Given 房間碼 "DELTA5" 狀態為 "finished"
And 房間有 5 名玩家,其中 "Offline-User"(isOnline=false)
When 主持人送出 RESET_ROOM
Then 系統從玩家列表移除 isOnline=false 的 "Offline-User"
And 伺服器廣播 ROOM_STATE(status="waiting",含剩餘 4 名在線玩家)
# ---------------------------------------------------------------------------
# AC-H08-2 — 再玩一局後 W 越界則重設為 null
# ---------------------------------------------------------------------------
@AC-HOST-RESET-002 @P1
Scenario: 再玩一局後 winnerCount 越界時自動重設為 null
Given 房間碼 "DELTA5" 狀態為 "finished",winnerCount=4
And 剔除離線玩家後剩餘在線玩家數量為 4(新 N=4,W=4 >= 新 N)
When 主持人送出 RESET_ROOM
Then 伺服器將 winnerCount 設為 null
And 主持人收到「中獎名額已重設,請重新設定」提示
And 新局 ROOM_STATE 中 winnerCount 為 null
# ---------------------------------------------------------------------------
# AC-H08-3 — 再玩一局時在線玩家數 < 2 → INSUFFICIENT_ONLINE_PLAYERS
# ---------------------------------------------------------------------------
@AC-HOST-RESET-003 @P1
Scenario: 再玩一局時在線玩家不足兩人時系統拒絕
Given 房間碼 "DELTA5" 狀態為 "finished"
And 目前僅有 1 名玩家在線(isOnline=true)
When 主持人送出 RESET_ROOM
Then 伺服器回傳錯誤碼 "INSUFFICIENT_ONLINE_PLAYERS"
And 前端顯示「在線玩家不足,無法開始新局」
And 房間狀態維持 "finished"
# ---------------------------------------------------------------------------
# AC-PLAY-AGAIN-001 — PLAY_AGAIN(新事件):finished 狀態下成功重置為 waiting
# ---------------------------------------------------------------------------
@AC-PLAY-AGAIN-001 @P1
Scenario: 主持人在 finished 狀態送出 PLAY_AGAIN 成功重置房間
Given 房間碼 "DELTA5" 狀態為 "finished"
And 房間有 5 名玩家,其中 4 名在線(isOnline=true),1 名離線(isOnline=false)
When 主持人送出 WS 訊息 PLAY_AGAIN
Then 系統從玩家列表移除所有 isOnline=false 的玩家
And 伺服器廣播 ROOM_STATE(status="waiting",含剩餘 4 名在線玩家)
And ladder 資料清空(ladder=null)
And results 資料清空(results=[])
# ---------------------------------------------------------------------------
# AC-PLAY-AGAIN-002 — PLAY_AGAIN 後 kickedPlayerIds 清空,前次被踢玩家可重新加入
# ---------------------------------------------------------------------------
@AC-PLAY-AGAIN-002 @P1
Scenario: PLAY_AGAIN 後 kickedPlayerIds 被清空,前次被踢玩家可以新暱稱加入
Given 房間碼 "DELTA5" 狀態為 "finished"
And kickedPlayerIds 包含 ["player-sam-uuid", "player-tom-uuid"]
When 主持人送出 WS 訊息 PLAY_AGAIN
Then 新局初始化完成後 Redis 中 kickedPlayerIds 為空陣列 []
And 前一局被踢玩家可使用新暱稱加入新局
# ---------------------------------------------------------------------------
# AC-PLAY-AGAIN-003 — PLAY_AGAIN 後 winnerCount 越界時自動重設為 null
# ---------------------------------------------------------------------------
@AC-PLAY-AGAIN-003 @P1
Scenario: PLAY_AGAIN 後在線玩家減少導致 winnerCount 越界,系統自動重設為 null
Given 房間碼 "DELTA5" 狀態為 "finished",winnerCount=4
And 剔除離線玩家後剩餘在線玩家數量為 4(新 N=4,W=4 >= 新 N)
When 主持人送出 WS 訊息 PLAY_AGAIN
Then 伺服器將 winnerCount 設為 null
And 主持人收到「中獎名額已重設,請重新設定」提示
And 新局 ROOM_STATE 中 winnerCount 為 null
# ---------------------------------------------------------------------------
# AC-PLAY-AGAIN-004 — PLAY_AGAIN 在非 finished 狀態被拒絕(錯誤路徑)
# ---------------------------------------------------------------------------
@AC-PLAY-AGAIN-004 @P1
Scenario Outline: 非 finished 狀態送出 PLAY_AGAIN 時收到 INVALID_STATE 錯誤
Given 房間碼 "DELTA5" 存在且狀態為 "<room_status>"
When 主持人送出 WS 訊息 PLAY_AGAIN
Then 伺服器回傳 ERROR 事件,code 為 "INVALID_STATE"
And 房間狀態維持 "<room_status>"
Examples:
| room_status |
| waiting |
| running |
| revealing |
# ---------------------------------------------------------------------------
# AC-PLAY-AGAIN-005 — PLAY_AGAIN 在線玩家不足兩人時被拒絕(錯誤路徑)
# ---------------------------------------------------------------------------
@AC-PLAY-AGAIN-005 @P1
Scenario: PLAY_AGAIN 時在線玩家不足兩人系統拒絕並回傳 INSUFFICIENT_ONLINE_PLAYERS
Given 房間碼 "DELTA5" 狀態為 "finished"
And 目前僅有主持人 1 名玩家在線(isOnline=true)
When 主持人送出 WS 訊息 PLAY_AGAIN
Then 伺服器回傳錯誤碼 "INSUFFICIENT_ONLINE_PLAYERS"
And 前端顯示「在線玩家不足,無法開始新局」
And 房間狀態維持 "finished"
# ---------------------------------------------------------------------------
# AC-P06-1/2 — 玩家被踢除後收到 PLAYER_KICKED 通知並跳轉首頁
# ---------------------------------------------------------------------------
@AC-P06-001 @P1
Scenario: 玩家被踢除後收到 PLAYER_KICKED unicast 並跳轉首頁
Given 房間碼 "DELTA5" 存在且狀態為 "waiting"
And 玩家 "Alice"(playerId="player-alice-uuid")已連線至房間
When 主持人送出 KICK_PLAYER(targetPlayerId="player-alice-uuid")
Then 伺服器 unicast PLAYER_KICKED 給 "Alice" 的 WebSocket 連線
And "Alice" 的客戶端顯示「你已被主持人移出房間」提示
And "Alice" 的 WebSocket 連線關閉
And 頁面顯示「回首頁」按鈕
@AC-P06-002 @P1
Scenario: 被踢除的玩家嘗試重連時收到 PLAYER_KICKED 錯誤
Given playerId="player-alice-uuid" 已存在於 kickedPlayerIds
When "Alice" 嘗試以相同 playerId 重新建立 WebSocket 連線
Then WS Upgrade 被以 close code 4003 拒絕
And 前端顯示「你已被移出此房間,無法重新加入」
# ---------------------------------------------------------------------------
# NFR-05 — 無效或過期 JWT 嘗試 Host 操作時回傳 401
# ---------------------------------------------------------------------------
@AC-AUTH-001 @P0
Scenario: 使用無效 JWT 執行 Host 操作時收到 401 AUTH_INVALID_TOKEN
Given 主持人使用格式錯誤或簽名不符的 JWT token
When 主持人嘗試送出 DELETE /api/rooms/DELTA5/players/some-player-id
Then HTTP 回應狀態碼為 401
And 回應錯誤碼為 "AUTH_INVALID_TOKEN"
@AC-AUTH-002 @P0
Scenario: 使用過期 JWT 執行 Host 操作時收到 401 AUTH_TOKEN_EXPIRED
Given 主持人的 JWT token 已超過 6 小時過期
When 主持人嘗試送出 POST /api/rooms/DELTA5/game/start
Then HTTP 回應狀態碼為 401
And 回應錯誤碼為 "AUTH_TOKEN_EXPIRED"
ladder-generation.feature
Feature: Ladder Generation
As a system
I want to generate deterministic and valid ladder structures using Mulberry32 PRNG
So that all clients get identical results and outcomes cannot be predicted before reveal
# ---------------------------------------------------------------------------
# FR-04-1, FR-04-2 — Deterministic generation with same seed produces same ladder
# ---------------------------------------------------------------------------
Scenario: Deterministic generation with same seed produces identical ladders
# FR-04-1, AC-H03-1
Given seedSource "550e8400-e29b-41d4-a716-446655440000" and 5 players
When ladder is generated twice using the same seedSource and N
Then both ladders are identical (deep equality on segments, rowCount, colCount)
And both ladders have the same rowCount
And both ladders have the same colCount equal to the player count
# ---------------------------------------------------------------------------
# FR-04-3 — rowCount clamping to [20, 60]
# ---------------------------------------------------------------------------
Scenario Outline: rowCount is clamped to [20, 60] for all player counts
# AC-H03-5, FR-04-3
Given seedSource "test-seed-xyz" and <N> players
When ladder is generated
Then ladder rowCount equals <expected_rowCount>
Examples:
| N | expected_rowCount | note |
| 2 | 20 | clamp(6, 20, 60)=20 |
| 3 | 20 | clamp(9, 20, 60)=20 |
| 7 | 21 | clamp(21, 20, 60)=21 |
| 10 | 30 | clamp(30, 20, 60)=30 |
| 20 | 60 | clamp(60, 20, 60)=60 |
| 21 | 60 | clamp(63, 20, 60)=60 |
| 50 | 60 | clamp(150, 20, 60)=60 |
# ---------------------------------------------------------------------------
# FR-04-2 — Segment validity: no overlap within the same row
# ---------------------------------------------------------------------------
Scenario: No segments overlap within the same row
# FR-04-2
Given seedSource "550e8400-e29b-41d4-a716-446655440000" and 10 players
When ladder is generated
Then for every pair of segments in the same row (col_a, col_b)
And |col_a - col_b| > 1 (no adjacent overlap)
Scenario Outline: No overlapping segments for various player counts
# FR-04-2
Given seedSource "test-seed-abc123" and <N> players
When ladder is generated
Then no row contains overlapping segments
Examples:
| N |
| 2 |
| 5 |
| 20 |
| 50 |
# ---------------------------------------------------------------------------
# FR-04-2 — Bar density: target max(1, round(N/4)) bars per row
# ---------------------------------------------------------------------------
Scenario Outline: Bar density target is max(1, round(N/4)) per row
# FR-04-2
Given seedSource "deterministic-seed-xyz" and <N> players
When ladder is generated
Then the average bars per row approaches <target_density>
And each bar attempt does not exceed N*10 retries
Examples:
| N | target_density |
| 2 | 1 |
| 4 | 1 |
| 8 | 2 |
| 12 | 3 |
| 20 | 5 |
# ---------------------------------------------------------------------------
# FR-04-4 — Bijection: N players produce N unique endCol values
# ---------------------------------------------------------------------------
Scenario: Result bijection ensures N unique endCol values for N players
# FR-04-4
Given seedSource "550e8400-e29b-41d4-a716-446655440000" and 8 players with winnerCount 3
When ladder is generated and results are computed
Then resultSlots array length equals 8
And all endCol values are unique (no duplicates, covering 0 to 7)
And the number of isWinner=true results equals 3
And the number of isWinner=false results equals 5
Scenario Outline: Bijection holds for various N and winnerCount combinations
# FR-04-4
Given seedSource "deterministic-seed-xyz" and <N> players with winnerCount <W>
When ladder is generated and results are computed
Then resultSlots array length equals <N>
And all endCol values are unique
And isWinner=true count equals <W>
Examples:
| N | W |
| 2 | 1 |
| 5 | 2 |
| 10 | 3 |
| 50 | 10 |
# ---------------------------------------------------------------------------
# FR-04-4 — 1000-seed automated bijection validation
# ---------------------------------------------------------------------------
Scenario: 1000 random seeds all satisfy bijection property
# FR-04-4, NFR-03
Given 1000 randomly generated seedSource values with N=10 and winnerCount=3
When ladder and results are computed for each seed
Then all 1000 results satisfy bijection (all endCol values unique)
And no result has more or fewer than N entries
# ---------------------------------------------------------------------------
# FR-04-6 — Seed not exposed to clients before finished state
# ---------------------------------------------------------------------------
Scenario: Seed is not transmitted to clients before room status is finished
# FR-04-6
Given a room is in "revealing" status with ladder generated
When a client receives ROOM_STATE or ROOM_STATE_FULL
Then the payload does NOT contain the seed field
And the payload does NOT contain the seedSource field
Scenario: Seed is transmitted to clients when room status becomes finished
# FR-04-6, AC-H04-4
Given a room transitions to "finished" status
When all clients receive ROOM_STATE broadcast
Then the payload contains the seed field
And the payload contains the complete results array
play_again.feature
# features/play_again.feature
#
# BDD Feature: 再玩一場(Play Again After Game Ends)
#
# 對應規格:
# PRD §3 US-H08 (AC-H08-1 ~ AC-H08-5)
# PRD §4 FR-12-1 ~ FR-12-3
# API §2.10 POST /api/rooms/:code/game/play-again
# API §3.6 WS PLAY_AGAIN
#
# 摘要:
# 遊戲結束(status=finished)後,房主可呼叫「再玩一場」重置房間,
# 所有在線玩家保留在房間並重新進入 waiting 狀態。
Feature: Play Again After Game Ends
As a host
I want to trigger "play again" after a game finishes
So that I can start the next round using the same room without asking players to rejoin
Background:
# 前置條件:主持人已取得有效 hostToken,並於 finished 狀態下操作
Given the host has a valid JWT token with role "host"
And room "ROOM01" exists with status "finished"
# ---------------------------------------------------------------------------
# AC-H08-1:finished 狀態下房主呼叫 play-again → 剔除離線玩家 → 重置為 waiting
# HTTP 路徑:POST /api/rooms/:code/game/play-again
# WS 路徑:PLAY_AGAIN 訊息
# ---------------------------------------------------------------------------
# 測試 Happy Path:透過 HTTP REST 呼叫再玩一場
@AC-H08-1 @P1 @http
Scenario: Host calls play-again via HTTP and room resets to waiting with online players only
# 房間有 5 名玩家,其中 1 名離線(isOnline=false)
Given room "ROOM01" has 5 players:
| nickname | isOnline |
| Alice | true |
| Bob | true |
| Carol | true |
| Dave | true |
| Offline1 | false |
When the host sends POST /api/rooms/ROOM01/game/play-again with valid token
Then the HTTP response status is 200
And the response body contains status "waiting"
And the response body players list has 4 entries
And "Offline1" is not in the response players list
# 廣播 ROOM_STATE 給所有仍在線的玩家
And a ROOM_STATE broadcast is sent to all connected clients with status "waiting"
And the ROOM_STATE payload players count is 4
# 測試 Happy Path:透過 WebSocket 訊息 PLAY_AGAIN 呼叫再玩一場
@AC-H08-1 @P1 @websocket
Scenario: Host sends PLAY_AGAIN via WebSocket and room resets to waiting with online players only
# 房間有 5 名玩家,其中 1 名離線(isOnline=false)
Given room "ROOM01" has 5 players:
| nickname | isOnline |
| Alice | true |
| Bob | true |
| Carol | true |
| Dave | true |
| Offline1 | false |
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then a ROOM_STATE broadcast is sent to all connected clients
And the ROOM_STATE payload status is "waiting"
And the ROOM_STATE payload players list contains exactly 4 entries
And "Offline1" is not included in the broadcast players list
# Redis 中確認離線玩家已被剔除
And the Redis room record for "ROOM01" has 4 players
# ---------------------------------------------------------------------------
# AC-H08-1 + FR-12-1:ladder、results 及 revealedCount 在新局中被清空
# ---------------------------------------------------------------------------
@AC-H08-1-RESET-DATA @P1
Scenario: After play-again, game data is cleared and room is in clean waiting state
# 確認梯子與結果資料在再玩一場後被清空
Given room "ROOM01" has ladder data and results from the previous game
And room "ROOM01" has revealedCount = 5
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then the ROOM_STATE broadcast contains ladder = null
And the ROOM_STATE broadcast contains results = null
And the ROOM_STATE broadcast contains revealedCount = 0
And the ROOM_STATE broadcast contains rowCount = null
# ---------------------------------------------------------------------------
# AC-H08-2 / FR-12-2:再玩一場後 winnerCount 越界時自動重設為 null
# 當新 N <= 原 winnerCount 時,winnerCount 被重設並通知主持人
# ---------------------------------------------------------------------------
@AC-H08-2 @P1
Scenario: winnerCount is reset to null when it becomes out of bounds after play-again
# 原 winnerCount=4,剔除離線玩家後剩餘在線玩家 N=4,W >= N 故越界
Given room "ROOM01" has winnerCount = 4
And room "ROOM01" has 5 players:
| nickname | isOnline |
| Alice | true |
| Bob | true |
| Carol | true |
| Dave | true |
| Offline1 | false |
# 剔除後新 N=4,winnerCount=4 >= N=4,故需重設
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then the ROOM_STATE broadcast contains winnerCount = null
# 主持人應收到需重新設定的提示
And the host receives an ERROR event with code "WINNER_COUNT_RESET"
And the error message contains "請重新設定中獎名額"
# ---------------------------------------------------------------------------
# AC-H08-3 / FR-12-1:在線玩家 < 2 時拒絕再玩一場
# HTTP 路徑回傳 400 INSUFFICIENT_ONLINE_PLAYERS
# WS 路徑回傳 ERROR 事件
# ---------------------------------------------------------------------------
# 透過 HTTP 呼叫時的拒絕
@AC-H08-3 @P1 @http
Scenario: play-again via HTTP is rejected when online players count is less than 2
# 房間僅剩主持人 1 名在線
Given room "ROOM01" has 3 players:
| nickname | isOnline |
| HostUser | true |
| Offline2 | false |
| Offline3 | false |
When the host sends POST /api/rooms/ROOM01/game/play-again with valid token
Then the HTTP response status is 400
And the response error code is "INSUFFICIENT_ONLINE_PLAYERS"
And the room status remains "finished"
# 透過 WebSocket 呼叫時的拒絕
@AC-H08-3 @P1 @websocket
Scenario: PLAY_AGAIN via WebSocket is rejected when online players count is less than 2
# 房間僅剩主持人 1 名在線,無法滿足 N >= 2 的條件
Given room "ROOM01" has 3 players:
| nickname | isOnline |
| HostUser | true |
| Offline2 | false |
| Offline3 | false |
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then the host receives an ERROR event with code "INSUFFICIENT_ONLINE_PLAYERS"
And the error message contains "在線玩家不足(至少需要 2 位),無法開始新局"
And no ROOM_STATE broadcast is sent
And the room status remains "finished"
# ---------------------------------------------------------------------------
# AC-H08-5 / FR-12-2:再玩一場後 kickedPlayerIds 被清空
# 前一局被踢者可在新局以全新 playerId 加入
# ---------------------------------------------------------------------------
@AC-H08-5 @P1
Scenario: kickedPlayerIds is cleared after play-again and previously kicked player can rejoin
# kickedPlayerIds 在再玩一場後必須清空
Given room "ROOM01" has kickedPlayerIds = ["kicked-player-uuid-1", "kicked-player-uuid-2"]
And room "ROOM01" has 3 players with 2 online
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then the ROOM_STATE broadcast contains kickedPlayerIds = []
And the Redis room record for "ROOM01" has kickedPlayerIds = []
# 前次被踢的玩家可以用新暱稱重新加入
And a new player with nickname "PreviouslyKicked" can join room "ROOM01"
# ---------------------------------------------------------------------------
# 非房主呼叫 play-again → 403 Forbidden(HTTP 與 WS 均適用)
# API §2.10 PLAYER_NOT_HOST / WS §3.6 驗證:host only
# ---------------------------------------------------------------------------
@AC-PLAY-AGAIN-NON-HOST @P1 @http
Scenario: Non-host player calling play-again via HTTP receives 403 Forbidden
# 非房主使用一般玩家 token 呼叫時應收到 403
Given room "ROOM01" exists with status "finished"
And a player "Bob" has a valid JWT token with role "player"
When "Bob" sends POST /api/rooms/ROOM01/game/play-again with player token
Then the HTTP response status is 403
And the response error code is "PLAYER_NOT_HOST"
And the room status remains "finished"
@AC-PLAY-AGAIN-NON-HOST @P1 @websocket
Scenario: Non-host player sending PLAY_AGAIN via WebSocket receives PLAYER_NOT_HOST error
# 非房主透過 WS 發送 PLAY_AGAIN 時應收到錯誤
Given room "ROOM01" exists with status "finished"
And a player "Bob" is connected via WebSocket with role "player"
When "Bob" sends WebSocket message PLAY_AGAIN with payload {}
Then "Bob" receives an ERROR event with code "PLAYER_NOT_HOST"
And no ROOM_STATE broadcast is sent
And the room status remains "finished"
# ---------------------------------------------------------------------------
# AC-H08-5:房間不在 finished 狀態下呼叫 → 400 / INVALID_STATE
# API §2.10 INVALID_STATE / WS §3.6 驗證:status 必須為 finished
# ---------------------------------------------------------------------------
@AC-H08-5 @P1 @http
Scenario Outline: play-again via HTTP is rejected with INVALID_STATE when room is not finished
# 只有 finished 狀態允許再玩一場,其他狀態應被拒絕
Given room "ROOM01" exists with status "<room_status>"
When the host sends POST /api/rooms/ROOM01/game/play-again with valid token
Then the HTTP response status is 409
And the response error code is "INVALID_STATE"
And the room status remains "<room_status>"
Examples:
| room_status |
| waiting |
| running |
| revealing |
@AC-H08-5 @P1 @websocket
Scenario Outline: PLAY_AGAIN via WebSocket is rejected with INVALID_STATE when room is not finished
# WS 路徑同樣拒絕非 finished 狀態的再玩一場請求
Given room "ROOM01" exists with status "<room_status>"
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then the host receives an ERROR event with code "INVALID_STATE"
And no ROOM_STATE broadcast is sent
And the room status remains "<room_status>"
Examples:
| room_status |
| waiting |
| running |
| revealing |
# ---------------------------------------------------------------------------
# FR-12-3 / §3.5 ROOM_STATE:廣播包含所有在線玩家(WS broadcast 驗證)
# 確保再玩一場後 ROOM_STATE 廣播給所有仍在線玩家(含重連玩家)
# ---------------------------------------------------------------------------
@AC-WS-BROADCAST @P1 @websocket
Scenario: ROOM_STATE broadcast after PLAY_AGAIN reaches all online connected clients
# 確認 ROOM_STATE 廣播給所有在線客戶端,包含重連回來的玩家
Given room "ROOM01" exists with status "finished"
And 4 players are connected via WebSocket to room "ROOM01"
And 1 player "LostConn" is offline (isOnline=false, no active WebSocket)
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then every active WebSocket connection in room "ROOM01" receives a ROOM_STATE message
And the ROOM_STATE payload contains status = "waiting"
And the ROOM_STATE payload players list does not contain "LostConn"
# ROOM_STATE_FULL 為每位玩家 unicast 的完整狀態快照,含 selfPlayerId
And each connected client receives their own ROOM_STATE_FULL with selfPlayerId set correctly
# ---------------------------------------------------------------------------
# 完整流程整合:finished → play-again → waiting → start next game
# 驗證整個「再玩一場」到「下一局開始」的狀態機轉換
# ---------------------------------------------------------------------------
@AC-FULL-FLOW @P1 @integration
Scenario: Full play-again flow from finished state to next game start
# 完整流程:遊戲結束 → 再玩一場 → 等待 → 設定中獎名額 → 開始下一局
Given room "ROOM01" exists with status "finished"
And room "ROOM01" has 4 online players and 1 offline player
And room "ROOM01" has kickedPlayerIds = ["kicked-uuid"]
And room "ROOM01" has winnerCount = 2
# Step 1: 主持人發起再玩一場
When the host sends WebSocket message PLAY_AGAIN with payload {}
Then a ROOM_STATE broadcast is sent with status "waiting"
And the broadcast players count is 4
And the broadcast kickedPlayerIds is []
And the broadcast winnerCount is 2
# winnerCount=2 < newN=4,保留不重設
# Step 2: 主持人設定中獎名額並開始新一局
When the host sets winnerCount to 1 for the new round
And the host sends WebSocket message START_GAME with payload {}
Then a ROOM_STATE broadcast is sent with status "running"
And the broadcast rowCount equals clamp(4 * 3, 20, 60) which is 20
# seed 與梯子資料在 finished 前不對客戶端公開
And the broadcast does not contain seed or ladder data
player-management.feature
# features/player-management.feature
Feature: 玩家加入與管理
As a 玩家
I want to 輸入暱稱並透過 6 碼房間碼加入房間
So that 能參與抽獎活動並即時看到玩家列表更新
Background:
Given 房間碼 "ALPHA2" 已存在且狀態為 "waiting"
# ---------------------------------------------------------------------------
# AC-P01-1 — 有效暱稱與正確房間碼,1.5 秒內加入成功
# ---------------------------------------------------------------------------
@AC-PLAYER-001 @P0
Scenario: 玩家以有效暱稱成功加入房間
Given 玩家輸入有效暱稱 "Alice"(長度 1~20 Unicode 字元)
When 玩家送出 POST /api/v1/rooms/ALPHA2/players 並 WebSocket 握手成功
Then HTTP 回應狀態碼為 201
And 回應包含 playerId(UUID v4 格式)及 sessionToken
And WebSocket 連線於 1.5 秒內建立完成
And 主持人及所有在線玩家收到更新後的 ROOM_STATE(玩家列表含 "Alice")
# ---------------------------------------------------------------------------
# AC-P01-2 — 暱稱與同房間已有玩家重複 → NICKNAME_TAKEN
# ---------------------------------------------------------------------------
@AC-PLAYER-002 @P0
Scenario: 玩家輸入重複暱稱時收到 NICKNAME_TAKEN 錯誤
Given 房間內已有暱稱為 "Bob" 的玩家
When 新玩家嘗試以暱稱 "Bob" 加入同一房間
Then HTTP 回應狀態碼為 409
And 回應錯誤碼為 "NICKNAME_TAKEN"
And 錯誤訊息包含「此暱稱已被使用,請換一個」
# ---------------------------------------------------------------------------
# AC-P01-3 — 前端暱稱驗證(空白 / 超過 20 字元)
# ---------------------------------------------------------------------------
@AC-PLAYER-003 @P0
Scenario Outline: 前端即時驗證暱稱格式不合規時禁用送出
Given 玩家在暱稱輸入框填入 "<nickname>"
When 前端執行即時驗證
Then 輸入框顯示欄位錯誤訊息
And 送出按鈕保持 disabled 狀態
And 不發送任何 HTTP 請求到伺服器
Examples:
| nickname | 說明 |
| | 空白暱稱 |
| ABCDEFGHIJKLMNOPQRSTU | 超過 20 字元(21)|
| 一二三四五六七八九十一二三四五六七八九十甲 | 超過 20 Unicode 字元 |
# ---------------------------------------------------------------------------
# AC-P01-4 — 房間人數達 50 人上限 → ROOM_FULL
# ---------------------------------------------------------------------------
@AC-PLAYER-004 @P0
Scenario: 房間已達 50 人上限時新玩家收到 ROOM_FULL 錯誤
Given 房間已有 50 名玩家(含主持人)
When 第 51 名玩家嘗試加入房間
Then HTTP 回應狀態碼為 409
And 回應錯誤碼為 "ROOM_FULL"
And 玩家看到「房間已滿,無法加入」提示
# ---------------------------------------------------------------------------
# AC-P02-1 — 新玩家加入後所有在線玩家 2 秒內看到更新的玩家列表
# ---------------------------------------------------------------------------
@AC-PLAYER-005 @P0
Scenario: 新玩家加入後所有在線玩家即時收到玩家列表更新
Given 房間內已有玩家 "Carol" 和 "Dave" 在線
When 新玩家 "Eve" 成功加入房間
Then 所有在線玩家於 2 秒內收到 ROOM_STATE 廣播
And ROOM_STATE payload 中玩家列表包含 "Carol"、"Dave"、"Eve" 三人
# ---------------------------------------------------------------------------
# AC-P02-2 — 玩家離線後其他玩家看到「離線」標記,不立即移除
# ---------------------------------------------------------------------------
@AC-PLAYER-006 @P0
Scenario: 玩家斷線後其他玩家列表顯示離線標記
Given 房間內有玩家 "Frank" 在線(isOnline=true)
When 伺服器偵測到 "Frank" 的 WebSocket 連線中斷
Then 伺服器廣播 ROOM_STATE
And ROOM_STATE 中 "Frank" 的 isOnline 為 false
And 其他玩家的等待畫面在 2 秒內更新並顯示 "Frank" 為「離線」
And "Frank" 的名額與位置保留,不從玩家列表移除
# ---------------------------------------------------------------------------
# EDD §12.1 ROOM_NOT_ACCEPTING — 玩家嘗試加入非 waiting 狀態的房間
# ---------------------------------------------------------------------------
@AC-PLAYER-007 @P0
Scenario Outline: 玩家嘗試加入非 waiting 狀態房間時收到 ROOM_NOT_ACCEPTING
Given 房間碼 "ALPHA2" 存在且狀態為 "<room_status>"
When 玩家嘗試以暱稱 "NewPlayer" 加入房間
Then HTTP 回應狀態碼為 409
And 回應錯誤碼為 "ROOM_NOT_ACCEPTING"
And 前端顯示「房間已在進行中,無法加入」提示
Examples:
| room_status |
| running |
| revealing |
| finished |
prng.feature
# features/prng.feature
Feature: PRNG 確定性與正確性
As a 系統
I want to 以 Mulberry32+djb2 確定性算法生成樓梯結構
So that 相同輸入恆得相同輸出,確保所有客戶端 100% 結果一致且無法舞弊
# ---------------------------------------------------------------------------
# 確定性(Determinism)— 相同 seed+N 兩次呼叫完全一致
# ---------------------------------------------------------------------------
@AC-PRNG-001 @P0
Scenario: 相同 seed 和 N 生成完全相同的樓梯結構(快照測試)
Given seedSource 為固定字串 "550e8400-e29b-41d4-a716-446655440000"
And N=5(玩家數)
When 以相同 seedSource 和 N 兩次呼叫 generateLadder
Then 第一次和第二次呼叫的 LadderData.segments 完全相等(深度比較)
And 兩次呼叫的 rowCount 相同
And 兩次呼叫的 colCount 相同
# ---------------------------------------------------------------------------
# 無重疊橫槓(Non-overlapping segments)
# ---------------------------------------------------------------------------
@AC-PRNG-002 @P0
Scenario: 同一 row 中不存在重疊的橫槓
Given seedSource 為 "550e8400-e29b-41d4-a716-446655440000",N=10
When 呼叫 generateLadder 生成 LadderData
Then 對於 LadderData.segments 中所有 row 相同的橫槓對(col_a, col_b)
And 不存在任何兩條橫槓使得 |col_a - col_b| <= 1(即不衝突)
@AC-PRNG-003 @P0
Scenario Outline: 多種 N 值下同一 row 均無重疊橫槓
Given seedSource 為 "test-seed-abc123",N=<N>
When 呼叫 generateLadder 生成 LadderData
Then 所有 row 中均不存在重疊橫槓
Examples:
| N |
| 2 |
| 5 |
| 20 |
| 50 |
# ---------------------------------------------------------------------------
# rowCount 精確符合 clamp(N*3, 20, 60)
# ---------------------------------------------------------------------------
@AC-PRNG-004 @P0
Scenario Outline: rowCount 精確符合 clamp(N*3, 20, 60) 公式
Given seedSource 為任意有效字串,N=<N>
When 呼叫 generateLadder 生成 LadderData
Then LadderData.rowCount 恰好等於 <expected_rowCount>
Examples:
| N | expected_rowCount | 說明 |
| 2 | 20 | clamp(6,20,60)=20 |
| 3 | 20 | clamp(9,20,60)=20 |
| 10 | 30 | clamp(30,20,60)=30 |
| 20 | 60 | clamp(60,20,60)=60 |
| 21 | 60 | clamp(63,20,60)=60 |
| 50 | 60 | clamp(150,20,60)=60 |
# ---------------------------------------------------------------------------
# 結果雙射(Bijection)— N 個起點對應 N 個唯一終點
# ---------------------------------------------------------------------------
@AC-PRNG-005 @P0
Scenario: 結果雙射:N 個玩家對應 N 個唯一 endCol 值
Given seedSource 為 "550e8400-e29b-41d4-a716-446655440000",N=8,winnerCount=3
When 呼叫 generateLadder 後再呼叫 computeResults 生成 ResultSlot[]
Then ResultSlot 陣列長度恰好為 8
And 所有 ResultSlot 的 endCol 值兩兩不相同(無重複,完整覆蓋 0~7)
@AC-PRNG-006 @P0
Scenario: 結果雙射:isWinner=true 的數量恰好等於 winnerCount
Given seedSource 為 "550e8400-e29b-41d4-a716-446655440000",N=8,winnerCount=3
When 呼叫 generateLadder 後再呼叫 computeResults 生成 ResultSlot[]
Then ResultSlot 中 isWinner=true 的數量恰好為 3
And ResultSlot 中 isWinner=false 的數量恰好為 5
@AC-PRNG-007 @P0
Scenario Outline: 多種 N 和 winnerCount 組合均滿足雙射特性
Given seedSource 為 "deterministic-seed-xyz",N=<N>,winnerCount=<W>
When 呼叫 generateLadder 後再呼叫 computeResults
Then ResultSlot 陣列長度為 <N>
And 所有 endCol 值唯一(無重複)
And isWinner=true 的數量等於 <W>
Examples:
| N | W |
| 2 | 1 |
| 5 | 2 |
| 10 | 3 |
| 50 | 10 |
reconnect.feature
# features/reconnect.feature
Feature: 斷線與重連行為
As a 玩家
I want to 在斷線後能重新連上房間並恢復完整狀態
So that 不因網路波動而遺失參與資格,並了解跨裝置登入時的 Session 替換行為
Background:
Given 房間碼 "EPS6" 已存在
# ---------------------------------------------------------------------------
# AC-P05-1 — 玩家帶 playerId 重連,取得完整房間狀態
# ---------------------------------------------------------------------------
@AC-RECONNECT-001 @P1
Scenario: 玩家斷線後以 localStorage 中的 playerId 重連並取得完整房間狀態
Given 玩家 "Alice" 的 localStorage 中存有 playerId="player-alice-uuid"
And 房間碼 "EPS6" 狀態為 "waiting"
When "Alice" 重新訪問房間連結並以 playerId="player-alice-uuid" 建立 WebSocket 連線
Then 伺服器驗證 playerId 後發送 ROOM_STATE_FULL 給 "Alice"
And ROOM_STATE_FULL payload 包含完整房間狀態(status、players、ladder、results、selfPlayerId)
And 重連完成時間在 3 秒以內
# ---------------------------------------------------------------------------
# AC-P05-2 — 重連時房間已在 revealing 狀態,直接呈現靜態結果,不重播動畫
# ---------------------------------------------------------------------------
@AC-RECONNECT-002 @P1
Scenario: 玩家重連時房間已在揭曉進行中,顯示靜態結果不重播動畫
Given 玩家 "Bob" 的 localStorage 中存有 playerId="player-bob-uuid"
And 房間碼 "EPS6" 狀態為 "revealing",已揭曉 3 條路徑(revealedCount=3)
When "Bob" 重新連線至房間
Then 伺服器發送 ROOM_STATE_FULL,其中包含已揭曉的 3 筆 ResultSlot 資料
And 前端直接渲染已揭曉路徑的靜態結果,不重播已完成的動畫
# ---------------------------------------------------------------------------
# AC-P05-3 — Ghost Player(localStorage 遺失)視為新玩家
# ---------------------------------------------------------------------------
@AC-RECONNECT-003 @P1
Scenario: localStorage 中無 playerId 的 Ghost Player 被視為新玩家處理
Given 玩家的瀏覽器 localStorage 中不存在任何 playerId
And 房間碼 "EPS6" 狀態為 "waiting",房間內已有暱稱 "Carol" 的玩家
When 玩家嘗試以暱稱 "Carol" 加入房間
Then 伺服器視為新玩家,但因暱稱重複回傳錯誤碼 "NICKNAME_TAKEN"
And 提示「此暱稱已被使用,請換一個」
@AC-RECONNECT-004 @P1
Scenario: Ghost Player 使用全新暱稱成功加入房間
Given 玩家的瀏覽器 localStorage 中不存在任何 playerId
And 房間碼 "EPS6" 狀態為 "waiting"
When 玩家以全新暱稱 "NewComer" 加入房間
Then 伺服器視為新玩家,HTTP 回應狀態碼為 201
And 回應包含新的 playerId(UUID v4 格式)
# ---------------------------------------------------------------------------
# FR-07-3 — 同一 playerId 從新裝置連線:舊 session 收到 SESSION_REPLACED,新裝置取得 ROOM_STATE_FULL
# ---------------------------------------------------------------------------
@AC-RECONNECT-005 @P1
Scenario: 同一 playerId 在新裝置登入時舊 session 收到 SESSION_REPLACED
Given 玩家 "Dave"(playerId="player-dave-uuid")已在裝置 A 建立 WebSocket 連線(session-A)
And 房間碼 "EPS6" 狀態為 "waiting"
When "Dave" 在裝置 B 以相同 playerId="player-dave-uuid" 建立新的 WebSocket 連線
Then 伺服器更新 "Dave" 的 isOnline=true 關聯至裝置 B 的連線
And 裝置 A 的 session-A 收到 SESSION_REPLACED 事件
And 裝置 A 的 WebSocket 連線被關閉
And 裝置 B 收到 ROOM_STATE_FULL 事件(含完整房間狀態及 selfPlayerId)
# ---------------------------------------------------------------------------
# AC-RECONNECT-006 — 舊 session 在新裝置登入時收到 SESSION_REPLACED 通知
# ---------------------------------------------------------------------------
@AC-RECONNECT-006 @P1
Scenario: 舊 session 在收到 SESSION_REPLACED 後連線關閉
Given 玩家 "Eve"(playerId="player-eve-uuid")已在裝置 X 建立 WebSocket 連線(session-X)
And 房間碼 "EPS6" 狀態為 "waiting"
When "Eve" 在裝置 Y 以相同 playerId="player-eve-uuid" 建立新 WebSocket 連線
Then 裝置 X 的 session-X 收到 SESSION_REPLACED 事件,其 payload 包含 replacedAt 時間戳
And 裝置 X 的 WebSocket 連線以 close code 4001 關閉
# ---------------------------------------------------------------------------
# AC-RECONNECT-007 — 重連後玩家收到包含完整狀態的 ROOM_STATE_FULL
# ---------------------------------------------------------------------------
@AC-RECONNECT-007 @P1
Scenario: 重連後玩家收到包含完整狀態的 ROOM_STATE_FULL
Given 玩家 "Frank"(playerId="player-frank-uuid")已成功加入房間 "EPS6"
And 玩家 "Frank" 的 WebSocket 連線中斷
When "Frank" 以相同 playerId="player-frank-uuid" 重新建立 WebSocket 連線
Then 伺服器發送 ROOM_STATE_FULL 給 "Frank"
And ROOM_STATE_FULL payload 包含完整房間狀態(status、players、ladder、results、selfPlayerId)
And "Frank" 在 players 清單中的 isOnline 為 true
# ---------------------------------------------------------------------------
# AC-RECONNECT-008 — 玩家斷線時 isOnline 變為 false,重連時恢復 true
# ---------------------------------------------------------------------------
@AC-RECONNECT-008 @P1
Scenario: 玩家斷線後 isOnline 變為 false,重連後恢復 true
Given 玩家 "Grace"(playerId="player-grace-uuid")已連線至房間 "EPS6"
When "Grace" 的 WebSocket 連線意外中斷
Then 伺服器廣播 ROOM_STATE,其中 "Grace" 的 isOnline 為 false
When "Grace" 重新建立 WebSocket 連線
Then 伺服器廣播 ROOM_STATE,其中 "Grace" 的 isOnline 為 true
# ---------------------------------------------------------------------------
# AC-RECONNECT-009 — 主持人斷線後房間繼續,進入 grace period
# ---------------------------------------------------------------------------
@AC-RECONNECT-009 @P1
Scenario: 主持人斷線後房間繼續並等待主持人重連(grace period)
Given 主持人(playerId="host-uuid")已連線至房間 "EPS6",房間狀態為 "running"
When 主持人的 WebSocket 連線意外中斷
Then 伺服器廣播 ROOM_STATE,主持人的 isOnline 為 false
And 房間狀態維持 "running"(不中止遊戲)
And 其他玩家收到提示「主持人暫時離線,等待重連中」
When 主持人在 grace period 內重新建立 WebSocket 連線
Then 主持人收到 ROOM_STATE_FULL(含完整遊戲狀態)
And 房間狀態維持 "running",遊戲繼續
# ---------------------------------------------------------------------------
# AC-RECONNECT-010 — 斷線重連後玩家資料保持不變
# ---------------------------------------------------------------------------
@AC-RECONNECT-010 @P1
Scenario: 斷線重連後玩家暱稱與 colorIndex 等資料保持不變
Given 玩家 "Henry"(playerId="player-henry-uuid",nickname="Henry",colorIndex=3)已連線至房間 "EPS6"
When "Henry" 的 WebSocket 連線中斷後重新連線
Then ROOM_STATE_FULL 中 "Henry" 的 nickname 仍為 "Henry"
And "Henry" 的 colorIndex 仍為 3
And "Henry" 的 playerId 仍為 "player-henry-uuid"
reveal-flow.feature
# features/reveal-flow.feature
Feature: 路徑揭曉流程
As a 主持人
I want to 手動逐步、自動定時或一鍵揭曉所有玩家的路徑
So that 能配合現場節奏製造懸念,或在時間緊迫時快速完成抽獎
Background:
Given 房間碼 "GAMMA4" 已存在且狀態為 "revealing"
And 房間有 4 名玩家(N=4),winnerCount=1
And 主持人已透過 hostToken 驗證身份
And 尚有未揭曉的路徑
# ---------------------------------------------------------------------------
# AC-H04-1 — 手動模式:REVEAL_NEXT → 廣播 REVEAL_INDEX 給所有客戶端
# ---------------------------------------------------------------------------
@AC-REVEAL-001 @P0
Scenario: 主持人點擊「下一位」後所有客戶端同步播放揭曉動畫
Given 目前已揭曉 0 條路徑(revealedCount=0)
When 主持人送出 WS 訊息 REVEAL_NEXT
Then 伺服器廣播 REVEAL_INDEX 事件給房間內所有連線客戶端
And REVEAL_INDEX payload 包含 playerIndex、result 及 revealedCount=1
# ---------------------------------------------------------------------------
# AC-H04-2 — 所有路徑已揭曉後再點擊「下一位」,系統不回應且按鈕 disabled
# ---------------------------------------------------------------------------
@AC-REVEAL-002 @P0
Scenario: 所有路徑揭曉完畢後主持人再點擊「下一位」系統不回應
Given 所有 4 條路徑已全數揭曉(revealedCount=4)
When 主持人送出 WS 訊息 REVEAL_NEXT
Then 伺服器不廣播任何 REVEAL_INDEX 事件
And 伺服器回傳 ERROR 事件,code 為 "INVALID_STATE"
And 前端「下一位」按鈕保持 disabled 狀態
# ---------------------------------------------------------------------------
# AC-H05-1 — 自動模式:每隔 T 秒廣播下一個 REVEAL_INDEX
# ---------------------------------------------------------------------------
@AC-REVEAL-003 @P1
Scenario: 主持人設定自動揭曉間隔後系統定時廣播
Given 目前已揭曉 0 條路徑
When 主持人送出 SET_REVEAL_MODE(mode="auto",intervalSec=3)
Then 每隔 3 秒伺服器自動廣播下一個 REVEAL_INDEX
And 重複直到所有 4 條路徑全部揭曉(revealedCount=4)
# ---------------------------------------------------------------------------
# AC-H05-2 — 自動模式進行中切換回手動模式,計時停止
# ---------------------------------------------------------------------------
@AC-REVEAL-004 @P1
Scenario: 自動揭曉進行中切換回手動模式後計時停止
Given 自動揭曉模式進行中(intervalSec=5),已揭曉 2 條路徑
When 主持人送出 SET_REVEAL_MODE(mode="manual")
Then 伺服器停止自動計時,不再自動廣播 REVEAL_INDEX
And 已揭曉的 2 條路徑結果不受影響
And 後續揭曉需由主持人手動觸發 REVEAL_NEXT
# ---------------------------------------------------------------------------
# AC-H06-1 — 一鍵全揭:REVEAL_ALL_TRIGGER → 廣播 REVEAL_ALL
# ---------------------------------------------------------------------------
@AC-REVEAL-005 @P0
Scenario: 主持人點擊「全部揭曉」後伺服器廣播 REVEAL_ALL
Given 目前已揭曉 1 條路徑,剩餘 3 條未揭曉
When 主持人送出 WS 訊息 REVEAL_ALL_TRIGGER
Then 伺服器廣播 REVEAL_ALL 事件給所有客戶端
And REVEAL_ALL payload 包含所有剩餘 3 條路徑的完整 ResultSlot 資料
And 所有客戶端於 2 秒內完成剩餘路徑動畫渲染
# ---------------------------------------------------------------------------
# AC-H06-2 — REVEAL_ALL 廣播後狀態自動轉為 finished
# ---------------------------------------------------------------------------
@AC-REVEAL-006 @P0
Scenario: REVEAL_ALL 廣播後房間狀態自動轉為 finished 並顯示完整得獎名單
Given 主持人已觸發 REVEAL_ALL_TRIGGER
And 伺服器已廣播 REVEAL_ALL
When 所有客戶端完成動畫播放
Then 伺服器廣播 ROOM_STATE,status 為 "finished"
And 所有客戶端顯示完整得獎名單
# ---------------------------------------------------------------------------
# AC-H05 — auto-reveal intervalSec 邊界值驗證(T=1 valid, T=30 valid, T=0 reject, T=31 reject)
# ---------------------------------------------------------------------------
@AC-REVEAL-AUTO-BOUNDARY @P1
Scenario Outline: 自動揭曉間隔 T 邊界值驗證
When 主持人送出 SET_REVEAL_MODE(mode="auto",intervalSec=<T>)
Then <expected_result>
Examples:
| T | expected_result |
| 1 | 伺服器接受並開始每隔 1 秒廣播 REVEAL_INDEX(合法下限) |
| 30 | 伺服器接受並開始每隔 30 秒廣播 REVEAL_INDEX(合法上限) |
| 0 | 伺服器回傳錯誤碼 "INVALID_AUTO_REVEAL_INTERVAL"(T=0 非法)|
| 31 | 伺服器回傳錯誤碼 "INVALID_AUTO_REVEAL_INTERVAL"(T=31 非法)|
# ---------------------------------------------------------------------------
# AC-BEGIN-REVEAL-001 — BEGIN_REVEAL:running → revealing,廣播 ROOM_STATE
# ---------------------------------------------------------------------------
@AC-BEGIN-REVEAL-001 @P0
Scenario: 主持人在 running 狀態觸發 BEGIN_REVEAL 後房間進入 revealing 狀態
Given 房間碼 "GAMMA4" 存在且狀態為 "running"
And 主持人已透過 hostToken 驗證身份
When 主持人送出 WS 訊息 BEGIN_REVEAL
Then 伺服器廣播 ROOM_STATE 給所有客戶端
And ROOM_STATE payload 中 status 為 "revealing"
And ROOM_STATE payload 中 revealedCount 為 0
And 所有客戶端進入揭曉等待介面
# ---------------------------------------------------------------------------
# AC-BEGIN-REVEAL-002 — BEGIN_REVEAL 在非 running 狀態時被拒絕
# ---------------------------------------------------------------------------
@AC-BEGIN-REVEAL-002 @P0
Scenario Outline: 非 running 狀態送出 BEGIN_REVEAL 時收到 INVALID_STATE 錯誤
Given 房間碼 "GAMMA4" 存在且狀態為 "<room_status>"
And 主持人已透過 hostToken 驗證身份
When 主持人送出 WS 訊息 BEGIN_REVEAL
Then 伺服器回傳 ERROR 事件,code 為 "INVALID_STATE"
And 房間狀態維持 "<room_status>"
Examples:
| room_status |
| waiting |
| revealing |
| finished |
# ---------------------------------------------------------------------------
# AC-END-GAME-001 — END_GAME:全部路徑已揭曉後主持人觸發,狀態轉為 finished
# ---------------------------------------------------------------------------
@AC-END-GAME-001 @P0
Scenario: 所有路徑揭曉後主持人送出 END_GAME 使房間進入 finished 狀態
Given 房間碼 "GAMMA4" 存在且狀態為 "revealing"
And 主持人已透過 hostToken 驗證身份
And 所有 4 條路徑已全數揭曉(revealedCount=4)
When 主持人送出 WS 訊息 END_GAME
Then 伺服器廣播 ROOM_STATE 給所有客戶端
And ROOM_STATE payload 中 status 為 "finished"
And 所有客戶端顯示完整得獎名單
# ---------------------------------------------------------------------------
# AC-END-GAME-002 — END_GAME 在路徑尚未全部揭曉時被拒絕
# ---------------------------------------------------------------------------
@AC-END-GAME-002 @P0
Scenario: 尚有未揭曉路徑時送出 END_GAME 收到 REVEALS_INCOMPLETE 錯誤
Given 房間碼 "GAMMA4" 存在且狀態為 "revealing"
And 主持人已透過 hostToken 驗證身份
And 目前已揭曉 2 條路徑,剩餘 2 條未揭曉(revealedCount=2,totalCount=4)
When 主持人送出 WS 訊息 END_GAME
Then 伺服器回傳 ERROR 事件,code 為 "REVEALS_INCOMPLETE"
And 錯誤訊息包含「尚有未揭曉的路徑,請先完成揭曉」
And 房間狀態維持 "revealing"
# ---------------------------------------------------------------------------
# AC-END-GAME-003 — END_GAME 在非 revealing 狀態時被拒絕
# ---------------------------------------------------------------------------
@AC-END-GAME-003 @P0
Scenario Outline: 非 revealing 狀態送出 END_GAME 時收到 INVALID_STATE 錯誤
Given 房間碼 "GAMMA4" 存在且狀態為 "<room_status>"
And 主持人已透過 hostToken 驗證身份
When 主持人送出 WS 訊息 END_GAME
Then 伺服器回傳 ERROR 事件,code 為 "INVALID_STATE"
And 房間狀態維持 "<room_status>"
Examples:
| room_status |
| waiting |
| running |
| finished |
# ---------------------------------------------------------------------------
# AC-END-GAME-004 — REVEAL_ALL 後系統自動觸發 END_GAME(無需主持人手動)
# ---------------------------------------------------------------------------
@AC-END-GAME-004 @P0
Scenario: REVEAL_ALL_TRIGGER 後伺服器自動完成揭曉並廣播 finished 狀態
Given 房間碼 "GAMMA4" 存在且狀態為 "revealing"
And 目前已揭曉 1 條路徑,剩餘 3 條未揭曉
When 主持人送出 WS 訊息 REVEAL_ALL_TRIGGER
Then 伺服器廣播 REVEAL_ALL 事件,payload 包含所有剩餘 3 條路徑的完整 ResultSlot 資料
And 伺服器隨後廣播 ROOM_STATE,status 為 "finished"
And 所有客戶端顯示完整得獎名單,無需主持人再送出 END_GAME
room-lifecycle.feature
# features/room-lifecycle.feature
Feature: 房間生命週期
As a 主持人
I want to 建立並管理一個帶有唯一房間碼的房間
So that 玩家能快速找到並加入我的抽獎活動
# ---------------------------------------------------------------------------
# AC-H01-1 — 建立房間:回傳合法 6 碼 Room Code
# ---------------------------------------------------------------------------
@AC-ROOM-001 @P0
Scenario: 成功建立房間並取得合法 6 碼 Room Code
Given 主持人送出 POST /api/v1/rooms 請求(含 hostNickname 及 winnerCount)
When 系統處理請求成功
Then HTTP 回應狀態碼為 201
And 回應包含 6 碼 roomCode,字元集為大寫英數字(排除 O、0、I、1),符合正規表達式 [A-HJ-NP-Z2-9]{6}
And Redis 中存在以 "room:{roomCode}" 為 key 的資料
And 回應時間在 2 秒以內
# ---------------------------------------------------------------------------
# AC-H01-2 — 建立成功後主持人進入等待畫面
# ---------------------------------------------------------------------------
@AC-ROOM-002 @P0
Scenario: 主持人建立房間後進入等待畫面
Given 主持人已成功建立房間,取得 roomCode 及 hostToken
When 主持人透過 WebSocket 連線至房間
Then 主持人收到 ROOM_STATE_FULL 事件
And 事件 payload 中 status 為 "waiting"
And 玩家列表為空(不含主持人以外的玩家)
And 頁面包含設定中獎名額的輸入框
# ---------------------------------------------------------------------------
# AC-H01-3 — 建立房間失敗時不產生孤立房間
# ---------------------------------------------------------------------------
@AC-ROOM-003 @P0
Scenario: 建立房間因網路異常失敗時不產生孤立房間
Given 後端模擬回應 HTTP 500 網路異常
When 主持人送出 POST /api/v1/rooms 請求
Then HTTP 回應狀態碼為 5xx
And Redis 中不存在任何對應此請求的 "room:{*}" 孤立 key
And 前端顯示「建立失敗,請重試」提示
# ---------------------------------------------------------------------------
# FR-01-2 — Room Code TTL 與碰撞重試
# ---------------------------------------------------------------------------
@AC-ROOM-004 @P0
Scenario: Room Code 在 Redis 中設有 TTL 且碰撞時最多重試 10 次
Given 系統正常運作中
When 主持人成功建立房間
Then Redis 中 "room:{roomCode}" 的 TTL 大於 0 且不超過 14400 秒(4 小時)
@AC-ROOM-005 @P0
Scenario Outline: Room Code 碰撞時系統重試並最終成功
Given Redis 中已存在 <collision_count> 個佔用的 Room Code
When 主持人送出建立房間請求
Then 系統在重試不超過 10 次後成功回傳唯一 roomCode
And HTTP 回應狀態碼為 201
Examples:
| collision_count |
| 1 |
| 5 |
| 9 |
# ---------------------------------------------------------------------------
# AC-H01-4 — Room Code 碰撞超過 10 次 → ROOM_CODE_GENERATION_FAILED
# ---------------------------------------------------------------------------
@AC-ROOM-006 @P0
Scenario: Room Code 碰撞超過 10 次後系統回傳 ROOM_CODE_GENERATION_FAILED
Given Redis 中已存在超過 10 個碰撞的 Room Code,導致每次嘗試均重複
When 主持人送出建立房間請求
Then HTTP 回應狀態碼為 500
And 回應錯誤碼為 "ROOM_CODE_GENERATION_FAILED"
And 前端顯示「建立失敗,請重試」提示
And Redis 中不存在任何孤立房間 key
# ---------------------------------------------------------------------------
# AC-H02-1 — waiting 狀態下 Host 可更新中獎名額(UPDATE_WINNER_COUNT)
# ---------------------------------------------------------------------------
@AC-WIN-001 @P0
Scenario: 主持人在 waiting 狀態成功更新中獎名額
Given 房間碼 "ALPHA2" 存在且狀態為 "waiting"
And 房間有 5 名玩家(N=5)且 winnerCount 尚未設定
When 主持人送出 UPDATE_WINNER_COUNT(winnerCount=2)
Then 伺服器接受並廣播 ROOM_STATE 給所有在線玩家
And ROOM_STATE payload 中 winnerCount 為 2
@AC-WIN-002 @P0
Scenario Outline: 主持人設定非法中獎名額時收到 INVALID_PRIZES_COUNT
Given 房間碼 "ALPHA2" 存在且狀態為 "waiting"
And 房間有 <N> 名玩家(N=<N>)
When 主持人送出 UPDATE_WINNER_COUNT(winnerCount=<W>)
Then 伺服器回傳錯誤碼 "INVALID_PRIZES_COUNT"
And 房間 winnerCount 維持不變
Examples:
| N | W | 說明 |
| 5 | 0 | W=0,低於下限 |
| 5 | 5 | W=N,等於上限違規 |
@AC-WIN-003 @P0
Scenario: 主持人在非 waiting 狀態更新中獎名額時被拒絕
Given 房間碼 "ALPHA2" 存在且狀態為 "running"
When 主持人送出 UPDATE_WINNER_COUNT(winnerCount=1)
Then 伺服器回傳錯誤碼 "UPDATE_WINNER_COUNT_NOT_ALLOWED_IN_STATE"
And 房間狀態維持 "running"
room-management.feature
Feature: Room Management
As a player
I want to create and join rooms
So that I can participate in lottery activities
# ---------------------------------------------------------------------------
# AC-H01-1 — Create a new room successfully
# ---------------------------------------------------------------------------
Scenario: Create a new room with valid host nickname
# AC-H01-1
Given I provide a valid nickname "Alice"
When I POST to /api/rooms with { hostNickname: "Alice", winnerCount: 1 }
Then response status is 201
And response contains roomCode, playerId, and token
And roomCode matches pattern [A-HJ-NP-Z2-9]{6}
And the room exists in Redis with TTL up to 14400 seconds
# ---------------------------------------------------------------------------
# AC-H01-2 — Host enters waiting lobby after room creation
# ---------------------------------------------------------------------------
Scenario: Host enters waiting lobby after room creation
# AC-H01-2
Given I have successfully created a room and received roomCode and token
When I connect WebSocket to /ws with the host token
Then I receive a ROOM_STATE_FULL event
And the payload status is "waiting"
And the player list contains only the host
And the page includes a winner count input field
# ---------------------------------------------------------------------------
# AC-H01-3 — Room creation failure does not produce orphan rooms
# ---------------------------------------------------------------------------
Scenario: Room creation failure does not produce orphan rooms
# AC-H01-3
Given the backend is configured to simulate HTTP 500 error
When I POST to /api/rooms with { hostNickname: "Alice", winnerCount: 1 }
Then response status is 500
And no orphan "room:{*}" key exists in Redis
And the error message indicates creation failure
# ---------------------------------------------------------------------------
# AC-H01-4 — Room code collision exceeds 10 retries returns error
# ---------------------------------------------------------------------------
Scenario: Room code collision exceeds 10 retries returns ROOM_CODE_GENERATION_FAILED
# AC-H01-4
Given Redis already contains more than 10 colliding Room Codes
When I POST to /api/rooms with { hostNickname: "Alice", winnerCount: 1 }
Then response status is 500
And response error code is "ROOM_CODE_GENERATION_FAILED"
# ---------------------------------------------------------------------------
# AC-P01-1 — Player joins existing room via WebSocket
# ---------------------------------------------------------------------------
Scenario: Join existing room via WebSocket
# AC-P01-1, AC-P02-1
Given a room with code "ABC123" exists and status is "waiting"
When I POST to /api/rooms/ABC123/players with { nickname: "Bob" }
Then response status is 201
And response contains playerId and token
When I connect WebSocket to /ws?room=ABC123 with the player token
Then I send { type: "JOIN_ROOM", nickname: "Bob" }
And all players receive ROOM_UPDATE message
And the player list includes "Bob"
# ---------------------------------------------------------------------------
# AC-P01-7 — Room not found returns 404
# ---------------------------------------------------------------------------
Scenario: Room not found
# AC-P01-7
When I GET /api/rooms/INVALID
Then response status is 404
And response error code is "ROOM_NOT_FOUND"
# ---------------------------------------------------------------------------
# AC-P01-4 — Room is full returns ROOM_FULL error
# ---------------------------------------------------------------------------
Scenario: Room is full when 50 players already joined
# AC-P01-4
Given a room with code "ABC123" exists and already has 50 players
When I POST to /api/rooms/ABC123/players with { nickname: "LateComer" }
Then response status is 409
And response error code is "ROOM_FULL"
And the message indicates the room is full
# ---------------------------------------------------------------------------
# AC-P01-2 — Duplicate nickname returns NICKNAME_TAKEN error
# ---------------------------------------------------------------------------
Scenario: Duplicate nickname in the same room returns NICKNAME_TAKEN
# AC-P01-2
Given a room with code "ABC123" exists and has a player with nickname "Bob"
When I POST to /api/rooms/ABC123/players with { nickname: "Bob" }
Then response status is 409
And response error code is "NICKNAME_TAKEN"
And the message says "此暱稱已被使用,請換一個"
# ---------------------------------------------------------------------------
# AC-P01-8 — Cannot join room that has already started
# ---------------------------------------------------------------------------
Scenario: Cannot join a room that has already started
# AC-P01-8
Given a room with code "ABC123" exists and status is "running"
When I POST to /api/rooms/ABC123/players with { nickname: "LateComer" }
Then response status is 409
And response error code is "ROOM_NOT_ACCEPTING"
And the message indicates the game has already started
websocket-protocol.feature
Feature: WebSocket Protocol
As a player or host
I want reliable WebSocket communication with the server
So that I can participate in real-time room events without losing state
# ---------------------------------------------------------------------------
# FR-03-1, FR-03-2 — Connection and join flow
# ---------------------------------------------------------------------------
Scenario: Player connects and receives initial room state
# FR-03-1, AC-P01-1
Given a room with code "WS0001" exists and status is "waiting"
And a player has obtained a token via POST /api/rooms/WS0001/players
When the player connects WebSocket to /ws?room=WS0001&token={token}
Then the server sends ROOM_STATE_FULL unicast to the player
And ROOM_STATE_FULL payload contains status, players, selfPlayerId, ladder (null), and results (null)
And the WebSocket connection is established within 1.5 seconds
Scenario: Host connects and receives host-specific room state
# FR-03-1, AC-H01-2
Given a host has created a room and obtained a host token
When the host connects WebSocket to /ws?room={roomCode}&token={hostToken}
Then the server sends ROOM_STATE_FULL unicast to the host
And ROOM_STATE_FULL payload status is "waiting"
And the host can issue host-only messages (START_GAME, BEGIN_REVEAL, etc.)
# ---------------------------------------------------------------------------
# FR-03-3 — WebSocket message envelope format validation
# ---------------------------------------------------------------------------
Scenario: Server sends messages in correct envelope format
# FR-03-3
Given a player is connected to a room
When any server event is broadcast
Then the message JSON contains "type", "ts", and "payload" fields
And "ts" is a Unix milliseconds timestamp
Scenario: Client sends messages in correct envelope format
# FR-03-3
Given a host is connected to a room in "waiting" status
When the host sends { type: "START_GAME", ts: 1745050000000, payload: {} }
Then the server processes the message without error
And the server responds or broadcasts within 1 second
# ---------------------------------------------------------------------------
# FR-03-4 — Error events include machine-readable code and human-readable message
# ---------------------------------------------------------------------------
Scenario: Error events include code and message fields
# FR-03-4
Given a player (not host) is connected to a room
When the player sends a host-only message { type: "START_GAME", ts: 1745050000000, payload: {} }
Then the server sends ERROR unicast to the player
And the ERROR payload contains "code" field (e.g. "PLAYER_NOT_HOST")
And the ERROR payload contains "message" field with human-readable description
And the WebSocket connection remains open
# ---------------------------------------------------------------------------
# FR-03-5 — WebSocket message size limit 64KB
# ---------------------------------------------------------------------------
Scenario: Server rejects oversized WebSocket messages
# FR-03-5
Given a player is connected to a room
When the player sends a WebSocket message exceeding 64KB
Then the server rejects the message and closes the connection
# ---------------------------------------------------------------------------
# AC-P05-2, FR-10-4 — Disconnect handling: player offline status broadcast
# ---------------------------------------------------------------------------
Scenario: Disconnect handling updates player online status
# AC-P02-2, FR-10-1
Given a room "WS0001" has players "Alice" and "Bob" both online
When "Bob"'s WebSocket connection is closed (TCP close or ping timeout)
Then the server detects the disconnect within 30 seconds
And the server broadcasts ROOM_STATE to all remaining players
And ROOM_STATE payload shows "Bob" with isOnline=false
And "Bob"'s slot and path are preserved (not removed from player list)
# ---------------------------------------------------------------------------
# FR-10-3 — Session replacement when same playerId reconnects from new device
# ---------------------------------------------------------------------------
Scenario: Session is replaced when same playerId connects from a new device
# FR-10-3, AC-RECONNECT-005
Given player "Dave" (playerId="player-dave-uuid") is connected via device A
And room "WS0001" status is "waiting"
When "Dave" connects from device B using the same playerId
Then device A receives SESSION_REPLACED unicast event
And device A's WebSocket connection is closed
And device B receives ROOM_STATE_FULL with complete room state
And "Dave"'s isOnline is updated to true for device B's connection
# ---------------------------------------------------------------------------
# AC-P05-1 — Reconnect restores complete state
# ---------------------------------------------------------------------------
Scenario: Reconnect restores player state with full room snapshot
# AC-P05-1, FR-10-1
Given player "Carol" (playerId="player-carol-uuid") has disconnected from room "WS0001"
And "Carol"'s localStorage contains the playerId
When "Carol" reconnects using the same token
Then the server sends ROOM_STATE_FULL unicast to "Carol"
And ROOM_STATE_FULL contains complete room state (status, players, ladder, results, selfPlayerId)
And "Carol"'s isOnline is restored to true in the broadcast ROOM_STATE
And reconnect completes within 3 seconds
Scenario: Reconnect during revealing state returns static snapshot without replaying animations
# AC-P05-2, FR-10-4
Given room "WS0001" is in "revealing" status with revealedCount=3
And player "Eve" has disconnected during revealing
When "Eve" reconnects to the room
Then the server sends ROOM_STATE_FULL with revealedCount=3 and current results
And the client renders already-revealed paths as static results
And no animations are replayed for previously revealed paths
# ---------------------------------------------------------------------------
# FR-03-4 — Heartbeat: server sends PING every 30 seconds
# ---------------------------------------------------------------------------
Scenario: Heartbeat keeps connection alive with PING/PONG exchange
# FR-03-1 (Heartbeat)
Given a player is connected to a room
When 30 seconds pass without activity
Then the server sends a WebSocket protocol-level PING frame
And the client responds with a PONG frame
And the connection remains open
Scenario: Server closes connection when client does not respond to PING
# FR-03-1 (Heartbeat timeout)
Given a player is connected to a room
When the server sends a PING frame and the client does NOT respond within 30 seconds
Then the server closes the connection with close code 1001
# ---------------------------------------------------------------------------
# FR-03-4 — Application-level PING/PONG for RTT measurement
# ---------------------------------------------------------------------------
Scenario: Application-level PING returns PONG with echo timestamp
# FR-03-3, §3.4
Given a player is connected to a room
When the player sends { type: "PING", ts: 1745049600000, payload: {} }
Then the server responds with { type: "PONG", payload: { ts: 1745049600000 } }
And the response ts echoes the original PING ts for RTT measurement
# ---------------------------------------------------------------------------
# FR-11-1, FR-11-4 — Player kicked event flow
# ---------------------------------------------------------------------------
Scenario: Kicked player receives PLAYER_KICKED and connection closes
# AC-H07-1, FR-11-4
Given room "WS0001" is in "waiting" status
And player "Frank" (playerId="player-frank-uuid") is connected
When the host sends KICK_PLAYER targeting "player-frank-uuid"
Then the server sends PLAYER_KICKED unicast to "Frank"
And "Frank"'s WebSocket connection is closed with close code 4003
And all other players receive updated ROOM_STATE without "Frank"
Scenario: Kicked player is blocked from reconnecting with same playerId
# AC-H07-2, FR-11-2
Given player "Grace" has been kicked from room "WS0001"
When "Grace" attempts to reconnect using the same playerId
Then the server sends PLAYER_KICKED and closes connection with close code 4003
And "Grace" cannot rejoin the same game session
# ---------------------------------------------------------------------------
# §3.2 — WebSocket upgrade validation rejects unauthorized connections
# ---------------------------------------------------------------------------
Scenario: WebSocket upgrade is rejected with invalid token
# §3.2, NFR-05
Given a room with code "WS0001" exists
When a client attempts to connect WebSocket with an invalid JWT token
Then the WebSocket upgrade is rejected with HTTP 401
And no WebSocket connection is established
Scenario: WebSocket upgrade is rejected for kicked player
# §3.2, FR-11-2
Given a player has been kicked from room "WS0001" (playerId in kickedPlayerIds)
When the kicked player attempts to connect WebSocket to the room
Then the server sends PLAYER_KICKED and closes the connection with close code 4003