Phase:M03 — Standalone Character Builder Distribution 本文件定義 M03-A~M03-G 每個 Subphase 的自動 / 人工驗收流程與測試證據要求。驗收意圖見
實作規格.md;實作契約見開發設計方針.md。
最後更新:2026-09-04
X.n 對應至少一條 pytest / Vitest / Playwright test 或人工程序,於 closeout 文件中列出檔名 + test 名稱。zh-TW / en 兩語各跑一次同一條 flow;只驗一語不算完成。Settings.enabled_content_packs 注入 subset,不刪 pack 目錄。分層 gate 的通則見 AGENTS.md「工程實作守則」。本節只記 M03 專屬的對照;已關門的 M03-A / B / C 不回填。
| Subphase | diff 性質 | Subphase 關門要跑的 E2E |
|---|---|---|
| M03-D | backend / DB / 打包相依,不碰 apps/web |
無。以 D.1~D.3 的 SQLite migration 與 FK pytest、D.4 的 Postgres round-trip、docker compose config 與容器起得來為證據 |
| M03-E | 新增 standalone entry 與 launcher,且 E.8 動到前端 capability 呈現 | E.8 對應的 spec,加上 m03b-character-export.spec.ts 與 m03c-character-import.spec.ts;E.0 frozen smoke 與 E.9 手動冷啟動照原契約跑,不因分層而省 |
| M03-F | Windows CI / release / import boundary,前端不動 | 無新 E2E。F.6 的網頁版 CI 無回歸沿用既有 workflow |
| M03-G | Phase 關門 | 全套 Playwright,含 e2e-docker.mjs 拿掉 xge 的第二輪;G.2 的無回歸要求不變 |
合併回 main 前一律比照 M03-G 跑全套,不因單一 Subphase 是 backend-only 而略過。
同 M02:zh-TW / en。所有雙語 test 皆為兩 locale 各跑一遍同一條 flow,斷言可見文字純該 locale。
M03 新增三份匯出範例 JSON,放於 apps/server/tests/data/m03/:
fixture_low_level_srd.json:Lv1 Fighter,僅引用 srd5.1;每個 version 皆帶 builder_provenance。fixture_multiclass_mixed.json:Lv5 Fighter 3 / Cleric 2,引用 srd5.1 + phb2014;帶完整 builder_provenance。fixture_xge_dependent.json:Lv3 Ranger with Gloom Stalker subclass(引用 xge);帶完整 builder_provenance。fixture_legacy_no_provenance.json:合成 fixture,builder_provenance 全為 null,用於 legacy character 拒絕測試。fixture_state_only_missing_inventory.json:Build 全部可解析、current_state.inventory_state 有一件「後來撿到但已從資料集移除」的 item ref(不用 active infusion,因為 infusion 通常同時存在 Build 的 infusion_refs 中,不是乾淨 state-only 缺 ref)。fixture_bad_ruleset_mismatch.json:envelope.ruleset 與 character.ruleset 不一致。fixture_bad_lineage_cycle.json:parent chain 有環。fixture_bad_version_kind.json:version_kind 為非 enum 字串。fixture_bad_builder_provenance.json:非 null builder_provenance 不通過 BuilderDraftPayload.model_validate。前四份於 M03-B 完成後透過 export endpoint 產生並提交進 repo;第五份手動編纂但通過 domain validation。
m03-standalone.yml on windows-latest。於每個 Subphase 完成後執行對照觀察,基線為 M03-A start baseline:
zh-TW / en 呈現與 M01 Full Closeout 觀察一致。list_kind() 於各 pack 回傳筆數與 M03-A start baseline 一致。實作者於 M03-A 開工當下產生一份 M03-A start baseline(docs/M03/baseline/m03a-start.json),作為後續每個 subphase 對照的凍結基準。M01 依使用者決定保持 open,因此這份基線是「M03-A 開工當下」的快照,不是 M01 full closeout 快照。 檔案內容為 enabled pack 清單、逐 pack entry 數與總 entry 數;測試一律經 apps/server/tests/m03_baseline.py 讀取,不得在測試碼內重複抄寫清單或數字。若 M01 後續補內容而使 entry 數變動,需同批更新本檔並在該 subphase 說明。
enabled_content_packs_full():完整 M03-A start baseline 清單(web / standalone 預設)。enabled_content_packs_without(pack: str):預設清單去掉一個 pack;供缺 pack test 使用。ADVENTURE_TABLE_ENABLED_CONTENT_PACKS 環境變數傳入 subset 給 backend。A. Content Root Path Abstraction & Enabled-Pack SSOT
B. Character JSON Schema, Export & Builder Provenance
C. Character JSON Import via Builder Draft
D. SQLite Migration Chain Gate & FK PRAGMA
E. Standalone Packaging & Launcher
F. Windows CI Build, Release & Import Boundary Test
G. Full M03 Integration & Closeout
以下逐 Subphase 列出測試證據要求。
apps/server/tests/test_m03a_paths.py:
test_resolve_content_root_prefers_env_var:monkeypatch ADVENTURE_TABLE_CONTENT_ROOT 指向 tmp 目錄(內含 pack subset 空殼),斷言回傳該路徑。test_resolve_content_root_rejects_missing_env_target:env var 指向不存在路徑,斷言 raise 且訊息含該路徑與解析階段。test_resolve_content_root_uses_exe_dir_when_frozen:monkeypatch sys.frozen=True + sys.executable=<tmp>/adventure-table.exe,<tmp>/data 為目錄,斷言回傳 <tmp>/data。test_resolve_content_root_falls_back_to_meipass_when_no_exe_data:monkeypatch frozen 且 exe_dir/data 不存在、sys._MEIPASS=<tmp2> 且 <tmp2>/data 為目錄,斷言回傳 <tmp2>/data。test_resolve_content_root_falls_back_to_repo_relative:非 frozen、無 env 時斷言回傳 repo 內 data/ 目錄。test_resolve_rules_path:回傳 <content_root>/rules/dnd5e-2014/character-builder.json。test_resolve_spa_root_returns_none_when_not_frozen:非 frozen 無 env 時回 None。test_resolve_spa_root_env_first:env var 與 <exe_dir>/web 皆存在時,回 env var 指向(env 為第一順位覆寫)。test_resolve_database_url_uses_sqlite_when_path_set:monkeypatch ADVENTURE_TABLE_DATABASE_PATH=<tmp>/x.sqlite3,resolve_database_url() 回 sqlite+pysqlite:///<abs path>。test_resolve_database_url_falls_back_to_settings_when_path_unset:無 env → 回 settings.database_url(Docker DATABASE_URL 語意)。apps/server/tests/test_m03a_registry_uses_resolver.py:
app.content.registry module scope 不再存在 REPOSITORY_ROOT、CONTENT_PACKS_ROOT、DEFAULT_CONTENT_ROOT、DEFAULT_SRD_CONTENT_ROOT 常數。load_default_content_registry() 於 monkeypatch ADVENTURE_TABLE_CONTENT_ROOT 指向 tmp 空殼時載入 tmp 內容而非 repo 內容。apps/server/tests/test_m03a_rules_uses_resolver.py:
app.domain.character_builder.rules 內不再存在 RULES_PATH 常數。api.dependencies 使用 resolverapps/server/tests/test_m03a_dependencies_uses_resolver.py:
app.api.dependencies 不再 from app.content.registry import CONTENT_PACKS_ROOT。get_content_localization() 於 monkeypatch env var 後從新路徑載入。create_registry, provide_content_localization)於預設環境下產出與 M03-A start baseline 相同的 localization overlay。apps/server/tests/test_m03a_no_legacy_path_constants.py:
apps/server/app 全部 .py:
REPOSITORY_ROOT / CONTENT_PACKS_ROOT / DEFAULT_CONTENT_ROOT / DEFAULT_SRD_CONTENT_ROOT / RULES_PATH / DEFAULT_CONTENT_PACKS 的賦值。Path(__file__).resolve().parents[N](N ≥ 3)用於推導 repo root 的 top-level statement,除了 app/paths.py 本身。app/content/__init__.py 專項:不得存在 _registry.DEFAULT_CONTENT_PACKS = ... 或等效 <module>.<CONSTANT> = ... 對已移除常數的 legacy monkey-patch(AST 抽 Assign 的 Attribute target 檢查)。apps/server/app 全部 .py:不得 from app.content.registry import CONTENT_PACKS_ROOT / DEFAULT_CONTENT_ROOT / DEFAULT_SRD_CONTENT_ROOT / DEFAULT_CONTENT_PACKS 之類 legacy 引用。apps/server/tests/test_m03a_enabled_packs.py:
Settings.enabled_content_packs 存在且 fallback 為 M03-A start baseline 完整清單(9 pack:srd5.1 / phb2014 / scag / gos / vgm / vrgr / tce / xge / mtf;M01-K 之後若擴充需同步更新)。ADVENTURE_TABLE_ENABLED_CONTENT_PACKS="srd5.1,phb2014" 經 NoDecode + field_validator 覆寫成 ("srd5.1", "phb2014")。這條同時是「該欄沒有被 EnvSettingsSource 當 complex type 做 JSON decode」的迴歸守門員:若 NoDecode(或等效的 custom source)被拿掉,本斷言會以 SettingsError 失敗而非 assertion 失敗。"srd5.1, phb2014")也 parse 正確。load_default_content_registry() 於 subset settings 下只載入 subset pack(未刪目錄)。app.content.registry module scope 不再存在 DEFAULT_CONTENT_PACKS 常數。app.content.__init__ 不再對 _registry.DEFAULT_CONTENT_PACKS 賦值。apps/server/tests/test_m03a_enabled_packs.py:
test_reference_into_disabled_pack_is_unresolved_not_corruption:目標 pack 已安裝但未啟用 → 不 raise;同一 ref 在該 pack 啟用後仍缺 entry → raise dangling reference。test_reference_into_uninstalled_pack_is_still_corruption:source 段打錯字(xgee)且該 pack 未安裝 → 仍 raise。test_full_runtime_registry_still_rejects_unknown_pack_sources:完整 9 pack 的網頁版設定下,指向未安裝 pack 的 ref 仍 raise——確保放寬不會滲進生產設定。apps/server/tests/test_m01a_content_packs.py:
test_installed_but_disabled_cross_pack_dependency_stays_unresolved:pack-a 在 fixture root 內存在但未啟用,from_root 成功載入且 get_optional("pack-a:feature:test-a") 為 None。test_reference_to_uninstalled_pack_is_still_dangling:引用 pack-typo: 的 pack 載入時 raise。test_cross_pack_dependency_must_be_enabled;契約變更理由見實作規格 A.5.1。apps/server/tests/test_m03a_settings_env_compat.py:
Settings.model_config 未啟用 env_prefix(維持既有 DATABASE_URL 語意)。DATABASE_URL="postgresql://..." 仍被 settings.database_url 讀到(Docker 相容)。content_root / database_path / spa_root / enabled_content_packs 各自透過明列的 validation_alias 讀對應 ADVENTURE_TABLE_* env var。Evidence 檔:M03-A closeout 文件列出 A.1~A.8 每條 test 位置與最近一次執行時間戳。
apps/server/tests/test_m03b_migration.py:
character_versions 有 builder_provenance 欄位,nullable。NULL。apps/server/tests/test_m03b_confirm_writes_provenance.py:
character_versions.builder_provenance 為非 NULL 且與 Draft payload snapshot 對得起來。builder_provenance 為非 NULL。builder_provenance 為非 NULL。builder_provenance 保持 NULL。apps/server/tests/test_m03b_json_schema.py:
apps/server/tests/data/m03/fixture_*.json,載入 → validate → serialize → 二次 validate,斷言 idempotent。schema_status == "unstable"。schema_status 值於 validation 通過(正例 / 反例對照)。ExportedVersion 帶 parent_version_no / superseded_by_version_no / builder_provenance 欄位。ExportPayload 帶 current_version_no 欄位。ExportedVersion.version_kind 是 strict enum:fixture_bad_version_kind.json 應被 pydantic 拒絕,不進 pipeline。apps/server/tests/test_m03b_versioned_draft_seeding.py:
builder_provenance 非 null,且對應 character_build_drafts row 已被清)→ 開 Level Up draft → 斷言 seed 來自 character_versions.builder_provenance。builder_provenance = NULL)→ 開 Level Up draft → 斷言 seed 來自 character_build_drafts;若那也不存在 → 斷言 seed 來自 legacy_payload_from_build。builder_provenance;並確認產出的 draft 通過 BuilderDraftPayload.model_validate。apps/server/tests/test_m03b_build_ref_walker.py:
apps/server/tests/test_m03b_export_payload.py:
build_export_payload → 斷言:
content_requirements 只列 build + state 實際引用的 pack。stable_key_refs_summary 是整數且與 walker 結果一致。versions 為完整 chain,並帶 parent_version_no / superseded_by_version_no。current_version_no 存在且指向 chain 內 version。builder_provenance 欄位(可能為 null)。archived_at / id / current_version_id。versions 依 version_no 遞增。apps/server/tests/test_m03b_export_api.py:
GET /api/characters/{id}/export → 200 + Content-Disposition: attachment。apps/web/e2e/m03b-character-export.spec.ts:
Envelope / ExportPayload 反向 validation。zh-TW / en 兩 locale 各跑一次。aria-label、tooltip、error toast 於 zh-TW / en 皆完整。apps/server/tests/test_m03c_state_ref_walker.py:
fixture_multiclass_mixed.json 的 current_state,斷言 walker 至少回傳:
conditions[].condition_refprepared_spells[].spell_keyinventory_state.inventory_entries[].item_refactive_infusions[].infusion_refspell_storing_item.spell_ref(若存在)apps/server/tests/test_m03c_import_pipeline.py:
preview_import 對每份 fixture:
enabled_content_packs_full() 下 fixture_*.json → landing_mode="character",unresolved_refs=[]。enabled_content_packs_without("xge") 下 fixture_xge_dependent.json → landing_mode="draft",unresolved_refs 含所有 xge:* refs、origin="build"、標示 version_no。enabled_content_packs_without("phb2014") 下 fixture_multiclass_mixed.json → landing_mode=”draft”,unresolved_refs 精確列出缺項。fixture_state_only_missing_inventory.json → build 全解、state 有未解 → landing_mode="draft_with_history_loss",且 unresolved_refs 非空並全部為 origin="state"。fixture_legacy_no_provenance.json 於 subset settings 下 → 400 draft_reconstruction_unavailable。preview_import 對故意破壞的 build_payload(例:class_progression_refs 帶非 class stable key)→ 400 invalid_build_shape(CharacterBuild.model_validate 失敗)。preview_import 對故意破壞的 state_payload(例:condition_ref 帶非 condition key)→ 400 state_shape_invalid。preview_import 對 fixture_bad_builder_provenance.json → 400 invalid_builder_provenance(BuilderDraftPayload validate 失敗)。preview_import 對 fixture_bad_ruleset_mismatch.json → 400 ruleset_mismatch。preview_import 對 fixture_bad_lineage_cycle.json → 400 version_lineage_cycle。preview_import 對 parent 指向自身 → 400 version_lineage_self_reference。preview_import 對 parent 指向後續 version → 400 version_lineage_direction_invalid。preview_import 對全 refs 可解析但 validate_build_references 會失敗的 payload(例:subclass 選擇對應等級不存在)→ 400 build_references_invalid。preview_import 對全 refs 可解析但 validate_state_against_build 會失敗的 payload(例:prepared spell 超出 build 允許)→ 400 state_inconsistent_with_build。apps/server/tests/test_m03c_commit.py:
commit_import_as_character:
parent_version_id=NULL、superseded_by_version_id=NULL;第二 pass UPDATE 填 lineage。斷言中間狀態確實走過兩階段(可透過 spy INSERT/UPDATE 呼叫序列驗證)。parent_version_id / superseded_by_version_id 於落地後對應到 chain 內新 UUID(不是 payload 中的 version_no)。builder_provenance 逐 row 落地保留原 JSON。characters.current_version_id 對應 current_version_no 的新 UUID,不假定 = max version。character_states.state_payload == payload.current_state.state_payload。character_import_records 有新 row,landing_mode="character";斷言 service 寫入時 character_id 非 null 且 draft_id null(此為 service 層契約,非 DB constraint)。validate_build_references / validate_state_against_build 於 commit 開始時再跑一次。commit_import_as_draft(涵蓋 landing_mode="draft" 與 "draft_with_history_loss" 兩種 variant):
versions[current_version_no].builder_provenance。character_import_records.draft_id 指向新 draft,character_id=NULL;landing_mode 值對應正確。character_id null 且 draft_id 非 null。character_import_records CheckConstraint 語意:
CHECK (character_id IS NULL OR draft_id IS NULL) 生效)。ON DELETE SET NULL 後的合法終態,不是資料損毀;此斷言存在的目的是擋住日後有人把 constraint 改回 XOR。apps/server/tests/test_m03c_import_rejections.py:
每個 rejection code 一條 test:
invalid_envelope_shape:缺 envelope.ruleset → 400 + code;raw body malformed JSON → 400 + 同 code(不落到全域 422)。invalid_payload_shape:缺 payload.character.name。unsupported_schema_status:"future_value" → 400 且 code 為 unsupported_schema_status,不是 invalid_envelope_shape(此值在 CharacterExport.model_validate() 階段就失敗,靠實作規格 C.7.1 的 special-case 映射才會回正確 code)。unsupported_ruleset:"pathfinder2e"。ruleset_mismatch:fixture_bad_ruleset_mismatch.json。version_chain_gap:versions=[1,3]。version_chain_out_of_order:versions=[2,1]。current_state_version_missing:current_version_no=99 不在 chain。version_lineage_invalid:parent_version_no=99。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:fixture_bad_lineage_cycle.json。invalid_version_kind:fixture_bad_version_kind.json → 400 且 code 為 invalid_version_kind,不是 invalid_payload_shape(同上,走 special-case 映射)。invalid_build_shape:見 C.2。invalid_builder_provenance:fixture_bad_builder_provenance.json。state_shape_invalid:見 C.2。build_references_invalid:見 C.2。state_inconsistent_with_build:見 C.2。draft_reconstruction_unavailable:fixture_legacy_no_provenance.json + subset;同時測 state-only 缺 ref + provenance null 情境(landing_mode="draft_with_history_loss" 應被 provenance null 攔下拒絕,而非落地)。payload_too_large:raw body > 5 MB → 413 + code;斷言未觸發 pipeline。每個 rejection 都斷言:
params(若適用)。characters / character_versions / character_states / character_import_records / character_build_drafts 無新 row。apps/server/tests/test_m03c_validation_error_mapping.py:
直接對 map_validation_error() 斷言(不經 HTTP),涵蓋實作規格 C.7.1 的整張表:
schema_status 錯 → unsupported_schema_status。version_kind 錯 → invalid_version_kind。schema_version 錯 → invalid_envelope_shape(明文決定,不是 special case)。invalid_envelope_shape;其他 payload 欄位錯 → invalid_payload_shape。version_kind 錯 + 其他 payload 欄位同時錯 → invalid_version_kind。schema_status 與 version_kind 同時錯 → 固定回 loc 排序最前者對應的 code;同一輸入重跑多次結果一致,且不因 pydantic error 順序改變而改變。apps/server/tests/test_m03c_duplicate_hint.py:
source_character_id 已有一筆 import record → dry-run 回應含 duplicate_hint。duplicate_hint=None。apps/server/tests/test_m03c_import_api.py:
POST /api/characters/import?dry_run=true → 200 + ImportResult,committed=false。POST /api/characters/import (JSON body) → 201 + ImportResult。validation_failed,而是 400 加對應 machine code。invalid_envelope_shape(不 422)。payload_too_large。apps/web/e2e/m03c-character-import.spec.ts:
fixture_low_level_srd.json(檔案選擇路徑,File.text() 讀出以 JSON body POST)→ dry-run 顯示 4 個 resolved / 0 unresolved / mode=character → 繼續 → 導向 Sheet。fixture_low_level_srd.json(貼上路徑)→ 同上。fixture_xge_dependent.json 且本機 xge disabled(透過 ADVENTURE_TABLE_ENABLED_CONTENT_PACKS 環境變數注入 subset)→ dry-run 顯示 mode=draft 與 unresolved 清單(origin=build,帶 version_no)→ 繼續 → 導向 Draft,Draft 中未填 choice 呈現正確狀態。fixture_state_only_missing_inventory.json:dry-run unresolved 內出現 origin=state 標示;landing_mode=draft_with_history_loss,dialog 顯示「Current State 與完整 Version History 都不會保留」warning banner,且未二次確認前不得送出 commit;二次確認後 → 導向 Draft。fixture_legacy_no_provenance.json 於缺 pack 環境 → dialog 顯示 draft_reconstruction_unavailable 訊息,未落地。duplicate_hint 提示。zh-TW / en 兩 locale 各跑主流程一次。zh-TW / en message 完整(含新加的 invalid_build_shape / state_shape_invalid / build_references_invalid / state_inconsistent_with_build / version_lineage_invalid / draft_reconstruction_unavailable)。resolved_count / unresolved_refs.length / landing_mode UI presentation 兩語完整。origin 標示(build / state)兩語完整。apps/server/tests/test_m03d_migration_sqlite.py:
alembic upgrade head:斷言無例外,結束後 tables 完整。alembic downgrade base:斷言無例外,結束後除 alembic_version 外無其他 table 殘留,且 alembic_version 的 row 數為 0。alembic_version 由 Alembic 自身管理、downgrade 不會 drop 它,因此「tables 全空」不可寫成字面要求。alembic upgrade head 二次:無例外。builder_provenance migration 與 M03-C 的 character_import_records migration。apps/server/tests/test_m03d_schema_parity.py:
alembic upgrade head 產生 schema A。metadata.create_all(engine) 產生 schema B(另一 tmp SQLite)。inspect 對照兩者的 tables / columns / types / nullable / indexes / unique constraints。alembic_version:schema A 有它(Alembic 建立),schema B 沒有(metadata.create_all() 不建它)。這是結構性的預期差異,不是 SQLite-only 讓步,不進白名單。apps/server/tests/test_m03d_sqlite_fk.py:
app.db 的 create engine path)→ 開新 connection → 執行 PRAGMA foreign_keys; 值為 1。character_import_records.character_id → 刪 character → 驗 record 仍存在且 character_id 為 NULL(ON DELETE SET NULL 生效,且列因兩欄皆 NULL 而未被 CheckConstraint 擋下)。character_import_records.draft_id → 刪 draft → 驗 record 仍存在且 draft_id 為 NULL。psycopg extrasapps/server/tests/test_m03d_dependency_extras.py:
pyproject.toml:psycopg[binary] 不在 [project.dependencies],在 [project.optional-dependencies].web。.github/workflows/*.yml)中網頁版相關 job 安裝 .[web,dev]。順序要求:本 gate 綠燈之前,不進行 E.6 spec 靜態檢查、E.7 build script 與 E.8 前端整合。
理由見實作規格 E.0:_MEIPASS 下的 alembic ScriptDirectory、uvicorn.Config("app.standalone:app", ...) 的字串式 import、hiddenimports 缺漏,這三類問題 unit test 一個都測不到,只有真正 freeze 才會現形。越晚 freeze,爆出來時越難歸因。
scripts/smoke_standalone.py(與 F.2 同一支腳本,此處先以最小 bundle 使用):
app.standalone(不含 web/、不含 .zip、不進 CI)。ADVENTURE_TABLE_DATABASE_PATH。adventure-table.sqlite3;alembic_version 等於當時 head revision;GET /api/meta/capabilities 回 200 且 channel="standalone";python -m app.launcher(非 frozen)代替執行——那正是本 gate 要抓的差異。通過後最小 spec 併入正式 spec,不保留兩份設定。
apps/server/tests/test_m03e_error_handlers_shared.py:
app.api.error_handlers.register_exception_handlers 存在。FastAPI() 空 app 呼叫 register_exception_handlers(app) → 觸發 CharacterNotFoundError 有對應 JSON response。app.main 呼叫 register_exception_handlers(app),app.standalone 呼叫同一函式。app.standalone 不 import app.main(重複於 F.3,本 subphase 內先 fail-fast)。apps/server/tests/test_m03e_capabilities.py:
app.main TestClient GET /api/meta/capabilities → 200,channel="web",M03 期間 room=false 等(P2 上線後會由 web entry 覆寫)。app.standalone TestClient GET 同 endpoint → 200,channel="standalone",room=false 等值。apps/server/tests/test_m03e_standalone_composition.py:
app.standalone 並斷言:
app.title 含 “standalone”。app.docs_url is None、app.redoc_url is None、app.openapi_url is None(standalone 不對外露 API docs)。app.routes 只含 M03-E 指定的五個 router 前綴(reference / content_presentation / characters / character_builder / meta)+ /assets + SPA catch-all;斷言中對 /docs / /redoc / /openapi.json 明確不存在。app.state.content_registry 存在。alembic.command.upgrade,斷言未被呼叫)。apps/server/tests/test_m03e_meta_router_factory.py:
app.main 與 app.standalone;分別對兩個 TestClient GET /api/meta/capabilities → 各自回 channel="web" / channel="standalone",斷言 channel 值不互相污染。app.api.meta module scope 不存在 meta_router = APIRouter(...) global;只有 create_meta_router(channel) factory。apps/server/tests/test_m03e_spa_fallback.py:
app.standalone TestClient GET /characters/00000000-0000-4000-8000-000000000001 → 200 且 body 為 index.html 內容。/assets/some.css(存在)→ 200 且 body 為 CSS 內容。/api/some-not-exist → 404(不 fallback 到 SPA;斷言 body 不是 index.html)。/api/characters/deep/not-exist → 404(/api/ prefix 一律不 fallback,不論深度)。/random/path/without/api → 200 且 body 為 index.html(SPA history 深路徑)。apps/server/tests/test_m03e_alembic_bundle_ready.py:
datas 內含:alembic/alembic.ini、alembic/env.py、alembic/versions/*.py。run_migrations 於 tmp dir + tmp SQLite → 斷言可正確找到 alembic ScriptDirectory(模擬 frozen 時 _MEIPASS/alembic/alembic.ini 存在);不倚賴 hiddenimports。apps/server/tests/test_m03e_launcher_headless.py:
open_browser 為 no-op。launcher.main 於 tmp dir(ADVENTURE_TABLE_DATABASE_PATH 指向 tmp .sqlite3)。ADVENTURE_TABLE_DATABASE_PATH 的 case(cwd 切到 tmp dir):斷言 launcher 自行解析出 <cwd>/adventure-table.sqlite3、該檔被建立、且 resolve_database_url() 回 SQLite URL。沒有這條,E.5 的預設路徑契約完全沒有被測到——原有 case 因為自己設了環境變數而必然通過。alembic upgrade head 執行成功。GET /api/meta/capabilities 回 200 且 channel="standalone"。GET /api/characters 回 200 + 空清單。apps/server/tests/test_m03e_database_path.py:
resolve_database_path() 解析順序逐條斷言:
ADVENTURE_TABLE_DATABASE_PATH → 取該值(相對路徑轉絕對)。settings.database_path → 取該值。sys.frozen / sys.executable)→ <exe_dir>/adventure-table.sqlite3。<cwd>/adventure-table.sqlite3。resolve_database_url() 皆回 sqlite+pysqlite:// 開頭,不會回 settings.database_url。resolve_database_url() 仍回 settings.database_url(Postgres 未回歸)。resolve_database_url() 回 Postgres URL → 組裝 app.standalone 應以明確 RuntimeError 中止,訊息含實際 URL 與環境變數名。這條讓「單機版落到 Postgres」不需要 freeze、不需要 CI 就測得到。<settings> 之類佔位字串。apps/server/tests/test_m03e_pyinstaller_spec.py:
datas 不 含 data/ 或 web/(這兩者由 build 腳本複製,不進 bundle)。datas 含 alembic/alembic.ini、alembic/env.py、alembic/versions/(migration scripts 需靠 filesystem,不能只靠 hiddenimports)。console=True。onefile=False。excludes 含 psycopg。scripts/build-standalone.cmd --dry-run:
apps/web/src/features/capabilities/*.test.tsx:
CapabilityProvider 於 mock capabilities response 為 channel="standalone" 時,nav 中 room / campaign 入口不 render。capabilities 為 channel="web" 時,nav 保持原樣。CapabilityDisabledPage(兩語)。於一台乾淨 Windows 11(未裝 Python / Node / Docker):
scripts/build-standalone.cmd 於開發機上產出 .zip。.zip 到乾淨機器,解壓。adventure-table.exe。adventure-table.sqlite3,且與 adventure-table.exe 同層。/characters/<uuid> reload → 正確載入 Sheet(SPA fallback 生效)。M03-E closeout 文件列出人工冷啟動的截圖 / 描述。
m03-standalone.yml(main push、PR label standalone-build 或 workflow_dispatch 皆可,三者走同一個 standalone-build job),job 全綠。scripts/smoke_standalone.py:
/api/meta/capabilities 200 且 channel="standalone" + /api/characters 200 + 收 process。adventure-table.sqlite3;alembic_version 等於 repo 當時 head revision;ADVENTURE_TABLE_DATABASE_PATH,且必須在乾淨資料夾執行;否則預設路徑契約會被環境變數掩蓋而測不到。發版一律在本機進行;CI 不建立 GitHub Release,因此本節沒有 tag 觸發或 gh release 的驗收項。
test_m03f_workflow_contract.py 靜態斷言 m03-standalone.yml 不含 gh release、refs/tags/v、tags: 與 contents: write,防止 Release 通路日後回流。scripts\build-standalone.cmd --version <版本> → 取 dist\adventure-table-standalone-<版本>.zip,由維護者自行保存與分發。docs/M03/release-notes-template.md 存在,且明文寫 “schema is unstable during M03”。apps/server/tests/test_m03_import_boundary.py:
test_character_distribution_import_graph_has_no_multiplayer_dependencies:對每個 guarded module 呼叫 _reachable_import_graph,斷言 forbidden regex (?:^|\.)(?:rooms?|sessions?|seats?|campaigns?|party_rosters?)(?:\.|$) 命中集合為空。regex 的可選複數是刻意的:app.api.rooms 這類複數 resource module 名比單數更可能是 P2 的寫法,segment 邊界只擋 session_scope / roommate 這類非多人層名稱。
app.content、app.domain.character、app.domain.character_builder、app.persistence.characters、app.persistence.builder_drafts、app.persistence.state_mutations、app.api.characters、app.api.character_builder、app.api.reference、app.api.content_presentation、app.api.meta、app.api.error_handlers、app.standalone。test_standalone_import_graph_never_reaches_web_entrypoint:app.standalone 的可達 import graph 內不得含 app.main。test_import_boundary_fixture_detects_multiplayer_module:反例 fixture module(tmp 檔)驗證 boundary test 本身有效——單數 app.room.fake 與複數 app.api.rooms 都會被抓到。test_forbidden_regex_matches_module_segments_not_substrings、test_forbidden_regex_matches_plural_resource_module_names:鎖住 regex 的命中與非命中邊界。test_m03e_standalone_composition 於 CI Windows runner 上綠。apps/server/tests/test_m03_standalone_composition.py(實作規格 F.4)同樣須綠。於 M03 期間的最新網頁版 + 最新單機版 .zip 上執行以下六條流程,每條保留 evidence(截圖 / 錄影 / test log):
A.json。A.json。landing_mode="character"。B.json。xge 的角色(Gloom Stalker Ranger)。C.json。enabled_content_packs 未含 xge 的單機版設定匯入 C.json(透過設 ADVENTURE_TABLE_ENABLED_CONTENT_PACKS 環境變數為 subset,或啟動 launcher 時另傳)。landing_mode="draft" 且 unresolved 清單含 subclass ref(origin=build)。srd5.1:item:potion-of-healing),但這件物品不來自 Starting Equipment 或任何 Build 選擇——是 DM 後來給的。匯出為 D.json。srd5.1:item:potion-of-healing 刻意標為缺失(可透過測試 fixture 覆寫 registry,讓該 stable key 無法解析)。landing_mode="draft_with_history_loss";unresolved 內出現 origin="state"。選擇 inventory item 而非 active infusion 的原因:active infusion 的 infusion_ref 通常同時存在 Build 的 infusion_refs 中;若 pack 缺失,build ref 會先未解析,state ref 只是重複命中,無法乾淨證明「build 全解、僅 state 缺」情境。
A.json 於單機版匯入第二次。duplicate_hint。fixture_legacy_no_provenance.json(builder_provenance 全為 null)。draft_reconstruction_unavailable 訊息;character_import_records 無新 row;characters 無新 row。m03-standalone.yml 綠。test_m03f_workflow_contract.py::test_m03f_workflow_never_publishes_a_github_release 綠(workflow 不含 gh release / refs/tags/v / tags: / contents: write)。app.standalone 不 import app.main)綠。scripts\build-standalone.cmd --version <版本> 產出 dist\adventure-table-standalone-<版本>.zip。PROJECT_BRIEF.md 標記 M03 closeout 並列出重點成果。AGENTS.md(本專案)於必要處補一句:M03 已交付,未來新增 P Phase / M Phase 不得違反 3.2 界線(含 app.standalone 不 import app.main)。docs/M03/M03-G_CLOSEOUT.md 撰寫:
SearchableSelect / 訊息模板 / rejection messages 兩語一致。已知問題.mdM03 各手動流程的截圖 / 錄影建議放於 docs/M03/evidence/,並於對應 closeout 文件引用檔名。若使用者選擇口頭 walkthrough 而非留檔,closeout 文件明示。
apps/server/tests/test_m03<letter>_<topic>.py。apps/web/src/**/*.test.ts / .test.tsx。apps/web/e2e/m03<letter>-<topic>.spec.ts。apps/server/tests/data/m03/*.json。docs/M03/baseline/m03a-start.json。.github/workflows/m03-*.yml。scripts/smoke_standalone.py。scripts/build-standalone.cmd。命名一致有助於 codex reviewer 定位證據。