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
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 要解決的是:
規格企劃.md 第五章〈Character Workshop〉已允許「未加入 Room 前也能創角色,沒有帳號時可 browser-local + JSON」,但這條保證目前只存在於規格,沒有出貨。app/content/registry.py 的 REPOSITORY_ROOT 寫死了 repo 目錄結構,凍結打包後找不到 data/;同一個常數也已被 app/api/dependencies.py 引用,改動範圍不只兩處。psycopg[binary] 是無條件相依,任何凍結打包都會帶進 5–10 MB 用不到的相依。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
不做第二份 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,差別在:
GET /api/meta/capabilities 回傳的 capability 值。單機版包裡不會出現半殘的多人 UI,靠 capability contract(見 2.2)而不是靠前端 build 時剔除路由。
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,
...
}
}
Navigation / Landing 依此決定顯示哪些入口。false 的 route 於 SPA 中不 render 入口按鈕;若使用者手動打開 URL,SPA 顯示對應的 capability_disabled 頁面。room=false 的 entry 不會掛 /api/rooms/* router,讓兩端保持一致的行為。if (standalone) hide(...) 分支;差別集中在 capability 表這一份宣告。單機版 capability 表對使用者顯露的功能上限即目前已完成的角色相關功能:
zh-TW / en 語言切換character_import_export=true)不包含、也永遠不會包含本質上就是多人的東西:Room、Campaign、Session、Seat、Combat、Timeline、AI Actor、DM 工具、帳號系統。這是定義,不是缺陷。
單機版前端 SPA 的 build 產物與線上版相同(同一 apps/web build)。P2 之後線上版新增的頁面會一併攜帶進單機版 SPA,但單機版 capability 表對應功能為 false,SPA 不 render 入口,backend 不掛 router;這是雙重機制決定的行為,不是缺陷。
單機版第一版就要有出有進,同一 JSON 格式在網頁版與單機版之間互通。
POST /api/characters/import 與 GET /api/characters/{id}/export。json.loads 與明確 CharacterExport.model_validate(不依賴 FastAPI typed body 的預設 422 validation_failed,見 3.5 與開發設計方針 §5.1)。前端檔案選擇於瀏覽器端 File.text() 讀出後以 JSON body POST(不使用 multipart,也不新增 python-multipart 相依)。builder_provenance(見 2.5)、envelope metadata。builder_provenance,見 2.5),讓使用者在 Builder 中補完缺口,Confirm 才成為正式 Character。landing_mode="draft";Current State 引用的 ref 有缺 時 landing_mode="draft_with_history_loss",UI 明示「補完後會產生新的 Version 1,本次匯入的 Current State 與完整 Version History 都會放棄」,使用者按繼續才落地。從 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 未解析:
builder_provenance 存在且通過 BuilderDraftPayload.model_validate(),取該 provenance 作為 CREATE Draft 起點,未解析 ref 對應的 choice 清為未填,landing_mode="draft"。builder_provenance 為 null 或無法 validate,拒絕匯入並回傳 code draft_reconstruction_unavailable / invalid_builder_provenance,不假裝能修。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 情境根本走不到終點。
M03 出貨時 JSON schema 尚未鎖定。
adventure-table.sqlite3。ADVENTURE_TABLE_DATABASE_PATH 覆寫(例:讓使用者把資料檔放到 OneDrive 目錄)。paths.resolve_database_url()。若 ADVENTURE_TABLE_DATABASE_PATH 有值 → 回傳 sqlite+pysqlite:///<abs path>;否則回傳 settings.database_url(保留 Docker DATABASE_URL 語意不變)。app/api/dependencies.py:get_database_engine、alembic/env.py 與 launcher 都必須走這條 resolver;沒有其他讀取 settings.database_url 的路徑。.sqlite3。alembic upgrade head,與網頁版共用同一條 migration 鏈;app.standalone startup 不再次呼叫 alembic。PRAGMA foreign_keys=ON,讓 ON DELETE SET NULL 等外鍵語意生效。app/content/registry.py 內定義 DEFAULT_CONTENT_PACKS = ("srd5.1",);app/content/__init__.py 於 import 時直接 monkey-patch _registry.DEFAULT_CONTENT_PACKS = (...) 塞入完整 9 pack 清單(srd5.1 / phb2014 / scag / gos / vgm / vrgr / tce / xge / mtf)。這條 monkey-patch 是目前完整 pack 清單的實際來源。registry.py 內的 DEFAULT_CONTENT_PACKS 常數,與 content/__init__.py 對它的 monkey-patch。改為由 Settings.enabled_content_packs 提供,可經環境變數 / 測試 fixture 覆寫。ADVENTURE_TABLE_ENABLED_CONTENT_PACKS=srd5.1,phb2014。pydantic-settings 把 tuple[str, ...] 視為 complex type,EnvSettingsSource 會在任何 validator 之前先對環境變數值做 JSON decode,comma string 因此以 SettingsError 失敗(已於 pydantic-settings 2.15.0 實測)。欄位必須標成 Annotated[tuple[str, ...], NoDecode] 關掉該欄的自動 decode,再由 field_validator("enabled_content_packs", mode="before") 明確 split。xge),不靠刪除仍 enabled 的 pack 目錄——刪目錄會直接讓 registry startup 失敗。M03 第一版只出 Windows x64。
理由:
但 launcher 與 build 腳本必須寫成平台中性(不寫死 C:\、不假設路徑分隔符、統一用 pathlib)。之後要加 Linux build 就只是 CI matrix 多一列,不是重寫。
朋友不自己打包。 打包由兩條路徑產生:
scripts\build-standalone.cmd --version <版本>,產出 dist\adventure-table-standalone-<版本>.zip,自行保存與分發(見 F.2)。windows-latest job 跑同一支腳本並上傳 workflow artifact,只作為「乾淨環境能建置」的證據,不是發版通路,也不建立 GitHub Release。朋友拿到的東西是一個 .zip:
adventure-table-standalone/)。adventure-table.exe(PyInstaller launcher)、_internal/ PyInstaller 相依、data/(content pack 與 rules)、web/(前端 SPA build)、LICENSE.txt、README-standalone.txt。資料根位置決策:
data/ 與 web/ 於 release 產物中放在執行檔同層目錄(<exe_dir>/data、<exe_dir>/web),不打包進 PyInstaller bundle。<exe_dir>/data(見 M03-A)。M03 是加功能與加封裝,不改既有功能。
zh-TW / en 下的 presentation 與 M02-H closeout 觀察一致(M03 開工時基線更新為 M03-A start baseline;見 2.8)。這是 M03 的政治承諾,也是 P2 的 schema 約束。
characters 表不得長出 room_id / campaign_id / session_id 欄位;未來 P2 的 Campaign 歸屬走 Party Roster join table。active / inactive / retired / dead)住在未來 roster row,不是 character row。app/content、app/domain/character*、app/persistence/characters、app/persistence/builder_drafts 與 app/api/character*、app/api/reference、app/api/content_presentation 不得 import 任何 room / session / seat 模組。app/standalone 不得 import app/main;共用邏輯(exception handler、meta router 等)住 neutral module。網頁版與單機版的匯入端點必須守住 P1 立場:正式 Build 不可以是假資料。
build_payload 必經 CharacterBuild.model_validate();不通過即拒絕(invalid_build_shape)。version_kind 於 JSON schema 上是 strict enum(與 persistence 對齊,非 str);不通過即拒絕(invalid_version_kind)。builder_provenance 必經 BuilderDraftPayload.model_validate();不通過即拒絕(invalid_builder_provenance)。validate_build_references(build, registry);不通過即拒絕(build_references_invalid)。CharacterState.model_validate(),並對 current_version_no 對應的 build 執行 validate_state_against_build(state, build, registry);不通過即拒絕(state_shape_invalid / state_inconsistent_with_build)。envelope.ruleset == character.ruleset == 每個 CharacterBuild.ruleset;任何不一致 → 拒絕 ruleset_mismatch。parent_version_no 不得 self-reference;parent_version_no 必須 < 自身 version_no;superseded_by_version_no 必須 > 自身 version_no;chain 上不得有環。任何違反 → version_lineage_direction_invalid / version_lineage_cycle / version_lineage_self_reference。builder_provenance。ContentNotFoundError 語意不改。landing_mode="draft_with_history_loss"。build_payload 與 current_state.state_payload 各跑一次 ref 收集。draft。{origin: "build" | "state", version_no?, stable_key, kind, pack, index} 呈現,讓 UI 能標示「不見的引用來自 Version 3 的 build 還是 current state 的 inventory」。/api/meta/capabilities;capability false 的功能不 render 入口。app.standalone 不掛未來 Room / Session router;前端誤打對應 URL 時,capability 表已標示不支援,SPA 顯示 capability_disabled 頁面,backend 也回 404。if (channel === "standalone") ... 散落分支;分流只集中在 nav manifest 讀 capability 表這一處。capability_disabled 頁面文字、README-standalone)於 M03-B / M03-C / M03-E / M03-G 完成當下必須同步交付 zh-TW / en 兩語,缺任一視同該 Subphase regression。這是 M02-H 建立的永久 supported-locale 交付守則。true(本 phase 只保證單機端為 false)。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 誤判)不會綁住產品增量。
把目前 app/content/registry.py 寫死的 REPOSITORY_ROOT、CONTENT_PACKS_ROOT、DEFAULT_CONTENT_ROOT、DEFAULT_SRD_CONTENT_ROOT,app/domain/character_builder/rules.py 的 RULES_PATH,以及 app/api/dependencies.py 引用的 CONTENT_PACKS_ROOT(用於 localization loader)等所有路徑常數改成經一組可覆寫的解析器取得,並把 enabled pack 清單從 DEFAULT_CONTENT_PACKS 常數改為 Settings.enabled_content_packs。
Content root 解析順序:
ADVENTURE_TABLE_CONTENT_ROOT(若非空且指向存在目錄)。sys.frozen):<executable directory>/data。sys._MEIPASS / "data"(僅在 exe_dir 未含 data/ 時退回,供 dev iteration 或早期 spec 遺留使用)。app/paths.py 起算 parents[3] / "data"),開發模式使用。Rules JSON、localization root 一律從 content root 推導;不獨立於別處硬編。
ADVENTURE_TABLE_CONTENT_ROOT 且非凍結環境下,解析結果與 M02-H closeout 觀察完全一致。ADVENTURE_TABLE_ENABLED_CONTENT_PACKS 時,Settings.enabled_content_packs fallback 為「M03-A start baseline 完整 pack 集合」。load_default_content_registry() 載入的 pack 集合、entry 集合、locale overlay 集合、rules JSON 皆不變。app/api/dependencies.py 對 localization loader 的呼叫仍成立,走同一 resolver。app/main.py / app/standalone.py import time,但路徑解析必須以函式形式(resolve_content_root() 等)取得,允許 PyInstaller launcher 起 uvicorn 前就設定環境變數。env / frozen-exe-dir / frozen-meipass / repo-relative)。於本 subphase 內完成以下清理,一次到位:
app/content/registry.py 內 REPOSITORY_ROOT、CONTENT_PACKS_ROOT、DEFAULT_CONTENT_ROOT、DEFAULT_SRD_CONTENT_ROOT 常數;改為 resolve_content_root() / resolve_srd_content_root() 等函式呼叫。app/content/__init__.py 對 _registry.DEFAULT_CONTENT_PACKS 的 monkey-patch;完整 pack 清單改由 Settings.enabled_content_packs 提供。此檔目前的 install_m01l_content_models() / install_m01m_content_models() 等 side effects 保留。app/domain/character_builder/rules.py 的 RULES_PATH 常數;改為 resolve_rules_path()。app/api/dependencies.py 對 CONTENT_PACKS_ROOT 的 import 與 get_database_engine 對 settings.database_url 的直接讀取,改為呼叫 resolve_content_root() 與 resolve_database_url()。alembic/env.py 兩處對 settings.database_url 的直接讀取,改為 resolve_database_url();migration URL 因此隨 ADVENTURE_TABLE_DATABASE_PATH 切換為 SQLite 時 launcher 也能跑 upgrade。Path(__file__).resolve().parents[N] 用於路徑推導的位置,若指向 repo root,全面統一為呼叫 resolver。module.CONSTANT = ... 形式對已移除常數的 legacy monkey-patch;禁止 from app.content.registry import CONTENT_PACKS_ROOT 等 legacy import。Settings.enabled_content_packs: Annotated[tuple[str, ...], NoDecode],可經 ADVENTURE_TABLE_ENABLED_CONTENT_PACKS(逗號分隔字串)覆寫。NoDecode 是必要的:沒有它,EnvSettingsSource 會先把該欄當 complex type 做 JSON decode 而以 SettingsError 失敗,field_validator 根本收不到字串。關掉自動 decode 後,再由 field_validator("enabled_content_packs", mode="before") 明確把 comma string 切為 tuple、同時接受 list/tuple。("srd5.1", "phb2014", "scag", "gos", "vgm", "vrgr", "tce", "xge", "mtf")(M03-A start baseline;若 M01-K 之後新增 pack,缺省值同步更新)。load_default_content_registry() 讀 settings.enabled_content_packs,不再依賴 DEFAULT_CONTENT_PACKS 常數或 content/__init__.py 的 monkey-patch。enabled_content_packs=("srd5.1", "phb2014"))以模擬缺 pack 情境,不靠刪目錄。Subset 載入要成立,就必須放寬 registry load 時的 dangling reference 檢查——phb2014 會引用 scag / tce,只 enable 部分 pack 時舊規則必定失敗。但放寬只能放到「已安裝但本次未啟用」為止:
enabled_content_packs 內 → ref 保持 unresolved,registry 正常載入。這是 M03-C import preview 要分類的情況,不是內容損壞。ContentValidationError,訊息維持 dangling reference。ContentRegistry._validate_cross_references()、builder_content_validation 的 _require_key() / _validate_spell_relation()、background_roleplay 的 inheritance resolver。kind 不符(例如 background 欄位填了 race key)永遠 raise,不受 disabled 放行影響。ContentRegistry 因此需要同時記住 enabled_pack_ids 與 installed_pack_ids;後者於 from_root() 由 content root 目錄掃描得出。這條收窄了 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已改寫成兩條測試分別守住上述兩半。
Settings 的 env_file=".env" 與缺省行為;不啟用 env_prefix="ADVENTURE_TABLE_"。原因:Docker Compose 目前傳 DATABASE_URL(沒有 ADVENTURE_TABLE_ 前綴),啟用 prefix 會直接 break 網頁版部署。content_root / database_path / spa_root / enabled_content_packs)以 Field(validation_alias=AliasChoices("ADVENTURE_TABLE_CONTENT_ROOT", ...)) 明列環境變數名,逐個欄位掛 alias。database_url 欄位維持既有 DATABASE_URL 語意,不觸動。/api/meta/capabilities router。在網頁版與(未來的)單機版都提供 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。
character_versions.builder_provenancecharacter_versions 加 builder_provenance JSONB NULL(Postgres)/JSON NULL(SQLite 走 with_variant)。NULL。<next>_m03b_builder_provenance.py)。builder_provenanceBuilderDraftPayload.model_dump())。superseded_by_version_id 時,寫入該 correction draft 的 snapshot。BuilderDraftPayload shape data。NULL。apps/server/app/domain/character_builder/versions.py 內載入 versioned draft seed 的路徑(現行第 344 行附近 source_payload 決定邏輯)。character_versions.builder_provenance(非 null 且通過 BuilderDraftPayload.model_validate())。character_build_drafts 對應 base_version 的舊 draft row。legacy_payload_from_build(character, registry)。legacy_payload_from_build 保留但降級為最後 fallback。GET /api/characters/{id}/export 回傳 application/json,Content-Disposition: attachment 給合理檔名(含角色名 sanitized 版本與 export 時間戳)。archived_at,見 B.5)。Envelope 至少包含以下欄位:
schema_version:M03 期間定為 "unstable";P2 lock 之後改為正式 semver。schema_status:"unstable" 或 "locked";M03 期間一律 "unstable"。ruleset:來自 characters.ruleset,M03 期間為 "dnd5e-2014"。content_requirements:本角色 build_payload + state_payload 引用到的所有 pack 版本清單(例:[{"pack": "srd5.1", "version": "1.0.0"}, {"pack": "phb2014", "version": "..."}])。清單來自實際 refs 走訪,version 由匯出當下 registry 中對應 pack 的 manifest version 提供。stable_key_refs_summary:本角色 build_refs + state_refs 總數(診斷用整數,內容細節不進 envelope)。source_character_id:原始 characters.id(UUID)。source_export_id:本次 export 產生的唯一 UUID,讓匯入端可判斷「同一份 export 匯了兩次」。source_app:{"name": "adventure-table", "channel": "web" | "standalone", "commit": ...}。exported_at:ISO 8601 UTC 時間戳。Payload 包含:
character:characters row 的 name、ruleset(不含 id、current_version_id、archived_at、created_at、updated_at)。current_version_no:整數,指向 versions 中對應「當前 Character 顯示的那個 version」的 version_no;不假定 = max。versions:完整 version chain,依 version_no 遞增。每個 version 帶:
version_noversion_kind(strict enum,對齊 persistence 現行 version_kind 值集合,非任意 str)parent_version_no(nullable,將原本 parent_version_id 映射為 chain 內的 version_no;匯入時再 map 回新 UUID)superseded_by_version_no(nullable,correction lineage)change_notebuild_payloadbuilder_provenance(nullable,見 B.2;legacy version 為 null)created_atcurrent_state:character_states.state_payload,視為綁定 current_version_no。不含:
archived_at(匯入時一律成為啟用中的角色,見 C.5)。id / current_version_id / updated_at 等),因匯入端會重新產生。zh-TW / en 雙語 UI 同步交付。characters.py 端點 signature 除新增 /export 外一律不變。/api/meta/capabilities(延至 M03-E)。content_requirements 的細節:只列 pack 版本,不列 StableKey 名單。在網頁版與(未來的)單機版都提供 POST /api/characters/import(JSON body),讓使用者從匯入 dialog 選檔或貼上 JSON,把角色匯入為 Builder Draft(若引用有缺且有 builder_provenance)或直接 append 為新角色(若全部解析得到且通過 domain validation)。同時建立單一 build + state ref 收集器與 domain-validator import pipeline,供本 phase 與未來 P7 共用。
POST /api/characters/import 接收 application/json body(envelope + payload);不接受 multipart。await request.body()),依序執行:size gate(超過 5 MB → 413)→ json.loads(失敗 → 400 invalid_envelope_shape)→ CharacterExport.model_validate(失敗 → 依 error location 對應 400 invalid_envelope_shape 或 400 invalid_payload_shape)→ 後續 pipeline。不使用 FastAPI 的 typed body pydantic 綁定,避免 RequestValidationError 走全域 422 validation_failed 覆蓋這批 machine code。collect_build_refs(build_payload) -> Iterable[ContentRef]。collect_state_refs(state_payload) -> Iterable[ContentRef]。CharacterBuild 中所有 pack-sourced content:race / subrace / lineage / background / class / subclass / feature choice / spell / feat / infusion / maneuver / fighting style / starting equipment 等。conditions[].condition_refprepared_spells[].spell_keyinventory_state.inventory_entries[].item_ref(或現行 InventoryEntry 結構等效欄位)active_infusions[].infusion_refspell_storing_item.spell_ref匯入分兩階段:
POST /api/characters/import?dry_run=true,執行以下依序:
CharacterExport.model_validate(C.1)。version_no 連續、current_version_no 存在於 chain、parent_version_no / superseded_by_version_no 指向 chain 內、current_state 對應 current_version_no)。build_payload 過 CharacterBuild.model_validate()(不通過即 400 invalid_build_shape)。builder_provenance 過 BuilderDraftPayload.model_validate()(不通過即 400 invalid_builder_provenance)。current_state.state_payload 過 CharacterState.model_validate()(不通過即 400 state_shape_invalid)。ruleset_mismatch。registry.get_optional;分類 resolved / unresolved。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)。versions[current_version_no].builder_provenance;為 null → 400 draft_reconstruction_unavailable;有效 → landing_mode="draft"。landing_mode="draft_with_history_loss"(UI 明示 history / state 將放棄,見 3.3.1);provenance null → 400 draft_reconstruction_unavailable。duplicate_hint(見 C.5)。character_preview(角色名、最高等級、class 摘要)。POST /api/characters/import(不帶 dry_run),實際落地;再走一次上述檢查,通過後 commit。前端 UI 一律先呼叫 dry-run,顯示可解析 / 未解析數字與 landing_mode 給使用者確認後才 commit。沒有預設門檻,是否繼續由使用者決定。
landing_mode == "character":
characters row。character_versions 兩階段 append:第一 pass 每個 version INSERT 時 parent_version_id=NULL、superseded_by_version_id=NULL;第二 pass 依 version_no → 新 UUID dict 執行 UPDATE 填入 lineage 欄位。這條路徑與現行 correction 落地一致,避免 FK 於 INSERT 時指向尚未存在的 row。builder_provenance 逐 row 保留原 JSON。current_state → character_states,綁定 current_version_no 對應的新 version UUID。characters.current_version_id 指向 current_version_no 對應的新 UUID。character_import_records 新增一列,landing_mode="character"。landing_mode == "draft" 或 landing_mode == "draft_with_history_loss":
versions[current_version_no].builder_provenance 建立 fresh CREATE Draft(不綁 base_version_id,因為沒有既有 Character)。character_import_records 新增一列,landing_mode 對應("draft" 或 "draft_with_history_loss"),draft_id=<new draft id>。draft_with_history_loss 情境已於 preview 明示,使用者知情。characters.id / character_versions.id 全新。source_character_id 與 source_export_id 存進 character_import_records(見 C.8),供匯入前重複偵測。duplicate_hint:若匯入端已存在同 source_character_id 的過去匯入紀錄,回應中列出「你已從相同來源匯入過 M 次,最近一次為 …」,前端據此提示但不阻擋。archived_at 不帶入;匯入結果一律為啟用中的角色。landing_mode == "character":完整 chain 保留,含 correction lineage(superseded_by)。landing_mode == "draft":只有 current_version_no 的 builder_provenance 進 Draft;其他 version 於本次匯入放棄。這是 M03 期間可接受的犧牲,用來避免違反 3.3 硬原則。匯入直接拒絕(原子拒絕、零副作用)的情況:
invalid_envelope_shape:raw body JSON parse 失敗、envelope 缺必要欄位、或 CharacterExport.model_validate 於 envelope 段失敗。invalid_payload_shape:payload 缺 character.name / versions / current_state / 至少一個 version / current_version_no;CharacterExport.model_validate 於 payload 段失敗。unsupported_schema_status:schema_status 出現不相容值(special case,見 C.7.1)。unsupported_ruleset:ruleset 不是本 registry 支援的 ruleset。ruleset_mismatch:envelope.ruleset / character.ruleset / 各 CharacterBuild.ruleset 不一致。version_chain_gap:version_no 非連續。version_chain_out_of_order:順序錯亂。current_state_version_missing:current_version_no 不在 chain 中。version_lineage_invalid:parent_version_no / superseded_by_version_no 指向 chain 之外的 version。version_lineage_self_reference:parent_version_no == self.version_no。version_lineage_direction_invalid:parent_version_no >= self.version_no 或 superseded_by_version_no <= self.version_no。version_lineage_cycle:parent chain 或 superseded chain 有環。invalid_version_kind:version_kind 不是允許的 enum 值(special case,見 C.7.1)。invalid_build_shape:CharacterBuild.model_validate 失敗。invalid_builder_provenance:某個非 null builder_provenance 不通過 BuilderDraftPayload.model_validate。state_shape_invalid:CharacterState.model_validate 失敗。build_references_invalid:全 refs 可解析但 validate_build_references 失敗。state_inconsistent_with_build:validate_state_against_build 失敗。draft_reconstruction_unavailable:未解析 refs 存在且 current version 無 builder_provenance(或 provenance null)。payload_too_large:raw body > 5 MB → 413。未解析 refs 本身不是拒絕條件;有 provenance 則落地為 Draft(或 draft_with_history_loss),無 provenance 則以 draft_reconstruction_unavailable 拒絕。
schema_status 是 Literal["unstable", "locked"]、version_kind 是 strict enum,兩者都在 CharacterExport.model_validate() 就失敗,到不了任何語意檢查步驟。若不特別處理,它們會被歸成泛用的 invalid_envelope_shape / invalid_payload_shape,而 C.7 又要求 unsupported_schema_status 與 invalid_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):
ValidationError 同時含 special-case 與其他欄位錯誤時,回 special-case code。loc 排序最前者,不依賴 pydantic 的 error 順序。對應地,envelope 語意檢查階段只負責 ruleset(unsupported_ruleset / ruleset_mismatch);schema_status 不在該階段判定。
新增 character_import_records 表(migration 隨本 subphase 提交,檔名編號依當時 Alembic head 的下一個 revision,不寫死 0007):
id UUID PKcharacter_id UUID NULL FK → characters.id ON DELETE SET NULLdraft_id UUID NULL FK → character_build_drafts.id ON DELETE SET NULLsource_character_id UUID NOT NULLsource_export_id UUID NOT NULLlanding_mode VARCHAR(32) NOT NULL("character" |
"draft" |
"draft_with_history_loss") |
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()source_character_id;index on source_export_id。CheckConstraint:character_id IS NULL OR draft_id IS NULL(至多一欄非 NULL)。
(character_id IS NULL) != (draft_id IS NULL)):兩個 FK 都是 ON DELETE SET NULL,而 import record 在目標 character / draft 被永久刪除後仍須保留。原本 character_id 非 NULL 的列在 character 被 permanent delete 後會變成兩欄皆 NULL,XOR 當場違反,刪除操作會被 constraint 擋掉。PRAGMA foreign_keys=ON 保證;本 subphase 內 SQLite 側 FK 可暫時不成立,於 M03-D 綠燈時同步驗證。File.text() 讀出後與貼上 JSON 走同一 preview 呼叫(皆為 JSON body)。X 個可解析、Y 個未解析、landing_mode 為 …;未解析清單可展開,並標示每個 unresolved ref 的 origin(build / state)與所在 version_no(若適用)。landing_mode="draft_with_history_loss" 時,dialog 顯示明確 warning banner:「Current State 與完整 Version History 都不會保留;補完後會產生新的 Version 1。」(見 3.3.1)。使用者需二次確認才發送 commit。landing_mode == "character")或新 Draft(draft / draft_with_history_loss)。zh-TW / en 雙語 UI 同步交付。把「整條 Alembic migration 鏈可以在 SQLite 上 upgrade head 到完整 schema、外鍵語意在 SQLite 上被啟用」從假設變成 CI-enforced 事實。M03 靠這條路徑升級單機版使用者的本機資料庫;沒有這道 gate,2.7 節的保存庫承諾無法兌現。
alembic upgrade head 全綠。downgrade() 於 SQLite 上亦可執行;若某個 migration 明顯無法 downgrade,需在 migration 中以清楚語意標示,並取得使用者拍板。alembic downgrade base 之後,除 alembic_version 外無其他 table 殘留,且 alembic_version row 數為 0。alembic_version 由 Alembic 自身管理,downgrade 不會 drop 它——「tables 全空」不是本契約的字面要求。alembic upgrade head 之後的 schema 與 metadata.create_all() 之後的 schema 於表列、欄位型別、null 屬性、索引、unique 約束上一致。alembic_version:migration 路徑會建立它、metadata.create_all() 不會。這是結構性的預期差異,不列入「零差異」的計算,也不算下一條的表達力讓步。event.listens_for(Engine, "connect") 或等效 hook,對每條新連線執行 PRAGMA foreign_keys=ON。app/db.py 建 SQLite engine 的所有位置,含 launcher 呼叫的 run_migrations。character_import_records.character_id / .draft_id 對應的 ON DELETE SET NULL 於 SQLite 上實際生效(刪除 character 後 record 對應欄位為 NULL)。psycopg[binary] 從 pyproject.toml 的無條件相依中移出,改放於 optional extra(例:web extra)。.[web] 或等價 extra。web extra,psycopg 不進 PyInstaller bundle。用 PyInstaller 把單機版打包為可下載、離線執行的 Windows x64 產物;launcher 負責啟動 uvicorn、跑 alembic upgrade、開瀏覽器、顯示資料檔路徑、提供結束方式。同時建立 /api/meta/capabilities neutral endpoint、SPA fallback route、以及 app.standalone 對 app.main 的完全隔離。
這條先於 E.7 的完整 spec 完成,是 M03-E 內部的順序要求。
PyInstaller + Alembic + Uvicorn 這一疊的風險不在架構,而在只有真的 freeze 成 exe 才知道漏了哪個 dynamic import / resource / 路徑:_MEIPASS 下的 alembic ScriptDirectory、uvicorn.Config("app.standalone:app", ...) 的字串式 import、hiddenimports 缺漏,unit test 一個都測不到。若等 E.1~E.10 全部堆完才第一次 freeze,這些失敗會一次爆出且難以歸因到單一層。
因此必須先有一個最小 frozen 產物通過:
app.standalone,不需要 web/、不需要 build 腳本、不需要 CI、不需要 .zip。adventure-table.sqlite3;alembic_version 等於當時 head revision);GET /api/meta/capabilities 回 200 且 channel="standalone";python -m app.launcher 代替——那正是本 gate 要抓的差異。app/api/error_handlers.py,提供 register_exception_handlers(app: FastAPI);app.main 與 app.standalone 各自 import 並呼叫,互不依賴。app/api/meta.py,提供 factory create_meta_router(channel: Literal["web", "standalone"]) -> APIRouter。每次呼叫產生新的 APIRouter instance;不使用 module-scope global router,避免跨 entry import 時同一路由被註冊兩次、handler 取錯 channel。app/api/spa.py(或於 standalone 內定義),提供 SPA history fallback 的 catch-all route。Catch-all 必須明確排除 /api/ prefix:未知 /api/* 保持 404,不 fallback 至 index.html。app.standalone 不得 import app.main;共用邏輯只走以上 neutral module。apps/server/app/standalone.py,內含 FastAPI(docs_url=None, redoc_url=None, openapi_url=None)(disable API docs;朋友間出貨不需要 OpenAPI 頁面),只掛以下 router:
reference_routercontent_presentation_routercharacters_routercharacter_builder_routercreate_meta_router("standalone") 產出的 routerregister_exception_handlers(app)。StaticFiles)於 /assets/* 等資源路徑。/api/ 起頭 且對應 static file 不存在時,回應 index.html;路徑以 /api/ 起頭則交回 FastAPI 預設 404,不 fallback。resolve_database_url(),非 SQLite URL 即以明確錯誤中止,訊息含實際解析到的 URL 與該檢查哪個環境變數。單機版永遠不得對 PostgreSQL 發出連線嘗試。此守衛必須能在不 freeze、不進 CI 的情況下由 unit test 觸發。GET /api/meta/capabilities 回傳:
{
"channel": "web" | "standalone",
"capabilities": {
"character_builder": true,
"character_import_export": true,
"room": false, // standalone 一律 false;web P2 之前也為 false,P2 之後由 web entry 覆寫為 true
"campaign": false,
"session": false,
"seat": false,
"combat": false,
"timeline": false,
"ai_actor": false
}
}
app.main 覆寫可支援的 capability;app.standalone 一律回上表。apps/server/app/launcher.py(可執行入口,PyInstaller 打包 target)。ADVENTURE_TABLE_DATABASE_PATH(外部已設時尊重覆寫),接著驗證該路徑可建立、可寫入。此步驟必須早於 migration 與 app import;路徑決策不得依賴「呼叫者已設好環境變數」。ADVENTURE_TABLE_CONTENT_ROOT。alembic.ini(見 E.7 對 alembic resources 打包的要求),呼叫 alembic.command.upgrade(config, "head")。DB URL 透過 resolve_database_url() 取得(SQLite);PRAGMA foreign_keys=ON 由 M03-D 的 hook 生效。失敗時以清楚錯誤訊息中止並保留 launcher stdout。app.standalone:app。http://127.0.0.1:<port>/。adventure-table.sqlite3(不是 %LOCALAPPDATA%,不是使用者家目錄)。非 frozen 直接跑 launcher(dev)時為 <cwd>/adventure-table.sqlite3。ADVENTURE_TABLE_DATABASE_PATH 環境變數(絕對或相對路徑)。settings.database_path → frozen 時 <exe_dir>/adventure-table.sqlite3 → launcher dev 執行時 <cwd>/adventure-table.sqlite3。單機版任何情況下都不得 fallback 到 settings.database_url(其預設為 PostgreSQL,且 bundle 刻意 exclude psycopg,落到該分支的症狀會是 driver 缺失堆疊而非可辨識的設定錯誤)。<exe_dir>/data。resolve_spa_root()。順序與 content root 一致:ADVENTURE_TABLE_SPA_ROOT env var → <exe_dir>/web →(frozen fallback _MEIPASS/web)→ dev 時 None。env var 為第一順位覆寫。data/ 與 web/ 目錄整體放在執行檔同層目錄,PyInstaller bundle 不內嵌第二份。data/ / web/ 若不存在 or 為空,是正常狀態;resolver 於 frozen mode 找不到 exe_dir 才 fallback _MEIPASS。apps/server/pyinstaller/standalone.spec。data/ 或 web/ 為 datas;這兩個資料夾由 build 腳本複製到產物根目錄。datas:alembic/alembic.ini、alembic/env.py、alembic/script.py.mako(若有)、alembic/versions/*.py。alembic 靠 ScriptDirectory 走檔案系統載入 migration,hiddenimports 沒辦法取代這條路徑。Launcher 據此建 Config(str(_MEIPASS/'alembic'/'alembic.ini')) 並 set sqlalchemy.url = resolve_database_url()。adventure-table-standalone/
adventure-table.exe # launcher
_internal/ # PyInstaller runtime(含 bundled alembic/ 資源)
data/ # content root(build 腳本複製)
web/ # 前端 SPA build(build 腳本複製)
LICENSE.txt # 專案 license + SRD 5.1 attribution
README-standalone.txt # 資料檔位置 / 匯出入口 / 已知限制
hiddenimports 明列 alembic migration module 名稱(動態 import 需要)與 pydantic 內建 codecs。console=True(保留 console window)。excludes 明列 psycopg。scripts/build-standalone.cmd(Windows;正式產物必於 Windows runner 產生)。apps/server 主相依 + standalone extra;不裝 web extra。npm ci + npm run build)。dist/adventure-table-standalone/。data/ → 產物 data/。apps/web/dist/ → 產物 web/。LICENSE.txt、README-standalone.<locale>.txt。.zip。--version <tag> 寫入 launcher 的 build id。npm run build 產物)。/api/meta/capabilities;capability 為 false 的頁面 nav 入口不 render,使用者手打對應 URL 時 SPA 顯示 capability_disabled 頁面。adventure-table.exe 到瀏覽器打開時間 < 10 秒(乾淨啟動、無防毒干預情況下)。.msi / .exe installer)。把 M03-E 的打包從「使用者本機能跑」升級為「CI 綠燈可 release」,並用 import boundary test 把 3.2 節的界線由文件轉為機器保證。
m03-standalone.yml,於 windows-latest runner 上:
scripts/build-standalone.cmd。/api/meta/capabilities 回 200 且 channel="standalone" → 打一次 GET /api/characters(空清單)→ 收 process。smoke 必須另外斷言:
adventure-table.sqlite3(證明 E.5 的預設路徑真的生效,而不是靠 runner 環境剛好有 ADVENTURE_TABLE_DATABASE_PATH);alembic_version 等於 repo 當時的 head revision;ADVENTURE_TABLE_DATABASE_PATH;預設路徑必須是被測對象本身。.zip 產物為 workflow artifact。push 到 main、PR label standalone-build、workflow_dispatch。發版一律在本機進行,CI 不建立 GitHub Release。
scripts\build-standalone.cmd --version <版本> 產出 dist\adventure-table-standalone-<版本>.zip,由維護者自行保存與分發。gh release 或 tag v* 觸發。此條由 test_m03f_workflow_contract.py 靜態守住,避免日後回流。docs/M03/release-notes-template.md 為本機發版時的 release notes 範本,M03 期間必須明文標示 JSON schema unstable。決策理由:本專案為朋友間私人使用,repo 將轉為 private。Private repo 的 Release 頁面與 asset 都需要 repo 讀取權限,沒有匿名下載連結,因此 GitHub Release 無法達成「把 zip 連結給朋友」的目的,只是多一條要維護與驗證的通路。
apps/server/tests/test_m03_import_boundary.py。ast.parse 遞迴),斷言以下模組不 import 任何 room / session / seat / campaign 相關 module:
app.content.*app.domain.character*app.persistence.charactersapp.persistence.builder_draftsapp.persistence.state_mutationsapp.api.charactersapp.api.character_builderapp.api.referenceapp.api.content_presentationapp.api.metaapp.api.error_handlersapp.standalone(room|session|seat|campaign|party_roster)」表達;P2 開工前若引入新的多人模組名稱,測試需一併更新以維持有效性。app.standalone 不 import app.main(不論名稱是否 match forbidden regex)。apps/server/tests/test_m03_standalone_composition.py。app.standalone 只掛 M03-E 指定的五個 router,不掛其他 router。app.standalone 掛載了前端靜態檔與 SPA fallback。app.standalone startup 不呼叫 alembic upgrade。GET /api/meta/capabilities 於 standalone 回傳 channel="standalone" 與 room=false 等值。app.main 亦掛 meta_router,於 web entry 回傳 channel="web"。把 M03-A~M03-F 建立的所有 pieces 串成一次真實的 end-to-end 體驗,確認網頁版 → 單機版、單機版 → 網頁版、缺 pack 情境、Draft 補洞情境、legacy 角色拒絕情境都跑得通;並把 M03 的產品行為、已知限制與後續 P2 依賴的界線正式關門。
以下路徑至少完整跑過一輪並保留證據:
xge 內容的角色 → 匯出 → 於一個 enabled_content_packs 未含 xge 的單機版設定匯入 → dry-run 顯示未解析數與 origin → landing_mode = draft → 使用者於 Builder 中改選 → Confirm 成為 Version 1。landing_mode=draft_with_history_loss(因 state ref 未解析)→ 走 Draft 補洞。duplicate_hint → 使用者仍可繼續 → 兩個角色並存。builder_provenance = NULL)匯出 → 於缺對應 pack 的環境嘗試匯入 → 400 draft_reconstruction_unavailable;DB 無殘留。scripts\build-standalone.cmd --version <版本> 產出 dist\adventure-table-standalone-<版本>.zip(F.2:不做 GitHub Release)。unstable 現狀、關閉方式(Ctrl+C / 關 console)。於 docs/M03/M03-G_CLOSEOUT.md 明列:
unstable,P2 上線時才 lock。builder_provenance)於缺 pack 情境無法匯入。app.standalone 不 import app.main)於 CI 綠燈狀態。PROJECT_BRIEF.md 記錄 M03 closeout;規格企劃.md 若第五章 Character Workshop 現行文字需要補充「單機版已出貨」,在本 subphase 一併更新。capability_disabled 頁面、拒絕原因訊息、README-standalone,於 zh-TW / en 兩語皆完整。