adventure-table

P2 — 開發設計方針

Phase:P2 — Room / Campaign / Session / Seat
本文件是 P2 的具體實作契約。產品行為以 實作規格.md 與根目錄 規格企劃.md 為準;測試與 closeout 證據以 測試指南.md 為準。

最後更新:2026-09-06


1. P2 Subphase 順序

三份 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 執行。


2. 開工時真實 codebase 與 P2 核心限制

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.mainapp.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 在 CharacterBuildCharacterState、Builder payload 或 Character JSON 裡加入 room_id


3. Alembic:P2 起正式分成 shared-character 與 web-multiplayer migration tracks

3.1 為什麼一定要分流

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分流。

3.2 Branch layout

以目前共同 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。

3.3 Upgrade targets

Standalone launcher改成:

command.upgrade(config, "character@head")

Web development / CI / deployment使用:

alembic upgrade heads

Web 必須同時吃到 characterweb 兩條 branch;不能只升 web@head,否則未來 M01 新增 shared Character migration會漏掉。

3.4 P2-A 必須同步修改所有實際 Alembic target與 single-head 假設

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。

至少逐項處理:

目前已知仍跑裸 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文件中的舊命令可以保留,因為它描述當時事實,不是現行操作指南。

3.5 未來跨 branch dependency:只能 Web → Character,不能 merge

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。

3.6 Migration script boundary

Web-only revision script:

P2 closeout要直接檢查 standalone SQLite sqlite_master:不得存在 Room / Campaign / Seat / Session P2 tables。

3.7 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。

3.8 SQLAlchemy MetaData 與 M03 schema parity

P2 不拆第二份 MetaDataapp/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)

要求:

  1. allowlist由 neutral Character persistence table object組成,不靠 table-name substring猜測。
  2. parity只比較 character@head 應有的 Character schema。
  3. 同一個 test另做 negative assert:migrated Standalone SQLite不得有 roomsroom_access_sessions或後續 P2 Web tables。
  4. test結果不得因其他 test先 import app.persistence.rooms.tables而改變。
  5. future shared Character table新增時,必須同步加入 Character allowlist與 character migration track;future Web table不得加入。

3.9 PostgreSQL verification path / P2 workflow

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」。


4. P2 module boundary

4.1 Multiplayer code location

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的理由:

4.2 M03 import boundary

P2-A 先更新 test_m03_import_boundary.py

P2 不允許「因為 module叫 workspace_multiplayer 沒被 regex抓到」這種逃逸。


5. P2-A — Room Foundation & Web Entry

5.1 Domain concepts

新增中立於 HTTP 的多人 types:

Room
RoomAccessAuthority = member | dm | owner
RoomAccessSession

注意:

RoomAccessAuthority != SeatRole

Owner / DM key描述「這個 caller在 Room 有什麼 authority」;SeatRole描述「這場桌上扮演 DM / Player / Spectator」。不要塞成同一 enum。

5.2 Room tables / Room code

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,規格固定:

5.3 Secrets / hashing / failed-attempt throttle

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:

5.4 Human access flow

第一版:

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。

5.5 Request scope

新增:

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,不要求隱藏存在性。

5.6 Presence / heartbeat ownership

P2 不建 WebSocket event bus。

Heartbeat 由 P2-A 交付,不是留到 P2-D:

POST /api/rooms/{room_id}/access/heartbeat
Authorization: RoomAccess token

成功只做:

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再擴充。

5.7 Web homepage / capability

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。

5.8 P2-A transitional Character compatibility

P2-A 不要為了「首頁已 Room-first」就讓現有 Web Character功能整段消失。

P2-A期間可以暫時保留 P2 前的 direct global Character route / API,讓既有角色仍可操作;但:


6. Character JSON locked v1

6.0 P2-A 第一個 implementation checkpoint:先封存最後 M03 unstable fixture

在修改任何 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要求:

完成並 review這個 fixture commit後,下一小階段才可改 exporter / parser到 v1。這個順序是 P2-A gate,不是可選建議。

6.1 v1 envelope

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。

6.2 Legacy parser 與 normalizer

不要把 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。

6.3 Room-neutral

v1 Character JSON明確不得加入:

room_id
room_code
campaign_id
seat_id
session_id
RoomAccess token / password / keys

Web target Room是 import request context,不是 file內容。

6.4 Backward compatibility方向

承諾:

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。


7. P2-B — Room Character Workspace

7.1 不把 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。

7.2 Transaction boundary:association 不能是第二步 best effort

目前 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。

7.3 Web room-scoped APIs

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:

  1. resolve RoomAccessContext。
  2. verify Character / Draft association屬於 room。
  3. enforce current Room / active Seat / current DM Controller scope。
  4. 呼叫既有 CharacterBuilderService / CharacterRepository / Interop logic。

7.3.1 既有 Web backend regression 不搬去 Standalone

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),責任至少:

真正只測 neutral Character service / compiler 的 pure tests可繼續不經 HTTP;M03 standalone已有自己的 API / frozen regression。不能以 standalone regression存在為理由,讓 P2-B 把原本 Web browser / Web API 的 Character coverage清空。

7.4 Shared API DTO 不要複製規則

如果 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。

7.5 Frontend routes

同一 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。

7.6 Legacy unscoped Web data

P2-B migration只加 association tables;不在 Alembic migration中猜哪個 Room屬於哪個既有 Character

upgrade策略:

  1. 既有 Character / Draft rows保留原樣。
  2. 新 Web create/import從 P2-B 起一律 scoped;禁止再造 unscoped。
  3. Server可計算 legacy_unscoped_character_count / draft_count,但一般 member不取得 global identity list。
  4. 正常升級路徑:P2-A 已被使用、至少已有一個 Room。 Server不自動猜 Room;Owner進入想接收舊資料的 target Room,在 Room → Characters / Room Character Workshop 看到 owner-only Legacy Character Data migration card。卡片只顯示仍 unscoped 的 Character / Draft count,不向一般 member洩漏 global identities。
  5. migration card提供一次性 Claim Legacy Character Data action。確認 modal必須顯示 target Room name、Character / Draft count,並明確說明「把目前所有仍 unscoped 的 legacy Character / Draft 歸入此 Room;已 scoped object不搬家」。只有 Owner可確認。
  6. Claim action在一個 transaction attach所有仍 unscoped Character / Draft;若同時有人先 claim,後到 transaction要重新計數/安全失敗,不可重複 attach或搬走已 scoped object。
  7. Bootstrap edge case:只有 P2-B 啟用時整個 server 的 Room count仍為 0,第一個 Owner Create Room才可在同一 bootstrap transaction自動 claim所有 legacy unscoped data。這是相容性捷徑,不是主要使用者路徑。
  8. P2-F upgrade fixture必須覆蓋「已有 Room → Owner migration card / explicit claim」主路徑,也保留 zero-Room auto-claim edge case,並證明沒有 silent loss。

這是 migration compatibility workflow,不是永久 global Character feature。Exact API path名稱可依現有 router style調整,但 UI入口與 authority不可省略或藏在 undocumented endpoint。

7.7 Archive / delete

Room Character archive:

Permanent individual delete:

Room Hard Delete是例外:Owner確認刪整個 workspace時,P2 service在一個 transaction中清掉 P2 data與其唯一 Room Characters / Drafts,不受 individual history guard阻擋。


8. P2-C — Campaign & Party Roster

8.1 Tables

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。

8.2 Campaign status vs active_campaign_id

兩者不同:

因此 Room可有多個 ongoing active Campaign,但一次只有一個 selected active_campaign_id。

8.3 Adventure / Campaign Rules phase ownership

P6 尚未存在 Adventure table。

P2 Create Campaign只正式要求:

name
ruleset = dnd5e-2014

不要先用 raw string adventure_id造假的 FK,也不要為了 Adventure=optional提前建 Adventure stub。P6 到來後以真正 Adventure identity擴充。

規格企劃.mdCampaign Level / Rules 另有:

Leveling = Milestone
Diagonal = 5/10 alternating

它們是產品規則方向,不代表 P2 要提前建立 generic Campaign Rules schema:

8.4 Roster same-Room invariant

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。

8.5 Roster不保存 CharacterState

禁止建立:

campaign_character_states
roster.state_payload
campaign HP copy

Campaign A / B reference同一 Character時,Character Sheet / State mutation仍走唯一 character_states row。

8.6 Campaign lifecycle / authority

DM key不取得 Room lifecycle ownership;Owner authority也不等於本場 Session DM Controller。


9. P2-D — Seat, Controller & Lobby

9.1 Seat persistence / lifecycle

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固定:

這讓 P2-E 的 Session FK可以使用 ON DELETE RESTRICT,不需要靠 nullable FK掩蓋「Seat被刪掉了」的歷史洞。

9.2 Owner不是 gameplay role

RoomAccessAuthority.owner是管理 authority。

Seat role只需:

dm
player
spectator

Owner如果今天當 DM:使用一個 DM Seat。
Owner如果今天當 Player:使用一個 Player Seat。

避免 role=owner導致「Owner到底算 DM還是 Player」的權限歧義。

9.3 Controller

Schema預留:

human
ai
none

P2可真正 binding:

human → room_access_session_id
none  → null

P2 的 ai不得綁假的 RoomAccessSession;UI只顯示 reserved / unavailable,正式 AI controller credential等 P3。

9.4 Player Seat Character selection

選擇時 transaction驗:

DM / spectator seat沒有 Player Active Character。

9.5 Human一人多 Seat

controller_access_session_id不 unique;同一 Human access session可以控制 Seat 1、Seat 2。

Seat的 selected_character_id每 seat最多一個。

9.6 Presence

Lobby Connected / Offline由 P2-A RoomAccessSession heartbeat contract推導:30秒 heartbeat、90秒 timeout;不把 Seat row delete當掉線。

P2不使用 arrival timestamp決定遊戲順序。

9.7 Permission / DM assignment

產品規格的「Owner Key:DM assignment」在 P2具體化如下:

其餘:

因第一版沒有 account ownership,Seat binding就是 active table期間最重要的 player write scope。


10. P2-E — Session Lifecycle & Late Join

10.1 Tables

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保留。

10.2 為什麼要 lease table

只靠 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。

10.3 Start transaction

SessionService.start_session()一個 transaction內:

  1. lock Campaign / relevant Seats。
  2. 確認沒有該 Campaign既有 active Session。
  3. caller是由 Owner assignment到該 DM Seat的 Human controller,且 Room authority ∈ {dm, owner};僅持 DM Key但未被 Owner assign、或僅有 Owner authority但未被 assign,都不能 Start。
  4. validate每個 Player Seat selection。
  5. create Session。
  6. create participant snapshots。
  7. insert all Character leases。
  8. 任一 collision → rollback整個 Start。

不允許「Session建好了但第三隻角色 lease失敗」的半成品。

10.4 DM Controller fixed

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。

10.5 Player controller與 Active Character

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

10.6 Late Join transaction

Late Join只能由本場 current DM Controller發起:

  1. 配置/選一個尚未參與本 Session的 Player Seat(或建立新 Seat)。
  2. validate Roster status / Room scope / archive state。
  3. insert participant。
  4. insert Character lease。
  5. collision rollback。

P2不要求 spawn point。

10.7 End / Abandon

End Session:只有 current DM Controller可執行;Owner / 其他 DM authority不是 substitute DM。

Abandon Session:current DM Controller或 Owner可執行,用於誤開/DM不再回來。

兩者 transaction:

P3 AI tokens存在後,Session end hook再加 token revoke;P2只保留 service hook / TODO contract,不建立假 token table。

10.8 Session boundary vs P7 Snapshot

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。

10.9 Resume DTO

P2 Resume DTO只由目前 truth組成:

room
campaign
active_session?
participants[]
seat/controller presence
active character summaries

P3/P4 後新增 pending roll / combat fields時用 additive DTO擴充,不在 P2 回假資料。


11. Web Character authorization wrapper

P2-B 起,Web Character endpoint不能再只靠 character_id

讀取流程:

RoomAccessContext
↓
room_characters lookup
↓
CharacterRepository

11.1 無 active Session lease

如果 Character目前沒有 active Session lease:

11.2 有 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,不另造第二套身份系統。


12. Room Hard Delete transaction

因 Character Core沒有 room_id FK,DB無法單靠 ON DELETE rooms自動刪 Character。

RoomService.hard_delete_room()必須:

  1. require Owner authority + explicit confirmation token / confirmation phrase contract。
  2. lock Room。
  3. revoke RoomAccessSessions。
  4. gather Room-owned Character / Draft ids。
  5. delete P2 Session / Seat / Roster / Campaign rows(Room Hard Delete可以先清歷史 reference再刪 Seat;一般 Seat delete仍受 §9.1 RESTRICT / archive policy約束)。
  6. delete associated Builder Draft rows(含 confirmed provenance draft rows)。
  7. delete associated Character State / Versions / import records / Character rows using neutral persistence primitives。
  8. delete Room association rows。
  9. delete Room。
  10. commit once。

Room Hard Delete不逐隻套 individual historical delete guard;使用者已在 workspace層選擇永久刪除全部。

測試必須在 PostgreSQL驗完整 FK / orphan;neutral Character delete primitive仍需保有 SQLite coverage,但 standalone本身不執行 Room service。


13. API error / message contract

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。


14. Frontend structure

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。

14.1 既有 Web E2E 的 Room-first 遷移策略

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

硬規則:

因此 P2-A完成後,完整既有 Web E2E suite仍應可跑;P2-B收口 global /characters後,同一 suite繼續跑 Web,只是由 shared helper進 Room-scoped Workshop。

URL / navigation

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


15. P2 capability演進

每完成一個真正可用 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」遮。


16. Localization

P2所有新增 Web UI / server messages延續 M02:

使用者輸入的 Room / Campaign / Seat label / Character name不翻譯。

Room Code / keys / UUID不翻譯。

Server code + params與 locale文案分離;API不回硬編譯中文句子作唯一判斷依據。


17. P2 不得提前建立的 substrate

P2 可以留下 ID / boundary讓後續引用,但不得建立:

例如 P2 Session可以有 id / status / participants / timestamps;不能因為未來 Timeline要 reference Session,就現在先建一套 generic Event Store。


18. P2-F closeout engineering checklist

P2-F static review至少逐條確認:

  1. app.domain.character* reachable graph沒有 app.*.rooms
  2. app.standalone reachable graph沒有多人 module。
  3. standalone launcher target是 character@head
  4. standalone SQLite沒有 P2 web tables。
  5. Web migration target使用 heads;Docker / README / CI / active scripts / workflows / executable tests與standalone smoke都沒有 repo-global single-head假設,包括 stale upgrade headget_current_head()len(heads)==1、自行推導唯一 head、或把 Web alembic_version固定當單列 scalar。
  6. test_m03c_migration.py已是multi-head-aware;test_m03d_migration_sqlite.py / test_m03d_schema_parity.py鎖定 character@head
  7. scripts/smoke_standalone.pytest_m03e_smoke_script.pytest_m03e_alembic_bundle_ready.pytest_m03e_launcher_headless.py都已在 multiple-head graph中明確解析 Character branch,不再假設 0008 或 repo只有一個 head。
  8. M03 schema parity使用explicit Character table allowlist,不受 global MetaData import order影響。
  9. future shared Character migration有明確 character branch落點;Character ancestry永遠不含 web revision。
  10. Web revision若依賴 Character revision,只使用單向 dependency,不 merge branches。
  11. P2 Non-E2E有 PostgreSQL evidence;不能拿別的 workflow綠燈代替。
  12. test_m03e_capabilities.py與新 capability contract一致:Web room=true、Standalone room=false
  13. 既有 Web Playwright Character/content regression仍跑 Web channel;P2-A已有 shared Room bootstrap,P2-B只透過 shared seam切到 Room-scoped Workshop,沒有把 suite搬去 standalone或大量 skip。
  14. 既有 app.main Character / Builder HTTP regression已用共用 Room-aware client/context改成 scoped endpoint;m01k_support.py與其 consumers沒有因 global router收口失去 Web coverage。
  15. Web global Character API沒有 scope bypass。
  16. Room Character / Draft association transaction atomic。
  17. cross-room UUID access default deny。
  18. Roster不複製 CharacterState。
  19. campaign_seats.campaign_id ON DELETE CASCADEsessions.campaign_id ON DELETE RESTRICT與 Room Hard Delete explicit order一致;Campaign有 Session history時不可被 cascade hard delete。
  20. Seat被 Session history reference後只能 archive;Session Seat FK non-null / restrict。
  21. DM Seat assignment只有 Owner authority可變更;DM Key holder不能 self-assign。
  22. P2-A heartbeat endpoint存在且 P2-D presence只消費同一 contract。
  23. Room code / password / throttle policy與測試一致。
  24. active Character lease在 concurrent Session下由 DB invariant保護。
  25. Session End / Abandon不 mutate CharacterState。
  26. Start / Late Join / End只接受 current Session DM Controller所需 scope;Owner只額外允許 Abandon,不是 substitute DM。
  27. Character JSON v1不含 Room資料;P2-A exporter改動前已先提交最後 M03 unstable fixture,legacy parser仍存在。
  28. Room Hard Delete無 P2 / Character orphan。
  29. P2 user-visible copy雙語完整。

上述任何一條無證據,不得只以「UI看起來正常」關 P2。