Phase:P2 — Room / Campaign / Session / Seat
本文件定義 P2-A~P2-F 的自動/人工驗收與 closeout evidence。產品完成條件以實作規格.md為準;具體架構與 migration / API contract以開發設計方針.md為準。
最後更新:2026-09-06
三份 P2 文件固定使用:
P2-A — Room Foundation & Web Entry
P2-B — Room Character Workspace
P2-C — Campaign & Party Roster
P2-D — Seat, Controller & Lobby
P2-E — Session Lifecycle & Late Join
P2-F — Full P2 Integration & Closeout
每個 Subphase 的測試新增要跟該段 code一起完成;不要把「Room scope / permission / migration」全部留到 P2-F才第一次驗。
P2 從單機 Character 系統進入多人 namespace,最危險的 regression不只是功能壞掉,而是:
MetaData因 test import order含 Web tables,讓 Standalone schema parity變 flaky。因此 P2 的 minimum gate不是「頁面打得開」,而是:
Scope isolation
+ persistence atomicity
+ migration correctness
+ concurrency invariant
+ controller authority
+ standalone independence
+ existing Web regression continuity
+ real PostgreSQL evidence
+ real-backend UX
Windows / PowerShell 下不要使用 && 串 command。Backend Python 指令若依賴 alembic.ini / relative path,cwd在 apps/server,interpreter使用 repo root .venv。
例如從 repo root:
Set-Location .\apps\server
..\..\.venv\Scripts\python.exe -m pytest
Focused test同理:
Set-Location .\apps\server
..\..\.venv\Scripts\python.exe -m pytest tests\test_p2a_rooms.py -q
P2-A 起 PostgreSQL-specific gate使用統一 env var:
P2_POSTGRES_URL
CI的 P2 Non-E2E workflow提供乾淨 PostgreSQL service並同時設定:
P2_POSTGRES_URL=<test db url>
DATABASE_URL=$P2_POSTGRES_URL
本機可從 repo root建立 dedicated test DB;PowerShell 5.1 不使用 &&:
docker compose up -d db
docker compose exec -T db psql -U adventure -d postgres -c "DROP DATABASE IF EXISTS adventure_table_p2_test;"
docker compose exec -T db psql -U adventure -d postgres -c "CREATE DATABASE adventure_table_p2_test;"
Set-Location .\apps\server
$env:P2_POSTGRES_URL = "postgresql+psycopg://adventure:adventure@localhost:5432/adventure_table_p2_test"
$env:DATABASE_URL = $env:P2_POSTGRES_URL
..\..\.venv\Scripts\python.exe -m pytest <P2 PostgreSQL focused tests> -q
需要 clean DB語意的 focused test不得假設開發用 adventure_table可隨便 drop;一律使用 dedicated adventure_table_p2_test或 CI fresh service。
Set-Location .\apps\web
npm test -- --run
npm run build
依 KI-ENV-001,完整 Playwright一律走 Docker/Linux web service:
Set-Location .\apps\web
npm run test:e2e:docker
不要在 Windows 上改用 Playwright-managed Vite來「暫時過測試」。
P2-A 必須建立專屬:
.github/workflows/p2-non-e2e.yml
Display name: P2 Non-E2E
它是 P2 PostgreSQL migration / persistence / concurrency non-E2E evidence的正式 workflow。P2-B~E持續在同 workflow擴充各自 focused tests。
GitHub Actions只代表其 workflow實際覆蓋的 gate;不得因 M03 standalone workflow或舊 M01 workflow綠燈就宣稱 P2 backend / PostgreSQL / permission / E2E全綠。
每個 closeout要記錄:
驗:
驗:
upgrade heads。character@head。驗:
app.main Character / Builder HTTP regression在 P2-B後仍由 Web room-scoped endpoint承接,不被移去 standalone。P2 首次有真正多人 race condition,需要明確測:
不能只用 sequential API test推論 concurrency安全。
驗:
/characters Web path不洩漏 global list。驗真正 browser + FastAPI + PostgreSQL:
Room
→ Character
→ Campaign
→ Lobby
→ Session
以及多 Room隔離、reload、restart後仍成立。
Layer F 包含既有 Web Playwright regression suite,不只新增 P2 journey。 P2-A/P2-B改 route時,既有 P1/M01/M02 spec必須透過 Room-aware helper繼續跑 Web channel。
至少:
Standalone gate是獨立保護,不是既有 Web regression的替代執行環境。
Automated test直接檢 Alembic ScriptDirectory:
0008_m03c_import_records是 branch point。character branch head。web branch head。character@head ancestry不包含任何 Web / Room migration。heads包含 shared Character + web multiplayer兩支。不要只 grep檔名。
建立 migration graph fixture / structural assertion:
depends_on某個 Character revision。depends_on Web revision。upgrade heads仍 deterministic成功。upgrade character@head仍完全不執行 Web revision。Empty PostgreSQL:
alembic upgrade heads
驗:
alembic_version只一列。/ready green。此 gate至少在 P2 Non-E2E的 PostgreSQL service執行。
建立 fixture DB停在:
0008_m03c_import_records
seed:
再:
upgrade heads
驗既有 payload byte/semantic等價;P2-B再驗 Room claim workflow。
Fresh SQLite與 legacy M03 SQLite都跑:
upgrade character@head
驗:
rooms / campaigns / campaign_seats / sessions / P2 association tables不存在。P2-A 不只掃 operational command,也必須改既有 pytest、frozen helper與 standalone smoke的 single-head contract。
test_m03c_migration.pycommand.upgrade(config, "head")改成 Web heads。SELECT version_num ... scalar() / 單一 revision equality不得保留;split後應比較 Alembic revision set,並明確包含當時 character + web heads。character_import_records不被 branch split破壞。test_m03d_migration_sqlite.pycharacter@head。ScriptDirectory.get_current_head()不得再使用,因 repository已有多 head;應明確 resolve character@head revision。character@head仍要成立。test_m03d_schema_parity.pycharacter@head。開發設計方針.md §3.8使用 explicit Character table allowlist。app.persistence.rooms.tables,結果也必須相同。scripts/smoke_standalone.pyheads後要求 len(heads) == 1。alembic_version與 Character branch expected current比對;不能跟 repo-global唯一 head比。test_m03e_smoke_script.py0008_m03c_import_records 是目前唯一 head。character@head。test_m03e_alembic_bundle_ready.pyScriptDirectory.get_current_head()。character@head,再跟 frozen SQLite current比對。test_m03e_launcher_headless.pyScriptDirectory.get_current_head()。character@head。Static scan範圍至少包含:
apps/server/tests/**
scripts/**
.github/workflows/**
active dev / release helpers
判準是任何 repo-global single-head 假設,不只是字面 "head" target。至少掃:
upgrade(..., "head") / alembic upgrade head
get_current_head()
len(heads) == 1 / expected one Alembic head
自製 AST / filename 唯一-head inference
Web assertion把 alembic_version 永遠讀成單列 scalar
逐一分類成 Web heads / revision set、Standalone character@head / Character revision,或明確的歷史文件文字。歷史 closeout文件文字不算 active contract。
Parity test至少做兩次等價情境:
兩者都必須:
metadata.create_all(..., tables=CHARACTER_SCHEMA_TABLES)只建立 Character allowlist。character@head migrated SQLite一致。這直接鎖死 import-order flake。
P2-A code review / automated static test檢查 active operational paths:
docker-compose.yml Web server migration target不是 stale upgrade head。upgrade heads。character@head。heads。apps/server/tests/**與scripts/**沒有 repo-global get_current_head() / len(heads)==1 / 自製唯一-head inference 留在 active migration / standalone smoke路徑。alembic_version永遠只有一列;Standalone只比較 Character branch current。目前已知 P2-A branch split必須檢查的舊 workflow至少:
p0a-foundation.yml
m01f-non-e2e.yml
m01g-non-e2e.yml
m01h-non-e2e.yml
m01i-non-e2e.yml
m01j-non-e2e.yml
m03a-non-e2e.yml
m03b-non-e2e.yml
若這些 workflow在目前 main仍執行 migration,就不能因名稱屬舊 Phase而保留 ambiguous upgrade head。
歷史 closeout / archive文件可以保留當時的 head證據,不列為 stale operational command。
加入測試證明如果 standalone誤跑 heads,test會偵測 multiplayer tables;launcher test則鎖死實際 target為 character@head。
目的不是故意讓 production做錯,而是確保 gate能抓到未來 regression。
任何 v1 exporter / parser code修改之前,先建立並 commit:
apps/server/tests/fixtures/character_export_m03_unstable.json
產生規則:
schema_version="unstable" / schema_status="unstable"。(room_code, remote_addr) failure window。room_access_throttled。X-Forwarded-For改寫client identity(除非測試/部署明確設定 trusted proxy contract)。member。dm。owner。如果 P2-A實作 rotate:
P2-A直接交付 heartbeat endpoint,因此本 Subphase就測:
POST /api/rooms/{room_id}/access/heartbeat
last_seen_at使用server time更新。P2-D再測 last_seen_at到 Connected/Offline的 projection;不重做第二套 heartbeat。
apps/server/tests/test_m03e_capabilities.py 是 P2-A 必改 regression,不是新增 P2 test取代它。
Web:
channel=web
room=true
Standalone:
channel=standalone
room=false
campaign=false
seat=false
session=false
要求:
web.capabilities.room is False expectation改為 True。room is False。/api/rooms。P2-A Web capability:
/ 顯示 Enter Room / Create Room。Standalone capability:
/ 顯示 Character Workshop。P2-A 為避免單一 Subphase讓既有 Web Character功能失效,direct legacy /characters / Character APIs 可以暫時存在;但要有 test證明首頁不導流,也要列成 P2-B 必須清掉的 transitional surface。
P2-B closeout後再把 direct Web global route改成 room-required / redirect negative gate。
不要靠 build-time separate SPA測;同一 frontend build切 capability。
在修改 exporter前,先 assert §6.0 committed fixture:
assert:
schema_version == "1"
schema_status == "locked"
export_type == "character"
以及不含:
room_id / room_code / campaign_id / seat_id / session_id
使用§6.0真正封存的 fixture:
legacy unstable
→ normalize
→ preview
→ import
→ export
最後輸出 v1,Character semantics不變。
v1 export → import as new identity → export;除了 source identity / timestamps等預期變化,Build / State / Version chain semantics等價。
test_m03_import_boundary.py:
app.*.rooms。app.api.rooms.characters一定被抓。app.domain.rooms.sessions一定被抓。app.content.roommate不誤判。P2-A 必須先為既有 Web E2E建立共用 Room bootstrap / navigation seam;不要等 P2-B關掉 /characters才一次撞完整 suite。
建議 contract(檔名可按現有 style):
enterRoom(page, options?)
openCharacterWorkshop(page, roomContext)
驗收原則:
page.goto('/characters');先透過 enterRoom()取得 isolated Room context,再由 openCharacterWorkshop()進 Workshop。openCharacterWorkshop()可以先導向既有 /characters;因為 P2-B Room-scoped Character API尚未完成。這不是 bypass,而是讓 route transition集中在 helper。/rooms/{roomId}/characters,不逐一再改二十多個 spec。/ 的期待,改驗 Create / Enter Room與 Room-first雙語 copy;不能用 helper跳過首頁後宣稱 landing有測。skip/fixme。P2-A closeout至少要證明:完整既有 Playwright suite在新的 Room-first首頁存在後仍可執行;P2-B closeout再證明同一 suite在 global Web /characters收口後仍全程走 Room-scoped Web path。
Persistence test:
Fault-injection test:
create neutral draft
→ insert room_builder_drafts故意 fail
整個 transaction rollback;不能留下 unscoped Draft。
反向 fault也要測:association不能存在但 Draft不存在。
Room Draft Confirm:
room_characters link。全部在一 transaction。
在 link insert前注入 failure → Character / Version / State / confirmed marker全部 rollback。
Repeated Confirm:
Room A Character:
API沒有 target Room欄位可把它搬到 B。
用 Room A token + Room B resource id逐項驗:
每條都必須 fail,不得只測 GET。
P2-B後 Web app:
/api/characters 不提供 global list / mutation bypass。/api/character-builder/drafts 不提供 global list / mutation bypass。/characters 不顯示 global Workshop。Standalone對應 endpoints仍 green。
至少兩種 upgrade fixture;Primary Case 是 P2-A 已被使用、Room 已存在,zero-Room auto-claim只保留 bootstrap edge coverage。
Legacy Character Data migration card,也拿不到 global identities。Characters / Room Character Workshop,看到 owner-only migration card。Claim Legacy Character Data → confirmation modal顯示 target Room B 名稱與 Character / Draft count。Web Room A import:
draft_with_history_loss → target Room association仍正確。seed:
Hard Delete Room後 query所有 P2 association / Character core tables,沒有屬於該 Room的 orphan。
其他 Room資料完全不動。
P2-B 收掉 app.main 的 global Character routers時,既有 Web HTTP regression必須改成 Room-scoped,不改綁 standalone app來逃避 authorization / route migration。
目前至少覆蓋:
apps/server/tests/test_character_api.py
apps/server/tests/test_character_archive.py
apps/server/tests/test_character_builder_api.py
apps/server/tests/test_character_workshop_api.py
apps/server/tests/test_m02d_workshop_presentation.py
apps/server/tests/test_p1f_character_creation.py
apps/server/tests/test_p1g_legacy_adapter.py
apps/server/tests/m01k_support.py
驗收:
app.main為被測 app。m01k_support.py 的 seed_http() / rebind_http() / HTTP helper一起 Room-aware,讓所有 consumers自動承接;不能只修其中一個 M01-K test。/api/characters / /api/character-builder,應視為漏接;除非該 test明確使用 app.standalone並屬 M03 standalone regression。Table-driven:
DM / Owner:
Member:所有 Roster mutation deny。
建立:
Room A
├─ Campaign A roster → Mira
└─ Campaign B roster → Mira
在 Campaign A context修改 Mira Current HP後,在 Campaign B context讀同一 Character Sheet:看到相同 Current HP。
再改 Inventory / prepared spell / resource至少各一個代表,證明不是只碰 HP巧合。
DB assert:沒有第二份 campaign state payload。
先建立未使用 Seat:
再建立 ended Session reference該 Seat:
seat_history_referenced / deny。seat_id讀取歷史 Seat。campaign_seats.campaign_id 使用 CASCADE,但只有無 Session history的 Campaign可 hard delete;sessions.campaign_id 使用 RESTRICT,存在 Session history時 DB / service都必須阻止 Campaign hard delete。sessions.dm_seat_id與session_participants.seat_id FK都使用 restrict semantics;一般 Seat delete不能把 historical FK設 null。同一 RoomAccessSession:
Seat 1 → Mira
Seat 2 → Luna
合法。
反向:
Seat 1 → Mira + Luna
資料模型做不到/API拒絕。
建立 Owner、DM A、DM B access sessions:
dm authority;Owner自身例外因 owner authority高於 dm。這裡鎖的是Start前 assignment;Session開始後由P2-E current DM Controller snapshot鎖死。
Fake clock測 P2-A heartbeat projection:
now-last_seen_at <= 90s → Connected。不要用 sleep造成 flaky test。
至少 table-driven測:
member / dm / owner
×
room settings / campaign lifecycle / roster / player-seat / dm-seat-assignment / character workspace
鎖定:
每個 deny都測 server response,不只檢 frontend button hidden。
Seed:
dm authority Human access session assign到 DM Seat。Owner-assigned DM Seat Human controller Start:
同一 Lobby:
dm authority → allow。owner authority → allow。Start成功後固定為 current DM Controller。
Session active後:
session_active_character_locked。這是 P2 必須有的真正 concurrent PostgreSQL DB test。
兩個 transaction同時:
Session A start → Mira
Session B start → Mira
只能一個 commit成功。
失敗側:
character_already_in_active_session。至少在 P2 Non-E2E PostgreSQL service執行;不要只在 SQLite mock concurrency,也不要用 M03 workflow綠燈代替。
同一 Campaign已有 active Session時第二次 Start拒絕,即使選不同 Characters。
Ended / abandoned後才可開下一 Session。
Active Session:
Start前 snapshot一個 rich Character State:
Session內先做合法 state改動;記住 end前 exact state。
current DM Controller End Session後:
state_after_end == state_immediately_before_end
不能只 assert HP。
Leases已釋放;Session participant/history與non-null historical Seat references仍存在。
Owner或 current DM Controller abandon:
Server restart後:
P2-B/E 完成後,特別驗現有 Character State API不被 Room wrapper漏掉。
Room member可依 Room協作規則使用 Room Character Workshop / Sheet。
Mira已 lease給 Seat A / Human A:
Borin若未在 active Session:依 Room一般協作規則處理。
Versioned Builder對 active Character也做同樣 Seat/current-DM scope guard,避免一般 member、另一 DM或 Owner用 Build Edit繞過 active table控制權。
P2-B後 Web:
/ → Room landing
/characters → room-required / redirect,不是 global list
/rooms/A/characters → A Workshop
/rooms/A/characters/Mira → A Sheet
Standalone:
/ → Character landing
/characters → Workshop
/rooms/A → capability-disabled
P2-A起為 shared helper本身加 focused coverage:
enterRoom()建立/進入合法 Room並回傳該 Room context。openCharacterWorkshop()走 transitional route時仍已先建立 Room access context。/rooms/{roomId}/characters,不殘留 global /characters。從:
Room A Workshop
→ Builder
→ Review
→ Confirm
→ Sheet
→ Version History
→ Back
每一步 return link仍在 Room A。
先進 Room A,再直接 navigation Room B:cache / query key必須包含 Room scope,不可把 A Character list短暫顯示在 B。
當 legacy_unscoped_character_count / draft_count > 0:
Characters / Room Character Workshop看得到 Legacy Character Data migration card。P2新頁面兩語各跑:
assert沒有 raw translation key。
P2-F前至少有下列 browser journeys。這些是新增 P2 journeys,不取代既有 P1/M01/M02 Web specs。
Homepage
→ Create Room A
→ Room A
→ Characters
→ New Character
→ save partial Draft
→ reload browser
→ Draft still exists in A
→ complete / Confirm
→ Sheet
→ Version History
→ back to Room A Workshop
Create Room A + Mira
Create Room B + Borin
A token / A UI
→ never lists Borin
→ manually navigate B character UUID under A route
→ not found / denied
再反向 B→A。
用 locked v1 fixture或真正封存的 M03 legacy fixture:
Room B
→ Import
→ Preview
→ Confirm
→ new Character appears only in Room B
→ Export
→ v1
Room A
→ Owner creates Campaign Alpha
→ DM/Owner adds Mira + Luna
→ Mira active / Luna inactive
→ Owner selects active Campaign
Campaign Alpha
→ Owner assigns DM A to DM Seat
→ Player Seat Mira
→ Player Seat Luna
→ assigned DM A Start Session
→ Session page
→ same DM A End Session
→ Character states preserved
Start with Mira only
→ Session active
→ current DM Late Join Luna
→ Luna appears
→ reload
→ assignments remain
Campaign Alpha roster Mira
Campaign Beta roster Mira
→ change Mira state in Alpha context
→ open Beta context
→ same state
可主要由 backend PostgreSQL concurrency證明;browser至少顯示可理解的 conflict文案,不顯 raw 409 / raw code。
Room with Character / Draft / Campaign / ended Session
→ Owner Room Settings
→ strong confirmation
→ delete
→ Room code no longer enterable
E2E不需要直接查 DB;backend integration另外做 orphan assert。
test_m03_import_boundary.py。app.standalone不 import app.*.rooms。Standalone capability:
character_builder=true
character_import_export=true
room=false
campaign=false
seat=false
session=false
Character parity使用explicit allowlist;不得存在:
rooms
room_access_sessions
room_characters
room_builder_drafts
campaigns
campaign_roster_entries
campaign_seats
sessions
session_participants
active_character_session_leases
至少:
Create → Confirm → Sheet
Level Up → Confirm → Version History
Export → Import → Sheet
P2-A(migration split)、P2-B(shared Character API / persistence refactor)、P2-F一定跑 Windows standalone build / smoke。
P2-A frozen smoke必須真的通過 branch-aware scripts/smoke_standalone.py;不能只因 build產出 exe就算 standalone gate green。
P2-C/D/E若只新增 app.*.rooms而未碰 shared/standalone graph,可依 diff使用 import-boundary + existing standalone unit gate;P2-F仍完整重跑。
P2 沒有 DM secrets world data yet,但 namespace與access credential已是 security boundary。
至少:
Response不得洩漏:
Room code本身是public locator;不把「能判斷code是否存在」當秘密洩漏,但沒有正確Password仍不可取得Room data。
character@head / heads documented與test鎖定。test_m03c_migration.py已multi-head-aware;不再scalar讀單一alembic_version。test_m03d_migration_sqlite.py不再用get_current_head()假設單head。test_m03d_schema_parity.py使用Character allowlist,且import Room tables前後結果一致。scripts/smoke_standalone.py不再要求repo只有一個head;M03-E smoke/bundle/launcher tests都明確resolve character@head。docker-compose.yml / README / CI / active scripts / workflows / executable tests沒有 stale repo-global single-head假設;scan包含scripts/**且不只找字面"head"。P2 Non-E2E workflow存在並提供PostgreSQL service / P2_POSTGRES_URL。test_m03e_capabilities.py已更新成Web room=true、Standalone room=false。/ 的 app-shell / localization specs已改驗新的 Room-first landing,而不是被 helper跳過。/rooms/{roomId}/characters;既有 Web Playwright suite仍跑 Web channel。app.main Character/Builder backend tests已用共用 Room-aware client/context遷移;m01k_support.py consumers沒有漏接。campaign_seats.campaign_id明確 CASCADE;Campaign有 Session history時由sessions.campaign_id RESTRICT + lifecycle guard阻止hard delete。P2 Non-E2E有evidence。sessions.campaign_id ON DELETE RESTRICT;Session Seat FK non-null / restrict,歷史Campaign/Seat reference不破。重跑 開發設計方針.md §18 全 checklist並逐項附 evidence。
P2 人工 smoke只驗 automation難以判斷的 UX,不人工重做全部 permission matrix。
建議代表流程:
Characters 頁可看到 migration card、確認 target Room與數量;一般 member不看到此入口。zh-TW / en 各巡一次 P2主要頁面,檢查 overflow / raw key。若 human smoke發現 UX confusion但不影響資料 correctness,可在 P2-F closeout列為 polish;若容易讓使用者誤刪 Room、進錯 Room、把 legacy data claim 到錯 Room、以為 Character跨 Room共用、誤以為 DM Key可自行取得本場DM assignment、或誤以為 Owner可接管 active Session,視為 blocker。
P2-F 不只跑 test_p2*.py。
Set-Location .\apps\server
..\..\.venv\Scripts\python.exe -m pytest
要求:所有 backend tests green;已知 intentional skip需有 已知問題.md對應。
特別確認既有 Web Character / Builder HTTP tests不是藉由改綁 standalone才變綠;P2-B後它們應透過 Room-aware fixture繼續驗 app.main。
在 exact final SHA執行 P2 Non-E2E,至少包含:
記錄 workflow run id;別的workflow不能替代。
Set-Location .\apps\web
npm test -- --run
npm run build
Set-Location .\apps\web
npm run test:e2e:docker
這裡要求的是完整既有 Web suite + 新 P2 journeys。Room-first route migration不得靠把舊 specs搬去 standalone、刪檔、或新增沒有已知問題依據的 skip來取得綠燈。
若 KI-M01J-001仍是 intentional fixme,不得把它算成 P2功能通過證據;只照 已知問題.md記錄。
依當時 M03 release contract跑:
scripts/smoke_standalone.py)。heads。heads。character@head。character@head。用含:
的 dataset重啟 server,再跑核心讀寫。
P2-F closeout至少留下可追溯文件,記錄:
Branch / final SHA
Alembic character head / web head(s)
Backend pytest result
P2 Non-E2E workflow + run id + PostgreSQL result
Frontend unit result
TypeScript / build result
Full Web Playwright result (existing suite + P2 journeys)
Standalone import-boundary result
Standalone frozen build / branch-aware smoke result
Fresh + legacy migration result
Character schema parity allowlist result
Single-head assumption scan result
Existing Web regression migration result
Concurrency lease test result
Authorization matrix result
Human smoke result
Known skips / known issues
Closeout不能只寫:
CI green
要能回答「哪個 CI / 哪個 commit / 哪些 test / PostgreSQL到底有沒有跑 / standalone到底有沒有跑到 Room schema / 既有 Web regression是否還真的在 Web 跑」。
P2 關門後以下不再是「只有 P2才跑」:
後續 M01如果修改 Character shared core、P3/P4如果修改 Seat/Session consumer、P7如果加入 export/snapshot,都必須把直接受影響的上述 regression帶著走。