Phase:P2 — Room / Campaign / Session / Seat
本文件是 P2 的具體實作契約。產品行為以實作規格.md與根目錄規格企劃.md為準;測試與 closeout 證據以測試指南.md為準。
最後更新:2026-09-06
三份 P2 文件使用完全一致的 Subphase 名稱與順序:
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 + tests + static review 後才進下一段;需要跑 Actions 時依 AGENTS.md gate 執行。
P2 開工時 main 的多人 domain 為零;現有正式 server domain只有:
app/domain/character/
app/domain/character_builder/
app/domain/rules/
app/content/
app/interop/
Persistence:
characters
character_versions
character_states
character_build_drafts
character_import_records
Web / Standalone 目前共用 neutral Character / Builder API。app.main 與 app.standalone 是不同 entry point;M03 已建立 import graph gate,standalone 不得 reach Room / Campaign / Session / Seat。
P2 最重要的架構規則:
多人層可以依賴 Character Core;Character Core 不得依賴多人層。
依賴方向固定:
Web Room / Campaign / Session / Seat
│
▼
Character / Builder / Rules / Interop
Standalone
│
▼
Character / Builder / Rules / Interop
Character Core ─X→ Room / Campaign / Session / Seat
Standalone ─X→ Room / Campaign / Session / Seat
不要為了 Web room scope 在 CharacterBuild、CharacterState、Builder payload 或 Character JSON 裡加入 room_id。
M03 standalone launcher目前會:
SQLite
→ alembic upgrade head
→ app.standalone
如果 P2 直接把 Room / Campaign / Session tables接在單一 Alembic head 後面,standalone SQLite 也會長出 multiplayer schema。即使 frontend 不顯示 Room,這仍破壞 standalone「只有 Character distribution」的邊界,也會讓未來 M01 Character schema migration無法與 Web multiplayer migration獨立演進。
因此 P2-A 必須先把 migration graph分流。
以目前共同 head:
0008_m03c_import_records
作 branch point:
┌─ 0009a_character_track_marker
0008_m03c_import_records ─┤ branch_label = character
│ ↓ future M01 / shared Character migrations
│
└─ 0009b_p2a_room_foundation
branch_label = web
↓ P2-B / P2-C / P2-D / P2-E web migrations
0009a_character_track_marker 可以是 no-op marker;目的不是造 table,而是給 standalone 一個永久可追的 shared-character branch label。
Standalone launcher改成:
command.upgrade(config, "character@head")
Web development / CI / deployment使用:
alembic upgrade heads
Web 必須同時吃到 character 與 web 兩條 branch;不能只升 web@head,否則未來 M01 新增 shared Character migration會漏掉。
Branch split 一旦提交,單數 head 會變 ambiguous。P2-A 同一個 code Subphase 必須同步更新所有真正會啟動/驗證 Web 或 Standalone 的 operational path、既有 migration tests、standalone smoke helper,以及任何把「整個 repository 只有一個 Alembic head」寫死的程式;不能只改 launcher / docker 而讓舊 test或 frozen smoke在 branch split後直接 multiple-head failure。
至少逐項處理:
docker-compose.yml server startup command:Web → heads。heads。heads。heads。apps/server/app/launcher.py:Standalone → character@head。apps/server/tests/test_m03c_migration.py:改成 branch-aware Web migration test,不再假設 alembic_version只有一列;Web final target使用 heads,assert時比較 revision set而非單一 scalar。apps/server/tests/test_m03d_migration_sqlite.py:Standalone migration target改為 character@head;不得再呼叫 ScriptDirectory.get_current_head()假設 repository只有單一 head,應解析 character@head 的 revision。apps/server/tests/test_m03d_schema_parity.py:Standalone migration target改為 character@head,並依 §3.8 的 Character schema allowlist做 parity。scripts/smoke_standalone.py:目前自行 AST 掃 revision 後要求 len(heads) == 1,P2-A 必須改成只解析 Character branch head;frozen SQLite 的 alembic_version比對也只能跟 Character branch current revision比,不得再以 repo-global single head當期待值。apps/server/tests/test_m03e_smoke_script.py:不得再 assert 0008_m03c_import_records 是 repository head;應鎖定 smoke helper能在 multiple-head graph中解析目前 character@head。apps/server/tests/test_m03e_alembic_bundle_ready.py:不得再用 ScriptDirectory.get_current_head();frozen bundle要明確 resolve bundled Alembic 的 character@head,再跟 standalone SQLite current revision比對。apps/server/tests/test_m03e_launcher_headless.py:同上,launcher migration test必須以 character@head為 standalone SSOT,不得依 repo只有一個 head。"head" target。目前已知仍跑裸 alembic upgrade head 的既有 workflow至少包括:
.github/workflows/p0a-foundation.yml
.github/workflows/m01f-non-e2e.yml
.github/workflows/m01g-non-e2e.yml
.github/workflows/m01h-non-e2e.yml
.github/workflows/m01i-non-e2e.yml
.github/workflows/m01j-non-e2e.yml
.github/workflows/m03a-non-e2e.yml
.github/workflows/m03b-non-e2e.yml
P2-A branch split時這些 active workflow若仍會執行 migration,就必須一併改成符合其用途的 target;Web regression workflow使用 heads,Standalone-only path使用 character@head。不能因為它們名稱屬舊 Phase就保留一條會在現在 main 上直接失敗的 active command。
這裡的 single-head 假設 至少包含:
command.upgrade(..., "head")
CLI: alembic upgrade head
ScriptDirectory.get_current_head()
len(heads) == 1 / expected one Alembic head
自行 AST / filename 推導唯一 repository head
把 alembic_version 永遠當成單列 scalar 的 Web assertion
原則:
Web → heads / revision set
Standalone / Character-only → character@head / Character current revision
不要保留一條「平常其實會被人照著跑」、「pytest會真的執行」或「standalone smoke會真的呼叫」的 repo-global single-head contract。歷史 closeout文件中的舊命令可以保留,因為它描述當時事實,不是現行操作指南。
M01 是 long-running track,所以未來可能發生:
character branch revision C12
新增 Character 欄位 / schema
web branch revision W20
Room / Session code開始依賴 C12
這時 W20 必須以 Alembic depends_on(或等價的明確 revision dependency)單向依賴 C12:
W20 (web)
depends_on → C12 (character)
禁止為了排序把 character / web branches merge成一個共同 head。 一旦 merge,future character@head可能被迫穿過 Web ancestry,破壞 standalone。
永久方向:
Web migration 可以依賴 Character migration
Character migration 永遠不能依賴 Web migration
測試要能檢查 Character branch ancestry不包含任何 web revision。
Web-only revision script:
app.persistence.rooms 來取得 Table object。P2 closeout要直接檢查 standalone SQLite sqlite_master:不得存在 Room / Campaign / Seat / Session P2 tables。
alembic/env.py不要為了 autogenerate 在 env.py 無條件 import P2 multiplayer persistence module,否則 standalone migration process本身會 import multiplayer code。
P2 第一版允許 P2 web migrations維持明確 handwritten migration;若未來要 autogenerate,另建立 explicit web migration scope,不得把 room imports放回 standalone共用 env path。
Autogenerate 陷阱必須明確避免: shared env.py 刻意不 import app.persistence.rooms.*,所以它的 target_metadata 在該 process 中看不到 Web tables。Web DB 一旦已經有 rooms / Campaign / Session tables,如果有人對這個 shared env 執行 alembic revision --autogenerate,Alembic 可能把「DB 有、metadata 沒有」解讀成應該 DROP TABLE。因此 P2 的 Web revisions維持 handwritten;不得用 shared env 對 Web DB 跑 autogenerate。未來真的要 autogenerate時,必須先建立 explicit web migration scope,確保比較前已明確載入 Web persistence metadata。
P2 不拆第二份 MetaData。app/db.py 繼續維持單一 global MetaData(),Character與 Web persistence tables都可註冊在同一個 metadata object;理由是避免跨 metadata FK、table identity與 Alembic/autogenerate scope變成第二套複雜架構。
但這代表 metadata.create_all() 不能再被當成 Standalone schema的無條件真相:pytest同 process只要 import過 app.persistence.rooms.*,global metadata就可能已經含 Web tables,若 parity test直接 metadata.create_all()會受 import順序污染。
因此 test_m03d_schema_parity.py 在 P2-A 必須改成明確 Character table allowlist,例如概念上:
CHARACTER_SCHEMA_TABLES = (
characters,
character_versions,
character_states,
character_build_drafts,
character_import_records,
)
metadata.create_all(metadata_engine, tables=CHARACTER_SCHEMA_TABLES)
要求:
character@head 應有的 Character schema。rooms、room_access_sessions或後續 P2 Web tables。app.persistence.rooms.tables而改變。character migration track;future Web table不得加入。P2有 PostgreSQL-specific migration、FK與 concurrency gate,因此從 P2-A 起建立專屬的 non-E2E workflow:
.github/workflows/p2-non-e2e.yml
Workflow display name: P2 Non-E2E
它至少提供 PostgreSQL service,並把 dedicated test DB URL注入:
P2_POSTGRES_URL
DATABASE_URL = P2_POSTGRES_URL
P2-A 必須先把 fresh/legacy migration與 branch tests接上這個 workflow;P2-B~E逐步把各自的 PostgreSQL persistence / concurrency tests加入同一條 P2 workflow。M03 或舊 M01 workflow綠燈不能替代 P2 Non-E2E evidence。
本機也必須有可重現路徑。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
若本機環境無法跑 PostgreSQL,對應 gate可以由 P2 Non-E2E workflow提供正式 evidence;但 closeout必須記錄 exact workflow/run/SHA與實際跑到的 test,不得只寫「CI green」。
P2 新多人 module採可被 M03 gate明確辨識的 rooms package boundary:
apps/server/app/domain/rooms/
├─ schemas.py
├─ access.py
├─ workspace.py
├─ campaigns.py
├─ seats.py
└─ sessions.py
apps/server/app/persistence/rooms/
├─ tables.py
├─ repository.py
├─ workspace.py
├─ campaigns.py
└─ sessions.py
apps/server/app/api/rooms/
├─ __init__.py
├─ access.py
├─ characters.py
├─ builder.py
├─ campaigns.py
├─ seats.py
└─ sessions.py
不要求 P2-A 一次建立所有空檔案;對應 Subphase才新增真正需要的 module。
選擇 rooms 作為多人 aggregate boundary的理由:
app.*.rooms.* 能被 standalone import-boundary gate用 module segment穩定阻擋。P2-A 先更新 test_m03_import_boundary.py:
app.*.rooms module。P2 不允許「因為 module叫 workspace_multiplayer 沒被 regex抓到」這種逃逸。
新增中立於 HTTP 的多人 types:
Room
RoomAccessAuthority = member | dm | owner
RoomAccessSession
注意:
RoomAccessAuthority != SeatRole
Owner / DM key描述「這個 caller在 Room 有什麼 authority」;SeatRole描述「這場桌上扮演 DM / Player / Spectator」。不要塞成同一 enum。
Web branch第一個 migration建立:
rooms
├─ id UUID PK
├─ code string UNIQUE
├─ name string
├─ password_salt / password_hash
├─ owner_key_hash
├─ dm_key_hash
├─ created_at
└─ updated_at
room_access_sessions
├─ id UUID PK
├─ room_id FK rooms ON DELETE CASCADE
├─ authority member|dm|owner
├─ token_hash UNIQUE
├─ display_name nullable
├─ created_at
├─ last_seen_at
└─ revoked_at nullable
active_campaign_id 到 P2-C 建 Campaign table後再加入,不在 P2-A 建 dangling FK。
Room code 在 P2-A 是public locator,不是 secret,規格固定:
I / L / O / U。Room Password是人類可能選弱字串,必須走 slow password KDF。第一版使用 stdlib hashlib.scrypt + per-room random salt,避免為 MVP只為 password新增大型 auth framework。
Room Password contract:
Owner Key / DM Key / RoomAccess token皆由 CSPRNG產生高 entropy random secret;DB只存 hash,raw secret:
比較 hash使用 constant-time compare。
P2-A 要有最小 brute-force protection,不做完整 auth platform:
(normalized_room_code, remote_addr)為 key做 application-level fixed-window throttle。room_access_throttled。X-Forwarded-For。第一版:
Create Room(name, password)
→ room code
→ owner key (show once)
→ dm key (show once)
→ owner RoomAccess token
Enter Room(code, password, optional dm/owner key)
→ RoomAccess token
一般 password只得到 member;正確 DM key得到 dm;正確 Owner key得到 owner。
Browser保存 opaque RoomAccess token供後續 request使用;Recent Rooms只是一個 client convenience,可以保存 Room code / name / access token,但不可保存 raw Room password / owner key / dm key。
新增:
RoomAccessContext
├─ room_id
├─ access_session_id
├─ authority
└─ display_name
所有 /api/rooms/{room_id}/... handler先由 dependency解析 bearer token,再確認 token room == path room。
錯誤 contract至少:
room_not_found
room_access_required
room_access_denied
room_access_throttled
room_scope_mismatch
room_access_revoked
不要用「查不到 UUID」與「沒權限」混成會洩漏跨 Room私有 resource的錯誤;跨 Room Character / Campaign / Seat等 resource對一般 caller可統一 404-style resource-not-found presentation。Room code本身是 public locator,不要求隱藏存在性。
P2 不建 WebSocket event bus。
Heartbeat 由 P2-A 交付,不是留到 P2-D:
POST /api/rooms/{room_id}/access/heartbeat
Authorization: RoomAccess token
成功只做:
room_access_sessions.last_seen_at。Frontend進入 Room後預設每30秒送一次 heartbeat;P2-D Presence projection使用:
now - last_seen_at <= 90 seconds → Connected
otherwise → Offline
測試用 fake clock,不用 real sleep。其他 authenticated Room request可以 opportunistically touch last_seen_at,但 heartbeat endpoint是正式且可測的 liveness contract;P2-D只消費這份資料,不再另造第二套 presence substrate。
P3 若加入更完整 connection/event transport,可以沿用 access/session identity再擴充。
build_capabilities("web") 不再與 standalone共用一份全 false multiplayer flags。
P2-A:
web:
room = true
campaign = false
seat = false
session = false
standalone:
room = false
campaign = false
seat = false
session = false
後續 Subphase完成再逐一開 capability。
apps/server/tests/test_m03e_capabilities.py 是既有 contract的一部分,P2-A 必須同 commit更新:Web assertion從 capabilities.room is False翻成 True,Standalone assertion仍保持 False。不能只改 build_capabilities() 而讓既有 M03-E regression故意紅著留到 closeout。
Frontend不要散落 if (channel === "web");以 capability + route context為 SSOT。
Web /:Create Room / Enter Room。
Standalone /:Character Workshop。
P2-A 不要為了「首頁已 Room-first」就讓現有 Web Character功能整段消失。
P2-A期間可以暫時保留 P2 前的 direct global Character route / API,讓既有角色仍可操作;但:
app.standalone 的 global Character route不是 TODO,永久保留。在修改任何 exporter schema_version/schema_status、Pydantic envelope或normalizer之前,P2-A 第一個可獨立 commit先用目前最後 M03 exporter真正產出並提交一份 realistic legacy fixture,例如:
apps/server/tests/fixtures/character_export_m03_unstable.json
fixture要求:
builder_provenance。schema_version="unstable" / schema_status="unstable"。完成並 review這個 fixture commit後,下一小階段才可改 exporter / parser到 v1。這個順序是 P2-A gate,不是可選建議。
P2-A 將目前 M03:
schema_version = "unstable"
schema_status = "unstable"
鎖成:
schema_version = "1"
schema_status = "locked"
export_type = "character"
其他已穩定 payload優先保持與 M03 最後 format一致,不趁 lock時無必要重寫 Build / State semantics。
不要把 Pydantic Literal["1"] 直接套到 legacy input讓它失效。
建立清楚兩層:
LegacyM03CharacterExport
CharacterExportV1
parse_character_export(raw)
↓
legacy or v1
↓
normalize_character_export(...)
↓
CharacterExportV1-compatible internal DTO
Legacy成功 import後重新 export一定輸出 v1。
v1 Character JSON明確不得加入:
room_id
room_code
campaign_id
seat_id
session_id
RoomAccess token / password / keys
Web target Room是 import request context,不是 file內容。
承諾:
new server imports v1 old files
不承諾:
old standalone binary imports arbitrary future schema
未來 schema upgrade若需 v2,必須保留 v1 parser / migration path;不能把 Literal["1"]直接改成 Literal["2"]後刪掉舊 parser。
room_id 加入 Character Core tables不要改成:
characters.room_id NOT NULL
character_build_drafts.room_id NOT NULL
新增 Web-only association tables:
room_characters
├─ room_id FK rooms
├─ character_id FK characters UNIQUE
└─ created_at
room_builder_drafts
├─ room_id FK rooms
├─ draft_id FK character_build_drafts UNIQUE
└─ created_at
UNIQUE character_id / UNIQUE draft_id直接保證一個 Web instance不會掛兩個 Room。
Standalone不 import這些 tables,也不執行 web migration branch。
目前 core repository methods自己開 transaction。P2-B 不得用:
core.create_character()
COMMIT
↓
room.attach_character()
COMMIT
因為第二步失敗會留下 global orphan。
做法:把 core persistence refactor成transaction-aware neutral primitives,例如:
BuilderDraftRepository.create_draft_in_transaction(connection, ...)
CharacterRepository.confirm_create_draft_in_transaction(connection, ...)
CharacterImportService.apply_in_transaction(connection, ...)
現有 standalone public methods仍可自己 engine.begin()後呼叫同一 primitive。
Web RoomCharacterWorkspaceService:
engine.begin()
├─ create / confirm / import neutral Character data
├─ insert room_builder_drafts / room_characters association
└─ commit once
Neutral primitive只知道 SQLAlchemy Connection / Character資料,不 import Room type。
不要使用 Character Core callback去 import room module,也不要複製整套 confirm algorithm到 Web wrapper。
P2-B 後 Web不直接 mount global Character routers。
Standalone保留:
/api/characters/...
/api/character-builder/...
Web改成:
/api/rooms/{room_id}/characters
/api/rooms/{room_id}/characters/{character_id}
/api/rooms/{room_id}/characters/{character_id}/sheet
/api/rooms/{room_id}/characters/{character_id}/versions/...
/api/rooms/{room_id}/characters/import/preview
/api/rooms/{room_id}/characters/import
/api/rooms/{room_id}/character-builder/drafts
/api/rooms/{room_id}/character-builder/drafts/{draft_id}
/api/rooms/{room_id}/character-builder/characters/{character_id}/drafts
...
Web wrapper:
P2-B 收掉 global Web Character routers時,既有以 app.main + /api/characters / /api/character-builder 驗證 Character Web contract 的 tests 必須繼續跑 Web channel,改成 Room-scoped client / base path;不能為了少改 route就把它們全部改綁 app.standalone。
目前已知至少包含:
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
m01k_support.py 被多個 M01-K regression重用,因此不能只修直接失敗的某一個 consumer;shared HTTP helper本身要 Room-aware。
P2-B 建立共用 Web test bootstrap(名稱按既有 pytest style決定,例如 web_room_client / room_api_context),責任至少:
app.main 建立/注入 Room persistence與 Room services。真正只測 neutral Character service / compiler 的 pure tests可繼續不經 HTTP;M03 standalone已有自己的 API / frozen regression。不能以 standalone regression存在為理由,讓 P2-B 把原本 Web browser / Web API 的 Character coverage清空。
如果 standalone /api/characters 與 web /api/rooms/.../characters需要同一 response model / presentation helper,將 DTO / helper抽到 neutral module,例如:
app/api/character_contract.py
或等價 neutral presentation module。
禁止 room router複製 Character rules / Builder compiler。
同一 SPA支援兩種 route shape:
Web:
/rooms/{roomId}
/rooms/{roomId}/characters
/rooms/{roomId}/characters/{characterId}
/rooms/{roomId}/characters/{characterId}/versions/...
/rooms/{roomId}/character-builder/{draftId}
Standalone:
/characters
/characters/{characterId}
/characters/{characterId}/versions/...
/character-builder/{draftId}
不要把 Room id塞進 Character component props一路污染 domain。建一個 route / API context provider負責選擇 endpoint base與返回 Room。
Web直接輸入 legacy global /characters:P2-B closeout後不得列出 global data;可以 redirect首頁或顯示 room_required,但不能當 bypass。
P2-B migration只加 association tables;不在 Alembic migration中猜哪個 Room屬於哪個既有 Character。
upgrade策略:
legacy_unscoped_character_count / draft_count,但一般 member不取得 global identity list。Room → Characters / Room Character Workshop 看到 owner-only Legacy Character Data migration card。卡片只顯示仍 unscoped 的 Character / Draft count,不向一般 member洩漏 global identities。Claim Legacy Character Data action。確認 modal必須顯示 target Room name、Character / Draft count,並明確說明「把目前所有仍 unscoped 的 legacy Character / Draft 歸入此 Room;已 scoped object不搬家」。只有 Owner可確認。這是 migration compatibility workflow,不是永久 global Character feature。Exact API path名稱可依現有 router style調整,但 UI入口與 authority不可省略或藏在 undocumented endpoint。
Room Character archive:
Permanent individual delete:
Room Hard Delete是例外:Owner確認刪整個 workspace時,P2 service在一個 transaction中清掉 P2 data與其唯一 Room Characters / Drafts,不受 individual history guard阻擋。
campaigns
├─ id UUID PK
├─ room_id FK rooms ON DELETE CASCADE
├─ name
├─ ruleset
├─ status draft|active|completed|archived
├─ created_at
└─ updated_at
campaign_roster_entries
├─ campaign_id FK campaigns ON DELETE CASCADE
├─ character_id FK characters
├─ status active|inactive|retired|dead
├─ added_at
└─ updated_at
unique:
(campaign_id, character_id)
P2-C migration再為 rooms 加 nullable:
active_campaign_id FK campaigns
Service必須驗 active_campaign candidate屬於同 Room。
active_campaign_id兩者不同:
campaign.status=active:這個 Campaign仍在進行。room.active_campaign_id:這個 Room目前 UI / Lobby選中的 Campaign。因此 Room可有多個 ongoing active Campaign,但一次只有一個 selected active_campaign_id。
P6 尚未存在 Adventure table。
P2 Create Campaign只正式要求:
name
ruleset = dnd5e-2014
不要先用 raw string adventure_id造假的 FK,也不要為了 Adventure=optional提前建 Adventure stub。P6 到來後以真正 Adventure identity擴充。
規格企劃.md 的 Campaign Level / Rules 另有:
Leveling = Milestone
Diagonal = 5/10 alternating
它們是產品規則方向,不代表 P2 要提前建立 generic Campaign Rules schema:
leveling_mode、diagonal_rule 或自由形狀 rules_json。Leveling = Milestone 維持全產品既有基線;等真的新增第二種 leveling mode時,再由對應 Phase把它提升成 Campaign setting。Diagonal = 5/10 alternating 只有 Tactical spatial movement真正消費,明確由 P5 Tactical Combat 實作與持久化所需 rule surface。campaign_roster_entries.character_id DB FK只能證明 Character存在,不能證明 Character跟 Campaign同 Room。
CampaignService.add_character() 必須在同 transaction驗:
campaign.room_id == room_characters.room_id(character_id)
跨 Room加入回 character_not_in_room / resource-not-found,不允許 copy-by-reference。
禁止建立:
campaign_character_states
roster.state_payload
campaign HP copy
Campaign A / B reference同一 Character時,Character Sheet / State mutation仍走唯一 character_states row。
DM key不取得 Room lifecycle ownership;Owner authority也不等於本場 Session DM Controller。
Seat是某 Campaign可重用的 Lobby slot,不是 Character ownership。
campaign_seats
├─ id UUID PK
├─ campaign_id FK campaigns ON DELETE CASCADE
├─ role dm|player|spectator
├─ label nullable
├─ controller_kind human|ai|none
├─ controller_access_session_id nullable
├─ selected_character_id nullable
├─ archived_at nullable
├─ created_at
└─ updated_at
campaign_seats 是 Campaign-owned row;對沒有 Session history、允許 hard delete 的 Campaign可隨 Campaign cascade。只要存在 Session history,§10.1 的 sessions.campaign_id ON DELETE RESTRICT與 service lifecycle guard會阻止一般 Campaign hard delete。Room Hard Delete則照 §12 先清 Session history,再讓 Campaign-owned Roster / Seat rows清除。
selected_character_id是下一場 Start 前 Lobby selection;真正 immutable session assignment會 snapshot到 P2-E session_participants.active_character_id。
Seat lifecycle固定:
session_participants reference的 Seat可以 hard delete。archived_at。這讓 P2-E 的 Session FK可以使用 ON DELETE RESTRICT,不需要靠 nullable FK掩蓋「Seat被刪掉了」的歷史洞。
RoomAccessAuthority.owner是管理 authority。
Seat role只需:
dm
player
spectator
Owner如果今天當 DM:使用一個 DM Seat。
Owner如果今天當 Player:使用一個 Player Seat。
避免 role=owner導致「Owner到底算 DM還是 Player」的權限歧義。
Schema預留:
human
ai
none
P2可真正 binding:
human → room_access_session_id
none → null
P2 的 ai不得綁假的 RoomAccessSession;UI只顯示 reserved / unavailable,正式 AI controller credential等 P3。
選擇時 transaction驗:
DM / spectator seat沒有 Player Active Character。
controller_access_session_id不 unique;同一 Human access session可以控制 Seat 1、Seat 2。
Seat的 selected_character_id每 seat最多一個。
Lobby Connected / Offline由 P2-A RoomAccessSession heartbeat contract推導:30秒 heartbeat、90秒 timeout;不把 Seat row delete當掉線。
P2不使用 arrival timestamp決定遊戲順序。
產品規格的「Owner Key:DM assignment」在 P2具體化如下:
dm authority,可管理 Roster / Player-Spectator Seat / Lobby;不能自行把自己綁上 DM Seat。dm authority access session指定為 DM Seat controller;Owner若自己要當 DM,也可把自己的 owner access session指定到 DM Seat。{dm, owner}。其餘:
因第一版沒有 account ownership,Seat binding就是 active table期間最重要的 player write scope。
sessions
├─ id UUID PK
├─ campaign_id FK campaigns ON DELETE RESTRICT
├─ status active|ended|abandoned
├─ dm_seat_id FK campaign_seats ON DELETE RESTRICT NOT NULL
├─ dm_controller_kind
├─ dm_controller_access_session_id nullable
├─ started_at
├─ ended_at nullable
└─ created_at
session_participants
├─ id UUID PK
├─ session_id FK sessions ON DELETE CASCADE
├─ seat_id FK campaign_seats ON DELETE RESTRICT NOT NULL
├─ role_snapshot
├─ controller_kind_at_join
├─ controller_access_session_id_at_join nullable
├─ active_character_id nullable FK characters
├─ joined_at
└─ left_at nullable
active_character_session_leases
├─ character_id PK FK characters
├─ session_id FK sessions ON DELETE CASCADE
└─ participant_id FK session_participants ON DELETE CASCADE
Sessions.campaign_id ON DELETE RESTRICT 是歷史保護 hard guard:一旦 Campaign 有 Session history,不能靠刪 Campaign cascade掉歷史;產品 flow改用 completed / archived。session_participants.session_id ON DELETE CASCADE 只服務於 explicit Session cleanup(目前正常產品不 hard delete有歷史的 Session)與 Room Hard Delete;Seat historical FK仍維持 RESTRICT。
P2的每個 Session participant都來自正式 Campaign Seat;Late Join若需要新位置,先建立 Seat再加入,因此 session_participants.seat_id不需要 nullable。Seat historical identity由 §9.1 archive policy保留。
只靠 query:
SELECT session_participants JOIN sessions WHERE status='active'
再 insert,兩個 concurrent Start仍可能同時通過 check。
active_character_session_leases.character_id primary key直接讓 DB保證:
same Character → at most one active Session
Start / Late Join:insert lease。
End / Abandon:delete該 Session leases。
Session history仍留在 session_participants,lease只代表 live concurrency ownership。
SessionService.start_session()一個 transaction內:
{dm, owner};僅持 DM Key但未被 Owner assign、或僅有 Owner authority但未被 assign,都不能 Start。不允許「Session建好了但第三隻角色 lease失敗」的半成品。
P2 Session保存:
dm_controller_kind
dm_controller_access_session_id
P2只有 Human DM真正可 start。
DM access session暫時 Offline:Session仍 active;同一 access identity reconnect後繼續。
P2不提供:
Owner在 DM確定不回來時可把 Session標 abandoned;這是結束壞掉的 Session,不是接管 DM。
session_participants.active_character_id一旦建立即 immutable。
未來 P3 Human ↔ AI handoff只改 Seat controller / controller credential,不改 participant.active_character_id。
P2若 UI有人想中途「換角色」,Server回 explicit session_active_character_locked。
Late Join只能由本場 current DM Controller發起:
P2不要求 spawn point。
End Session:只有 current DM Controller可執行;Owner / 其他 DM authority不是 substitute DM。
Abandon Session:current DM Controller或 Owner可執行,用於誤開/DM不再回來。
兩者 transaction:
character_states payload。P3 AI tokens存在後,Session end hook再加 token revoke;P2只保留 service hook / TODO contract,不建立假 token table。
P2只保存 Session lifecycle metadata與 participants,不建立完整 Snapshot store。
可以有 neutral boundary marker:
session.started_at
session.ended_at
participant snapshots
P7 到來後才新增:
Snapshot payload
Restore
archived post-restore history
不要在 P2 偷做半套 Snapshot JSON。
P2 Resume DTO只由目前 truth組成:
room
campaign
active_session?
participants[]
seat/controller presence
active character summaries
P3/P4 後新增 pending roll / combat fields時用 additive DTO擴充,不在 P2 回假資料。
P2-B 起,Web Character endpoint不能再只靠 character_id。
讀取流程:
RoomAccessContext
↓
room_characters lookup
↓
CharacterRepository
如果 Character目前沒有 active Session lease:
如果 Character已被 active Session lease,live Current State write只允許:
current Session DM Controller
OR
Human Player Seat Controller for that leased participant
不是任何持有 DM Key的人,也不是 Owner authority自動放行。
Build workflow若 Character正在 active Session:
P3建立正式 GameAction permission layer時,沿用這個 Room / Seat scope,不另造第二套身份系統。
因 Character Core沒有 room_id FK,DB無法單靠 ON DELETE rooms自動刪 Character。
RoomService.hard_delete_room()必須:
Room Hard Delete不逐隻套 individual historical delete guard;使用者已在 workspace層選擇永久刪除全部。
測試必須在 PostgreSQL驗完整 FK / orphan;neutral Character delete primitive仍需保有 SQLite coverage,但 standalone本身不執行 Room service。
P2新增 domain error不要把 raw DB exception送前端。
至少穩定 code:
room_not_found
room_access_required
room_access_denied
room_access_throttled
room_access_revoked
room_scope_mismatch
room_required
legacy_character_data_pending
character_not_in_room
character_in_active_session
character_history_referenced
campaign_not_found
campaign_not_in_room
character_not_in_roster
roster_character_unavailable
seat_not_found
seat_character_invalid
seat_history_referenced
session_not_found
session_already_active
session_active_character_locked
character_already_in_active_session
dm_controller_mismatch
session_not_active
全部 user-visible code必須進 M02既有 zh-TW / en message SSOT;frontend不可顯示 raw code。
P2 不重寫既有 Character UI。
建議新增:
apps/web/src/features/rooms/
├─ api.ts
├─ RoomLandingPage.tsx
├─ EnterRoomPage.tsx
├─ CreateRoomPage.tsx
├─ RoomShell.tsx
├─ RoomHomePage.tsx
├─ RoomCharacterWorkshopPage.tsx
├─ CampaignPage.tsx
├─ LobbyPage.tsx
└─ SessionPage.tsx
實際檔案按 Subphase需要建立,不一次造空 skeleton。
建立 RoomContext / CharacterRouteContext(名稱可依現有 style調整),讓既有:
只需要取得 endpoint scope / return path,而不是複製 Web版 component。
P2 的 Room-first 改版不能把既有 P1 / M01 / M02 Character Playwright suite改跑 standalone來逃避 Web route migration。這批 spec原本就是 Web browser regression;Standalone另有 M03 frozen regression,兩者責任不同。
P2-A 先建立共用 Web E2E Room bootstrap/navigation helper(檔名按現有 style,可例如 apps/web/e2e/support/room.ts):
enterRoom(page, options?)
→ create/enter isolated test Room
→ establish browser RoomAccess context
→ return { roomId, roomCode, ...minimal test context }
openCharacterWorkshop(page, roomContext)
→ P2-A transitional: 可暫時導向 /characters
→ P2-B closeout: 改成 /rooms/{roomId}/characters
硬規則:
page.goto('/characters');統一經 shared helper進入測試 Room,再由 helper開 Character Workshop。/characters」。重點是把 Room bootstrap與 Workshop route集中成一個 seam。/rooms/{roomId}/characters;不再逐一修改二十多個 spec 的 hardcoded入口。/ 為被測主體的 app-shell / localization specs不要被 helper掩蓋;P2-A 直接把它們的 expected landing內容改成 Create / Enter Room與雙語 Room-first copy。test.skip() / 暫時移除 spec / 改成 standalone channel作為 P2-A/P2-B route migration的 closeout手段。因此 P2-A完成後,完整既有 Web E2E suite仍應可跑;P2-B收口 global /characters後,同一 suite繼續跑 Web,只是由 shared helper進 Room-scoped Workshop。
Web:
/
/rooms/{roomId}
/rooms/{roomId}/characters
/rooms/{roomId}/character-builder/{draftId}
/rooms/{roomId}/characters/{characterId}
/rooms/{roomId}/campaigns/{campaignId}
/rooms/{roomId}/campaigns/{campaignId}/lobby
/rooms/{roomId}/sessions/{sessionId}
Standalone現有 routes不改。
所有 Back to Workshop 在 Web必須回同 Room Workshop,不得掉回 global /characters。
每完成一個真正可用 Subphase才打開 capability:
| 時點 | room | campaign | seat | session | character_builder | character_import_export |
|---|---|---|---|---|---|---|
| Standalone 永久 | false | false | false | false | true | true |
| P2-A Web | true | false | false | false | true | true |
| P2-C Web | true | true | false | false | true | true |
| P2-D Web | true | true | true | false | true | true |
| P2-E/F Web | true | true | true | true | true | true |
P2-B不需要新增 capability名稱;它把既有 Character capability從 Web global route收進 Room context。
不要一開始把尚未完成的 flag設 true再用「Coming Soon」遮。
P2所有新增 Web UI / server messages延續 M02:
zh-TWen使用者輸入的 Room / Campaign / Seat label / Character name不翻譯。
Room Code / keys / UUID不翻譯。
Server code + params與 locale文案分離;API不回硬編譯中文句子作唯一判斷依據。
P2 可以留下 ID / boundary讓後續引用,但不得建立:
GameAction full pipeline。例如 P2 Session可以有 id / status / participants / timestamps;不能因為未來 Timeline要 reference Session,就現在先建一套 generic Event Store。
P2-F static review至少逐條確認:
app.domain.character* reachable graph沒有 app.*.rooms。app.standalone reachable graph沒有多人 module。character@head。heads;Docker / README / CI / active scripts / workflows / executable tests與standalone smoke都沒有 repo-global single-head假設,包括 stale upgrade head、get_current_head()、len(heads)==1、自行推導唯一 head、或把 Web alembic_version固定當單列 scalar。test_m03c_migration.py已是multi-head-aware;test_m03d_migration_sqlite.py / test_m03d_schema_parity.py鎖定 character@head。scripts/smoke_standalone.py、test_m03e_smoke_script.py、test_m03e_alembic_bundle_ready.py、test_m03e_launcher_headless.py都已在 multiple-head graph中明確解析 Character branch,不再假設 0008 或 repo只有一個 head。character branch落點;Character ancestry永遠不含 web revision。P2 Non-E2E有 PostgreSQL evidence;不能拿別的 workflow綠燈代替。test_m03e_capabilities.py與新 capability contract一致:Web room=true、Standalone room=false。app.main Character / Builder HTTP regression已用共用 Room-aware client/context改成 scoped endpoint;m01k_support.py與其 consumers沒有因 global router收口失去 Web coverage。campaign_seats.campaign_id ON DELETE CASCADE、sessions.campaign_id ON DELETE RESTRICT與 Room Hard Delete explicit order一致;Campaign有 Session history時不可被 cascade hard delete。上述任何一條無證據,不得只以「UI看起來正常」關 P2。