adventure-table

P2-D Closeout Checklist

P2-D — Seat, Controller & Lobby closeout scope。編號對應 實作規格 的「Lobby / Seat 行為」十三條,另加「Seat 概念」與「Controller」兩節的形狀要求。

Lobby / Seat 行為

Seat 概念與 Controller 形狀

Verification evidence

分支 p2-d-seat-controller-lobby,最終 code SHA 3439cbe

Branch / final code SHA
  p2-d-seat-controller-lobby @ 3439cbe

Alembic heads
  0009_p2a_character_head (character) (head)
  0013_p2d_campaign_seats (web) (head)
  0013 接在 0012_p2c_campaigns_roster 之後,只落在 web track;
  standalone SQLite 仍只升 character@head,不會長出 campaign_seats。
  本機 compose PostgreSQL 實跑 alembic upgrade heads 通過,
  alembic_version 為 0009_p2a_character_head + 0013_p2d_campaign_seats。

Backend pytest
  1052 collected / 166 files
  1041 passed, 11 skipped, 0 failed, exit 0
  cwd apps/server,直譯器 ..\..\.venv\Scripts\python.exe
  11 個 skip 全部是缺 P2_POSTGRES_URL 的 PostgreSQL-gated 測試,
  該部分改由下方 CI 的 postgres-migrations job 覆蓋。
  執行於 44e4b4b;44e4b4b..3439cbe 只改 apps/web/e2e 與
  apps/web/scripts,backend 無差異,未重跑全套。

CI P2 Non-E2E(正式 workflow evidence)
  run id 34111033698 @ 3439cbe(exact final SHA)
  https://github.com/shellpig/adventure-table/actions/runs/34111033698
  conclusion success;backend / postgres-migrations / frontend 三個 job 全綠。
  postgres-migrations 在乾淨 PostgreSQL 17 上跑
  test_p2a_postgres_migration.py + test_p2b_postgres_workspace.py,
  其中 _assert_p2d_web_schema() 驗 campaign_seats 存在、三個 FK 的
  ondelete(campaign_id CASCADE / controller_access_session_id RESTRICT /
  selected_character_id SET NULL),以及
  (campaign_id, selected_character_id) unique constraint。
  前一個 SHA b1ee7a7 的 run 34109856936 亦為 success。
  對照組:6c4ae8d 的 run 34102224030 為 failure,正是 44e4b4b 修掉的
  P2-C CampaignRepository 契約回歸。

Frontend unit
  200 passed / 41 files

TypeScript / build
  npx tsc --noEmit exit 0
  npm run build(tsc --noEmit && vite build)exit 0

Full Web Playwright
  npm run test:e2e:docker exit 0 @ 3439cbe
  第一輪 107 tests:104 passed / 0 failed / 3 skipped(5.9m)
  第二輪 xge-less M03-C 子集:7 passed(5.9s)
  執行時 compose server 已在 0013_p2d_campaign_seats,
  /api/meta/capabilities 回 seat:true,確認 E2E 是對 P2-D backend 跑的。
  本次全套無 KI-P1D-001 失敗(見下方「順帶修掉的既有問題」)。

docker compose config
  exit 0

驗收條目 → 測試對應

條目 證據
1, 6 (create gate) test_p2d_seat_policy.py::test_lobby_and_seat_creation_require_current_active_campaign::test_controller_and_character_mutations_require_current_active_campaign;瀏覽器層 p2d-lobby-seats.spec.ts::P2-D Lobby is reachable only while the Campaign is the Room current active Campaign(draft 無入口 → active 仍無入口 → select 後出現 → clear 後 server 回 409)
2, 3 test_p2d_persistence_contract.py::test_p2d_campaign_seat_schema_matches_contract(無 ready 欄位);p2d-lobby-seats.spec.ts 建 Seat 時 Roster 只有一隻角色而 Seat 有三個
4 test_p2d_seat_policy.py::test_one_human_controller_can_bind_multiple_player_seatstest_p2d_persistence_contract.py::test_p2d_controller_can_repeat_but_character_selection_is_unique_per_campaign
5 同上 unique constraint 條;test_p2d_seat_persistence_integration.py::test_selection_transaction_rejects_cross_room_roster_corruption_and_duplicate_character
6, 7 test_p2d_seat_policy.py::test_player_character_selection_enforces_eligibility_and_uniquenesstest_p2d_seat_persistence_integration.py::test_selection_transaction_rechecks_roster_status_and_character_archive
8 test_p2d_persistence_contract.py(CheckConstraint)+ p2d-lobby-seats.spec.ts 斷言 DM Seat 沒有 Active Character 選單
9 test_p2d_seat_policy.py::test_lobby_presence_uses_p2a_ninety_second_timeout(89s/91s 邊界)、test_p2d_seat_lifecycle_evidence.py::test_offline_controller_keeps_seat_binding_and_reconnect_restores_connected_presenceRoomLobbyPage.test.ts 鎖住 heartbeat 掛載與離開時的 stopHeartbeat()
10 test_p2d_seat_api_permissions.py::test_create_seat_http_permission_matrix(member/dm/owner × player/dm 六格)、::test_member_cannot_operate_another_members_player_seat_via_http::test_assigned_member_can_select_character_on_own_player_seat_via_http
11 test_p2d_seat_policy.py::test_dm_seat_assignment_is_owner_only_but_other_seat_management_allows_dmtest_p2d_seat_api_permissions.py::test_dm_cannot_reassign_dm_seat_via_httptest_p2d_seat_api_authority.py::test_owner_can_reassign_dm_seat_from_dm_a_to_dm_b_via_httptest_p2d_seat_policy.py::test_dm_controller_must_have_dm_or_owner_room_authoritytest_p2d_seat_lifecycle_evidence.py::test_dm_seat_can_be_reassigned_from_one_dm_controller_to_another
12 上述所有 HTTP 層 deny 測試皆斷言 server response code,不檢查按鈕是否隱藏
13 test_p2d_seat_lifecycle_evidence.py::test_unreferenced_seat_delete_and_archive_release_character_for_another_seat(archive 後 Seat 退出 Lobby、釋出角色、未引用的 Seat 可 hard delete);p2d-lobby-seats.spec.ts 走完 archive → 新 Seat 接手同一角色 → delete 的瀏覽器路徑
Controller 形狀 / AI 未接線 test_p2d_seat_policy.py::test_ai_controller_is_domain_shape_only_and_cannot_be_bound_in_p2::test_p2d_router_exposes_seat_and_lobby_but_no_session_surface::test_controller_must_be_an_active_access_session_from_same_room
選角收斂只發生在寫入路徑 test_p2d_seat_policy.py::test_lobby_read_does_not_reconcile_stale_selectiontest_p2d_selection_convergence.py 兩條、test_p2d_character_archive_convergence.py(走真實 POST /api/rooms/{id}/characters/{id}/archive
controller FK RESTRICT 與 Room Hard Delete test_p2d_controller_fk_restrict.py::test_human_controller_session_is_restricted_but_room_hard_delete_cleans_seat_firstPRAGMA foreign_keys=ON,真的驗到刪除被擋);PostgreSQL 層由 test_p2a_postgres_migration.py::_assert_p2d_web_schema 斷言三個 FK 的 ondelete 與 unique constraint
capability 旗標 test_p2c_capabilities.py::test_web_channel_advertises_p2d_seat_capabilitytest_m03e_capabilities.py::test_web_capabilities_enable_room_campaign_and_seat_for_p2droutes.test.ts
standalone 不長 Seat schema test_m03d_schema_parity.pycampaign_seats 已加入 FORBIDDEN_MULTIPLAYER_TABLES)、test_m03_import_boundary.pytest_p2a_room_import_boundary.py
migration track 分離 test_p2a_migration_tracks.py 三條(WEB_REVISIONS 已納入 0013_p2d_campaign_seats
前端 route / 權限 / 文案 RoomLobbyPage.test.ts 五條(含以 renderToStaticMarkup 實際 render 元件的兩條)、seats.test.tsRoomCampaignPage.test.tshardcodedUiCopy.test.tsRoomLobbyPage.tsx 已納入掃描清單)

關門過程中修正的問題

Review 提出 6 項,全數已修並重新驗證。

  1. Lobby 只檢查 campaign.status,沒檢查 rooms.active_campaign_id 開發設計方針 §8.2 明確定義 active_campaign_id 是「Room 目前 UI / Lobby 選中的 Campaign」,而 Room 可以同時有多個 active 狀態的 Campaign。原實作讓任何 active Campaign 都能開 Lobby,並留下一個從未被呼叫的 SeatRepository.active_campaign_id() — 那是被丟掉的檢查留下的痕跡。已改為 _require_current_active_campaign() 並套用到 create / controller / character / lobby 四條路徑,前端 Lobby 入口也一併加上同一條件。

  2. create_seat 未檢查 Campaign 狀態。 draft / completed / archived Campaign 都能建 Seat,只是開不了 Lobby。已與第 1 項同一個 gate 一併解決。archive_seatdelete_seat 刻意不套這個 gate,讓 Campaign 被換掉後仍能清理 Seat,並由 test_seat_cleanup_remains_available_after_campaign_leaves_lobby 鎖住。

  3. GET endpoint 會寫資料庫。 list_seats()lobby() 原本呼叫 _reconcile_selections(),把不再 eligible 的 selected_character_id 清成 null — 任何 member 呼叫 GET 都會觸發寫入,GET 不再 idempotent。已移除該路徑,改在三條真正的寫入路徑收斂:Roster status 轉 retired / dead、Roster entry 移除、Room Character archive。unarchive 刻意不還原選角。

  4. controller_access_session_idON DELETE SET NULL 與 CHECK constraint 互斥。 ck_campaign_seats_controller_binding 要求 controller_kind='human' 時該欄非 null,SET NULL 一旦真的觸發就會撞 CHECK。已改為 RESTRICT,語意變成「還有 Seat 綁著就不准刪 access session」,並確認 hard_delete_room() 的既有順序(先清 campaigns 連帶 cascade seats,再刪 rooms)仍能通過。

  5. 測試指南 §9.2 / §9.5 / §9.6 有四條沒有對應證據。 Seat hard delete、archived Seat 退出 Lobby selection、Offline 不動 Seat 與 Controller、reconnect 恢復 Connected、Owner reassign DM A → DM B。已補 test_p2d_seat_lifecycle_evidence.py 三條與 test_p2d_seat_api_authority.py 一條,全部打真實 repository 或真實 HTTP endpoint。

  6. RoomLobbyPage 只有原始碼字串斷言。 已改用 renderToStaticMarkup 實際 render 元件,涵蓋 missing-access 與已授權載入兩種狀態;原本的字串斷言保留作為 heartbeat 接線的補充。瀏覽器層另補 p2d-lobby-seats.spec.ts(見下節)。

順帶修掉的既有問題

承接 P2-C 的未結清項目

Boundary

已知限制

Handoff

P2-D 已完成並關門。下一步是 P2-E — Session Lifecycle & Late Join

P2-E 直接繼承以下 substrate,不得再造第二套: