adventure-table

M03 — 實作規格

Phase:M03 — Standalone Character Builder Distribution 類型:M Phase(Modification / Maintenance Phase) 插入時點:M01 Full Closeout 之後、P2 開工之前。 本文件定義 M03-A~M03-G 每個 Subphase 完成後什麼必須為真。具體資料格式、schema、module、API、migration、打包腳本與接線放在 開發設計方針.md;自動與人工驗收方式放在 測試指南.md

最後更新:2026-09-04


1. M03 定位

P0 / P1 已完成 Character Core、Character Builder、Level Up、Version History 與 Character Sheet;M01 進一步建立 Multi-Source Content Pack 與 PHB / SCAG / GoS / VGM / VRGR / TCE / XGE 大部分 character-relevant content;M02 建立 zh-TW / en 雙語永久基礎。

在進入 P2 — Room / Campaign / Session / Seat 之前,M03 把「目前已完成的角色相關功能」包成一個可下載、離線執行的單機創角版本出貨,並補上網頁版尚未存在的角色 JSON 匯出/匯入能力,讓網頁版與單機版之間、以及不同人的單機版之間可以互通角色資料。

M03 要解決的是:

  1. 規格企劃.md 第五章〈Character Workshop〉已允許「未加入 Room 前也能創角色,沒有帳號時可 browser-local + JSON」,但這條保證目前只存在於規格,沒有出貨。
  2. 網頁版目前沒有角色 JSON 匯出/匯入端點,朋友間無法傳遞角色。
  3. app/content/registry.pyREPOSITORY_ROOT 寫死了 repo 目錄結構,凍結打包後找不到 data/;同一個常數也已被 app/api/dependencies.py 引用,改動範圍不只兩處。
  4. psycopg[binary] 是無條件相依,任何凍結打包都會帶進 5–10 MB 用不到的相依。
  5. 目前 codebase 只有角色這一層,還沒有 Room 可以滲進來,這是把「單機版永遠只做創角、不做多人」界線釘死的最便宜時機。
  6. P2 之後角色資料模型仍會變動,但必須守住「角色可以脫離 Room 存在」的 schema 保證,否則單機版死亡。M03 是先把這條界線用 import boundary test 與 CI build 具現化,讓 P2 開工時已經有一個會失敗的 build 當守門員。

M03 不改寫正常 P0 → P1 → P2 Roadmap,也不推遲 P2:

M01-A
→ M01-B
→ M01-C
→ 暫停 M01
→ M02-A~M02-H
→ M02 closeout
→ 回到 M01-D
→ M01-E~M01-K(K 之後可能仍有其他 M01 Subphase)
→ M01 Full Closeout
→ M03-A~M03-G
→ M03 closeout
→ P2

2. M03 產品行為

2.1 兩個 entry point,同一份 codebase

不做第二份 repo,也不用 feature flag 散落在各處。

線上版 entry (apps/server/app/main.py)
  → 全部 router,含 P2 之後的 Room / Session / Seat
  → 由 docker-compose / production 部署驅動
  → 資料庫:PostgreSQL

單機版 entry (apps/server/app/standalone.py,M03-E 新增)
  → 只掛 content / character / builder / meta 相關 router
  → 不 import app.main
  → 由 PyInstaller 打包成單一可執行檔驅動
  → 資料庫:本機 SQLite 檔
  → 執行時自動開瀏覽器,關閉 launcher(Ctrl+C 或關 console)時收掉 process

同一份 domain code、同一份 rules、同一份前端 build,差別在:

單機版包裡不會出現半殘的多人 UI,靠 capability contract(見 2.2)而不是靠前端 build 時剔除路由。

2.2 前端 capability contract

GET /api/meta/capabilities兩個 entry point 共同掛載的 neutral endpoint,回傳當前 entry 所具備的 capability 表:

{
  "channel": "web" | "standalone",
  "capabilities": {
    "character_builder": true,
    "character_import_export": true,
    "room": false,
    "campaign": false,
    "session": false,
    ...
  }
}

2.3 單機版可見範圍

單機版 capability 表對使用者顯露的功能上限即目前已完成的角色相關功能

不包含、也永遠不會包含本質上就是多人的東西:Room、Campaign、Session、Seat、Combat、Timeline、AI Actor、DM 工具、帳號系統。這是定義,不是缺陷。

單機版前端 SPA 的 build 產物與線上版相同(同一 apps/web build)。P2 之後線上版新增的頁面會一併攜帶進單機版 SPA,但單機版 capability 表對應功能為 false,SPA 不 render 入口,backend 不掛 router;這是雙重機制決定的行為,不是缺陷。

2.4 角色 JSON 交換

單機版第一版就要有出有進,同一 JSON 格式在網頁版與單機版之間互通。

2.5 Builder Provenance 隨 version 匯出,也接回 Level Up / Build Edit

從 M03-B 開始,character_versions 表新增 nullable 欄位 builder_provenance(JSON)。Confirm 時把 Builder Draft 當時的 payload 快照寫入該欄位;legacy version(M03-B migration 之前 Confirm 的角色)該欄位為 NULL

匯出:每個 version 攜帶其 builder_provenance 快照(可能為 null)。

匯入且全 refs 解析成功:直接 append 為新角色。

匯入且有 build refs 未解析

Versioned draft seeding SSOT:M03-B 之後,Level Up / Build Edit / Correction 於載入 versioned draft seed 時,優先讀 character_versions.builder_provenance;缺才走 character_build_drafts 對應舊 draft row;再缺才走 legacy_payload_from_build()。這條 fallback chain 讓匯入落地的角色也能安全走後續 Level Up,不會退回 legacy reconstruction。

legacy_payload_from_build() 不作為匯入路徑上的反推手段;該函式本身需要 registry 完整,缺 pack 情境根本走不到終點。

2.6 版本相容性

M03 出貨時 JSON schema 尚未鎖定。

2.7 SQLite 是保存庫,不是快取

2.8 Enabled content pack SSOT

2.9 平台範圍

M03 第一版只出 Windows x64

理由:

但 launcher 與 build 腳本必須寫成平台中性(不寫死 C:\、不假設路徑分隔符、統一用 pathlib)。之後要加 Linux build 就只是 CI matrix 多一列,不是重寫。

2.10 打包產物與資料根

朋友不自己打包。 打包由兩條路徑產生:

朋友拿到的東西是一個 .zip

資料根位置決策


3. M03 共通硬原則

3.1 網頁版行為不得回歸

M03 是加功能加封裝,不改既有功能。

3.2 角色可以脫離 Room / Campaign / Session / Seat 存在

這是 M03 的政治承諾,也是 P2 的 schema 約束。

3.3 匯入必須走 domain validation,不得產生假資料

網頁版與單機版的匯入端點必須守住 P1 立場:正式 Build 不可以是假資料

3.3.1 State-only 缺 ref 的 UX 明示

3.4 Ref walker 涵蓋 build + state

3.5 匯入 / 匯出邏輯 server-authoritative

3.6 單機版可見範圍靠 capability contract 與 router 掛載雙重決定

3.7 Localization 交付對稱

3.8 Standalone 打包成本不得回流到網頁版


4. M03 Subphase 順序

M03-A — Content Root Path Abstraction & Enabled-Pack SSOT
→ M03-B — Character JSON Schema, Export & Builder Provenance
→ M03-C — Character JSON Import via Builder Draft
→ M03-D — SQLite Migration Chain Gate & FK PRAGMA
→ M03-E — Standalone Packaging & Launcher
→ M03-F — Windows CI Build, Release & Import Boundary Test
→ M03-G — Full M03 Integration & Closeout

每個 Subphase 必須能獨立實作、驗證並 commit;完成時應處於可執行、可測試、沒有已知編譯/型別/該 Subphase 測試錯誤的狀態。

M03-B 完成後,網頁版即已具備角色 JSON 匯出能力builder_provenance 已隨新 version 落地,這是可獨立驗證的產品增量。M03-C 完成後,網頁版即已具備完整 round-trip 匯入能力。M03-E 之前,尚未有可下載的單機版產物,但角色交換已可行。這個順序讓 JSON 交換的價值早出,也讓打包相關卡關(PyInstaller / SmartScreen 誤判)不會綁住產品增量。


M03-A — Content Root Path Abstraction & Enabled-Pack SSOT

目標

把目前 app/content/registry.py 寫死的 REPOSITORY_ROOTCONTENT_PACKS_ROOTDEFAULT_CONTENT_ROOTDEFAULT_SRD_CONTENT_ROOTapp/domain/character_builder/rules.pyRULES_PATH,以及 app/api/dependencies.py 引用的 CONTENT_PACKS_ROOT(用於 localization loader)等所有路徑常數改成經一組可覆寫的解析器取得,並把 enabled pack 清單從 DEFAULT_CONTENT_PACKS 常數改為 Settings.enabled_content_packs

完成後必須為真

A.1 解析順序固定

Content root 解析順序:

  1. 明確環境變數 ADVENTURE_TABLE_CONTENT_ROOT(若非空且指向存在目錄)。
  2. Frozen mode(sys.frozen):<executable directory>/data
  3. Frozen mode fallback:sys._MEIPASS / "data"(僅在 exe_dir 未含 data/ 時退回,供 dev iteration 或早期 spec 遺留使用)。
  4. Repo 相對路徑:現況邏輯(自 app/paths.py 起算 parents[3] / "data"),開發模式使用。

Rules JSON、localization root 一律從 content root 推導;不獨立於別處硬編。

A.2 現行網頁版行為不變

A.3 應用啟動不再於 import time 依賴 filesystem 位置

A.4 Repo-wide sweep

於本 subphase 內完成以下清理,一次到位:

A.5 Enabled pack SSOT

A.5.1 Subset registry 的 unresolved 語意(M01-A 契約收窄)

Subset 載入要成立,就必須放寬 registry load 時的 dangling reference 檢查——phb2014 會引用 scag / tce,只 enable 部分 pack 時舊規則必定失敗。但放寬只能放到「已安裝但本次未啟用」為止:

這條收窄了 M01-A 原本的「跨 pack 依賴必須被 enable,否則 registry load 失敗」。原契約的兩半只有一半能保留:subset 載入是 M03-A 的硬需求,所以「未 enable 就失敗」讓位;「source 打錯字要炸」則完整保留,M01-J / M01-L / M01-M 依賴的 registry load 身分守門不因 M03 而失效。tests/test_m01a_content_packs.py 內原本的 test_cross_pack_dependency_must_be_enabled 已改寫成兩條測試分別守住上述兩半。

A.6 Settings 環境變數相容性

本 Subphase 不要求


M03-B — Character JSON Schema, Export & Builder Provenance

目標

在網頁版與(未來的)單機版都提供 GET /api/characters/{id}/export,讓使用者從 Character Workshop 或 Character Sheet 下載一份內含完整 version chain 1..N、current state、每個 version 的 builder_provenance 與 envelope metadata 的 JSON 檔。同時新增 character_versions.builder_provenance 欄位,讓後續 M03-C 於缺 pack 情境能重建 CREATE Draft。

完成後必須為真

B.1 Migration:character_versions.builder_provenance

B.2 Confirm 寫入 builder_provenance

B.2.1 Versioned draft seeding SSOT

B.3 匯出端點

B.4 Envelope

Envelope 至少包含以下欄位:

B.5 Payload

Payload 包含:

不含

B.6 前端匯出入口

B.7 不變性

本 Subphase 不要求


M03-C — Character JSON Import via Builder Draft

目標

在網頁版與(未來的)單機版都提供 POST /api/characters/import(JSON body),讓使用者從匯入 dialog 選檔或貼上 JSON,把角色匯入為 Builder Draft(若引用有缺且有 builder_provenance)或直接 append 為新角色(若全部解析得到且通過 domain validation)。同時建立單一 build + state ref 收集器domain-validator import pipeline,供本 phase 與未來 P7 共用。

完成後必須為真

C.1 匯入端點

C.2 Ref 收集器:build + state

C.3 解析預檢

匯入分兩階段:

  1. Preview 階段POST /api/characters/import?dry_run=true,執行以下依序:
    1. Raw body / size gate / JSON parse / CharacterExport.model_validate(C.1)。
    2. Envelope shape / ruleset / schema_status 檢查(不通過即 400,見 C.7)。
    3. Version chain 一致性檢查(version_no 連續、current_version_no 存在於 chain、parent_version_no / superseded_by_version_no 指向 chain 內、current_state 對應 current_version_no)。
    4. Version lineage 完整性檢查:self-reference、direction、cycle(見 3.3)。
    5. 每個 version 的 build_payloadCharacterBuild.model_validate()(不通過即 400 invalid_build_shape)。
    6. 每個非 null builder_provenanceBuilderDraftPayload.model_validate()(不通過即 400 invalid_builder_provenance)。
    7. current_state.state_payloadCharacterState.model_validate()(不通過即 400 state_shape_invalid)。
    8. Ruleset 三重 cross-check(envelope / character / 各 build):不一致 → 400 ruleset_mismatch
    9. 收集 build_refs(每個 version)與 state_refs。
    10. 對每個 ref 呼叫 registry.get_optional;分類 resolved / unresolved。
    11. 判定 landing_mode:
      • unresolved 為空 → 逐 version 執行 validate_build_references(build, registry);對 current_version 對應的 build 執行 validate_state_against_build(state, build, registry);全部通過 → landing_mode="character";任一不通過 → 400(build_references_invalid / state_inconsistent_with_build)。
      • build refs 有未解析 → 取 versions[current_version_no].builder_provenance;為 null → 400 draft_reconstruction_unavailable;有效 → landing_mode="draft"
      • build refs 全解、僅 state refs 有未解析 → 同上取 provenance;有效 → landing_mode="draft_with_history_loss"(UI 明示 history / state 將放棄,見 3.3.1);provenance null → 400 draft_reconstruction_unavailable
    12. duplicate_hint(見 C.5)。
    13. character_preview(角色名、最高等級、class 摘要)。
  2. Commit 階段POST /api/characters/import(不帶 dry_run),實際落地;再走一次上述檢查,通過後 commit。

前端 UI 一律先呼叫 dry-run,顯示可解析 / 未解析數字與 landing_mode 給使用者確認後才 commit。沒有預設門檻,是否繼續由使用者決定。

C.4 落地邏輯

C.5 Identity 與重複偵測

C.6 歷史版本處理

C.7 拒絕條件

匯入直接拒絕(原子拒絕、零副作用)的情況:

未解析 refs 本身不是拒絕條件;有 provenance 則落地為 Draft(或 draft_with_history_loss),無 provenance 則以 draft_reconstruction_unavailable 拒絕。

C.7.1 Pydantic 階段的 code 映射

schema_statusLiteral["unstable", "locked"]version_kind 是 strict enum,兩者都在 CharacterExport.model_validate() 就失敗,到不了任何語意檢查步驟。若不特別處理,它們會被歸成泛用的 invalid_envelope_shape / invalid_payload_shape,而 C.7 又要求 unsupported_schema_statusinvalid_version_kind 兩個專屬 code,兩條驗收無法同時成立。

因此 import endpoint 捕捉 CharacterExport.model_validate()ValidationError 後,必須依每筆 error 的 loc 做以下映射:

loc 命中 code
schema_status unsupported_schema_status
version_kind invalid_version_kind
schema_version invalid_envelope_shape
其餘 envelope 欄位 invalid_envelope_shape
其餘 payload 欄位 invalid_payload_shape

決定性規則(兩條都必須成立,否則同一份輸入可能回不同 code):

對應地,envelope 語意檢查階段只負責 ruleset(unsupported_ruleset / ruleset_mismatch);schema_status 不在該階段判定。

C.8 Import records

新增 character_import_records 表(migration 隨本 subphase 提交,檔名編號依當時 Alembic head 的下一個 revision,不寫死 0007):

C.9 前端匯入入口

C.10 不變性

本 Subphase 不要求


M03-D — SQLite Migration Chain Gate & FK PRAGMA

目標

把「整條 Alembic migration 鏈可以在 SQLite 上 upgrade head 到完整 schema、外鍵語意在 SQLite 上被啟用」從假設變成 CI-enforced 事實。M03 靠這條路徑升級單機版使用者的本機資料庫;沒有這道 gate,2.7 節的保存庫承諾無法兌現。

完成後必須為真

D.1 SQLite migration 綠燈

D.2 Schema 一致性

D.3 SQLite FK PRAGMA

D.4 Postgres 行為不變

D.5 psycopg 相依可選

本 Subphase 不要求


M03-E — Standalone Packaging & Launcher

目標

用 PyInstaller 把單機版打包為可下載、離線執行的 Windows x64 產物;launcher 負責啟動 uvicorn、跑 alembic upgrade、開瀏覽器、顯示資料檔路徑、提供結束方式。同時建立 /api/meta/capabilities neutral endpoint、SPA fallback route、以及 app.standaloneapp.main 的完全隔離。

完成後必須為真

E.0 Minimal frozen smoke gate

這條先於 E.7 的完整 spec 完成,是 M03-E 內部的順序要求。

PyInstaller + Alembic + Uvicorn 這一疊的風險不在架構,而在只有真的 freeze 成 exe 才知道漏了哪個 dynamic import / resource / 路徑_MEIPASS 下的 alembic ScriptDirectoryuvicorn.Config("app.standalone:app", ...) 的字串式 import、hiddenimports 缺漏,unit test 一個都測不到。若等 E.1~E.10 全部堆完才第一次 freeze,這些失敗會一次爆出且難以歸因到單一層。

因此必須先有一個最小 frozen 產物通過:

E.1 Neutral shared modules

E.2 單機版 entry point

E.3 Capability endpoint

E.4 Launcher

E.5 資料檔路徑

E.6 Content root / SPA root

E.7 PyInstaller spec

E.8 Build 腳本

E.9 前端 build 一致性

E.10 啟動可觀察行為

本 Subphase 不要求


M03-F — Windows CI Build, Release & Import Boundary Test

目標

把 M03-E 的打包從「使用者本機能跑」升級為「CI 綠燈可 release」,並用 import boundary test 把 3.2 節的界線由文件轉為機器保證。

完成後必須為真

F.1 Windows CI job

F.2 Release 產物

發版一律在本機進行,CI 不建立 GitHub Release。

決策理由:本專案為朋友間私人使用,repo 將轉為 private。Private repo 的 Release 頁面與 asset 都需要 repo 讀取權限,沒有匿名下載連結,因此 GitHub Release 無法達成「把 zip 連結給朋友」的目的,只是多一條要維護與驗證的通路。

F.3 Import boundary test

F.4 Standalone composition test

F.5 網頁版行為不變

本 Subphase 不要求


M03-G — Full M03 Integration & Closeout

目標

把 M03-A~M03-F 建立的所有 pieces 串成一次真實的 end-to-end 體驗,確認網頁版 → 單機版、單機版 → 網頁版、缺 pack 情境、Draft 補洞情境、legacy 角色拒絕情境都跑得通;並把 M03 的產品行為、已知限制與後續 P2 依賴的界線正式關門。

完成後必須為真

G.1 End-to-end round trip

以下路徑至少完整跑過一輪並保留證據:

  1. W → S 完整:網頁版建立一個 Lv5 Multiclass 角色(含 subclass 選擇、starting equipment、prepared spell、live inventory 有變動)→ 匯出 JSON → 於單機版匯入 → dry-run 全部解析(build + state)→ commit → Character Sheet 呈現與網頁版一致,Version History 完整。
  2. S → W 完整:單機版建立一個 Lv3 角色 → 匯出 → 網頁版匯入 → 相同結果。
  3. W → S 缺 pack:網頁版建立一個引用 xge 內容的角色 → 匯出 → 於一個 enabled_content_packs 未含 xge 的單機版設定匯入 → dry-run 顯示未解析數與 origin → landing_mode = draft → 使用者於 Builder 中改選 → Confirm 成為 Version 1。
  4. State-only 缺 ref:Build 全部可解析、但 Current State 的 inventory 帶一個 pack subset 中缺失的 item → dry-run 顯示 landing_mode=draft_with_history_loss(因 state ref 未解析)→ 走 Draft 補洞。
  5. 重複匯入偵測:同一份 export 於同一台單機版匯入第二次 → dry-run 顯示 duplicate_hint → 使用者仍可繼續 → 兩個角色並存。
  6. Legacy character 缺 pack 拒絕:一個 legacy 角色(builder_provenance = NULL)匯出 → 於缺對應 pack 的環境嘗試匯入 → 400 draft_reconstruction_unavailable;DB 無殘留。

G.2 網頁版功能不回歸

G.3 單機版可下載產物

G.4 M03 已知限制記錄

docs/M03/M03-G_CLOSEOUT.md 明列:

G.5 P2 依賴的界線正式生效

G.6 Localization 同步

本 Subphase 不要求