Phase:M03 — Standalone Character Builder Distribution 本文件定義 M03-A~M03-G 的具體實作契約:模組、API、資料模型、資料流、接線、必要技術決策。驗收意圖見
實作規格.md;測試證據見測試指南.md。
最後更新:2026-09-04
app.main(線上版)與 app.standalone(單機版)共用 app.content / app.domain / app.persistence / app.api;差別只在掛哪些 router、資料庫走 SQLite 或 Postgres、GET /api/meta/capabilities 回什麼值。app.standalone 不 import app.main;共用邏輯放在 neutral module。CharacterBuild.model_validate / CharacterState.model_validate / BuilderDraftPayload.model_validate / validate_build_references / validate_state_against_build 完整鏈。REPOSITORY_ROOT 等常數全部改為呼叫 resolver;api/dependencies.py 對 CONTENT_PACKS_ROOT 的 import、content/__init__.py 對 DEFAULT_CONTENT_PACKS 的 monkey-patch、alembic/env.py 對 settings.database_url 的直接讀取,一併遷移。resolve_database_url(),api.dependencies.get_database_engine / alembic/env.py / launcher 三處都走它;SQLite 與 Postgres 走同一條入口。Settings 不啟用 env_prefix,維持 Docker DATABASE_URL 語意;新欄位以 AliasChoices 明列環境變數名;tuple 有 field_validator 明確把 comma string 切開。datas 明列 alembic/alembic.ini / env.py / versions/*.py;launcher 建 Config(_MEIPASS / 'alembic' / 'alembic.ini') 並注入 resolve_database_url()。psycopg 移到 optional extra,PyInstaller 不打包網頁 driver。app.standalone 不 import app.main 是永久契約。<exe_dir>/data 與 <exe_dir>/web;PyInstaller bundle 只帶 alembic resources 與 Python bytecode,不內嵌第二份 data/web。/api/meta/capabilities 是兩 entry 共同 neutral endpoint;router 用 factory create_meta_router(channel),每個 entry 建自己的 router instance,避免 global 累積。parent_version_id / superseded_by_version_id 由第二 pass UPDATE 填入,避免 FK 於 INSERT 時指向尚未存在的 row。model_validate → 對應 machine code,避免全域 422 覆蓋 rejection codes。character_versions.builder_provenance;缺才走 legacy fallback。M03 完成後 backend 相關 module layout(僅列與 M03 相關者):
apps/server/
app/
main.py # 線上版 entry(M03-E 改為呼叫 neutral shared modules + create_meta_router("web"))
standalone.py # 單機版 entry(M03-E 新增;不 import app.main;docs_url=None)
launcher.py # PyInstaller entry(M03-E 新增)
paths.py # content root / data path / SPA root / database URL 解析(M03-A 新增)
config.py # 現有;M03-A 加入 content_root / database_path / spa_root / enabled_content_packs setting(各自 AliasChoices,不改 env_prefix)
content/
__init__.py # M03-A 移除對 _registry.DEFAULT_CONTENT_PACKS 的 monkey-patch
registry.py # M03-A 改為呼叫 paths.resolve_* + settings.enabled_content_packs;移除路徑常數;新增 installed_pack_ids
domain/
character/
character_builder/
rules.py # M03-A 改為呼叫 paths.resolve_rules_path()
versions.py # M03-B 修改 draft seed 路徑,優先讀 character_versions.builder_provenance
persistence/
characters.py # 現有;M03-B 加 builder_provenance 欄位讀寫;M03-C 落地路徑加兩階段 insert helper
builder_drafts.py # 現有
character_imports.py # M03-C 新增(import records + 至多一欄非 NULL CheckConstraint)
state_mutations.py # 現有
api/
characters.py # M03-B 加 export、M03-C 加 import(raw body)
character_builder.py # 現有
reference.py # 現有
content_presentation.py # 現有
meta.py # M03-E 新增(create_meta_router(channel) factory;無 module-level router)
error_handlers.py # M03-E 新增(neutral;由 main / standalone 共用)
interop/ # M03-B / M03-C 新增
character_export.py # export payload builder
character_import.py # import parser / validator / lander(raw body path)
content_ref_walker.py # collect_build_refs + collect_state_refs
json_schema.py # envelope + payload pydantic model(含 strict version_kind enum)
db.py # 現有;M03-D 加 SQLite FK PRAGMA event listener
alembic/
alembic.ini # 現有;M03-A 於 env.py 中 URL 改走 resolve_database_url()
env.py # M03-A 改為 resolve_database_url()
versions/
<next revision>_m03b_builder_provenance.py # M03-B
<next revision>_m03c_character_import_records.py # M03-C
pyinstaller/
standalone.spec # M03-E 新增;datas 內含 alembic/、excludes 內含 psycopg
scripts/
build-standalone.cmd # M03-E 新增
smoke_standalone.py # M03-F 新增
Frontend 新增(apps/web/src/):
features/character-io/
ImportCharacterDialog.tsx # M03-C(含 draft_with_history_loss warning banner)
ExportCharacterButton.tsx # M03-B
api.ts # 對應端點呼叫
features/capabilities/
CapabilityProvider.tsx # M03-E(讀 /api/meta/capabilities)
useCapability.ts
CapabilityDisabledPage.tsx
i18n/
copy/character-io.zh-TW.ts # M03-B / M03-C
copy/character-io.en.ts
copy/capabilities.zh-TW.ts # M03-E
copy/capabilities.en.ts
Migration 檔名:不寫死編號。實作者建立 migration 時,以當時 Alembic head 的下一個 revision 命名。原因:M01 Full Closeout 前仍可能新增 subphase 與 migration,避免撞號。
apps/server/app/paths.pyfrom pathlib import Path
import os
import sys
from app.config import settings
def _executable_dir() -> Path | None:
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return None
def _meipass_root() -> Path | None:
meipass = getattr(sys, "_MEIPASS", None)
return Path(meipass) if meipass else None
def resolve_content_root() -> Path:
env = os.environ.get("ADVENTURE_TABLE_CONTENT_ROOT") or settings.content_root
if env:
candidate = Path(env)
if candidate.is_dir():
return candidate
raise RuntimeError(
f"[env] ADVENTURE_TABLE_CONTENT_ROOT 指向的路徑不存在: {candidate}"
)
exe_dir = _executable_dir()
if exe_dir is not None:
candidate = exe_dir / "data"
if candidate.is_dir():
return candidate
meipass = _meipass_root()
if meipass is not None:
fallback = meipass / "data"
if fallback.is_dir():
return fallback
raise RuntimeError(
f"[frozen] 找不到 data 目錄: {candidate}(也未於 _MEIPASS 找到)"
)
# dev / test:repo 相對
# 檔案位於 apps/server/app/paths.py
# parents[0]=app, [1]=apps/server, [2]=apps, [3]=repo root
return Path(__file__).resolve().parents[3] / "data"
def resolve_rules_path() -> Path:
return resolve_content_root() / "rules" / "dnd5e-2014" / "character-builder.json"
def resolve_localization_root() -> Path:
return resolve_content_root() / "localization"
def resolve_srd_content_root() -> Path:
return resolve_content_root() / "srd5.1"
def resolve_spa_root() -> Path | None:
"""Standalone 用;優先環境變數,其次 <exe_dir>/web,其次 _MEIPASS/web,dev 時 None。"""
env = os.environ.get("ADVENTURE_TABLE_SPA_ROOT") or settings.spa_root
if env:
candidate = Path(env)
if candidate.is_dir():
return candidate
return None
exe_dir = _executable_dir()
if exe_dir is not None:
candidate = exe_dir / "web"
if candidate.is_dir():
return candidate
meipass = _meipass_root()
if meipass is not None:
fallback = meipass / "web"
if fallback.is_dir():
return fallback
return None
STANDALONE_DB_FILENAME = "adventure-table.sqlite3"
def resolve_database_path() -> Path | None:
"""SQLite 檔位置;回傳 None 代表本次執行不使用 SQLite(web entry)。
順序:env → settings.database_path → frozen 時 <exe_dir>/<filename>
→ launcher dev 執行時 <cwd>/<filename>。
"""
path = os.environ.get("ADVENTURE_TABLE_DATABASE_PATH") or settings.database_path
if path:
return Path(path).resolve()
exe_dir = _executable_dir()
if exe_dir is not None:
return (exe_dir / STANDALONE_DB_FILENAME).resolve()
# 非 frozen:只有 launcher 會走到這裡(web entry 不呼叫 resolve_database_path()
# 以外的 standalone 路徑,且其 settings.database_url 仍為 Postgres)。
if _running_as_launcher():
return (Path.cwd() / STANDALONE_DB_FILENAME).resolve()
return None
def resolve_database_url() -> str:
"""Web / Standalone / Alembic 共用的 SSOT."""
db_path = resolve_database_path()
if db_path is not None:
return f"sqlite+pysqlite:///{db_path.as_posix()}"
return settings.database_url
_running_as_launcher() 由 app/launcher.py 於 main() 最前面設一個 module-level flag(或設 ADVENTURE_TABLE_DATABASE_PATH 本身即可,見 8.4);實作者擇一,但必須有明確機制讓「非 frozen 的 launcher dev 執行」拿到 SQLite 而不是 Postgres。
這條 fallback 順序是硬契約:resolve_database_url() 只在 resolve_database_path() 回 None 時才回 settings.database_url。settings.database_url 預設是 PostgreSQL,是 web entry 的正確值;若 standalone 走到它,症狀不是「連不到 DB」——E.7 的 excludes 明列 psycopg,實際會在 alembic upgrade 階段噴 driver 找不到的堆疊,難以對應到根因。因此 8.2 另設啟動硬守衛。
實作者於 M03-A 開工時以檔案實際位置驗證 parents[3] 深度,若 apps 結構調整則同步修正。
app/content/registry.py:
REPOSITORY_ROOT、CONTENT_PACKS_ROOT、DEFAULT_CONTENT_ROOT、DEFAULT_SRD_CONTENT_ROOT、DEFAULT_CONTENT_PACKS。load_default_content_registry() 改為呼叫 resolve_content_root() 與 resolve_srd_content_root();讀 settings.enabled_content_packs 決定 pack 集合。ContentRegistry.from_root(...) 呼叫端保持形狀不變,但實際路徑經 resolver。ContentRegistry.installed_pack_ids:from_root() 掃 content root 下帶 manifest.json 的目錄得出,from_directory() 與 legacy 建構式則等同該單一 pack。它與 enabled_pack_ids 一起決定 subset 語意(實作規格 A.5.1):_validate_cross_references() 只在「installed 且 not enabled」時放行未解析的 ref,source 未安裝或目標 pack 已啟用卻缺 entry 一律 raise。app/content/builder_content_validation.py 與 app/content/background_roleplay.py:
_target_pack_is_installed_but_disabled() / installed_pack_ids 比對)。kind 不符先於 disabled 放行判斷,永遠 raise。app/content/__init__.py:
_registry.DEFAULT_CONTENT_PACKS = (...) monkey-patch。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:
from app.content.registry import CONTENT_PACKS_ROOT。from app.paths import resolve_content_root, resolve_database_url。get_content_localization() 呼叫時傳入 resolve_content_root()。get_database_engine() 用 resolve_database_url() 建 engine。alembic/env.py:
settings.database_url 改為 resolve_database_url()。Path(__file__).resolve().parents[N] 用於路徑推導的位置;指向 repo root 的一律改呼叫 resolver。<module>.<CONSTANT> = ... 對已移除常數的 legacy monkey-patch。from app.content.registry import CONTENT_PACKS_ROOT 等 legacy import。load_default_content_registry() 讓例外冒出,app.main / app.standalone import 時中斷。[env] / [frozen] / [repo-relative] 前綴以標示解析階段,供 launcher 顯示。app/config.py(M03-A 之後):
from typing import Annotated, Iterable
from pydantic import AliasChoices, Field, field_validator
from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict
_DEFAULT_PACKS = (
"srd5.1", "phb2014", "scag", "gos",
"vgm", "vrgr", "tce", "xge", "mtf",
)
class Settings(BaseSettings):
app_name: str = "Adventure Table API"
database_url: str = (
"postgresql+psycopg://adventure:adventure@localhost:5432/adventure_table"
)
content_root: str | None = Field(
default=None,
validation_alias=AliasChoices("ADVENTURE_TABLE_CONTENT_ROOT"),
)
database_path: str | None = Field(
default=None,
validation_alias=AliasChoices("ADVENTURE_TABLE_DATABASE_PATH"),
)
spa_root: str | None = Field(
default=None,
validation_alias=AliasChoices("ADVENTURE_TABLE_SPA_ROOT"),
)
# NoDecode 關掉本欄的環境變數 JSON decode;沒有它,EnvSettingsSource 會在
# field_validator 之前就把 comma string 當 complex type 解析並丟 SettingsError。
enabled_content_packs: Annotated[tuple[str, ...], NoDecode] = Field(
default=_DEFAULT_PACKS,
validation_alias=AliasChoices("ADVENTURE_TABLE_ENABLED_CONTENT_PACKS"),
)
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
@field_validator("enabled_content_packs", mode="before")
@classmethod
def _parse_pack_list(cls, value: object) -> tuple[str, ...]:
if isinstance(value, str):
parts = tuple(p.strip() for p in value.split(",") if p.strip())
return parts or _DEFAULT_PACKS
if isinstance(value, Iterable):
return tuple(str(p) for p in value)
return _DEFAULT_PACKS
關鍵:不 啟用 env_prefix,維持 Docker DATABASE_URL 直接讀取。新欄位靠 AliasChoices 明列環境變數名。
enabled_content_packs 需要 NoDecode 與 field_validator 兩件事一起才成立:
tuple[str, ...] 視為 complex type,EnvSettingsSource 在跑任何 validator 之前就會對環境變數值做 json.loads。單靠 field_validator(mode="before") 時,ADVENTURE_TABLE_ENABLED_CONTENT_PACKS=srd5.1,phb2014 會直接以 SettingsError: error parsing value for field "enabled_content_packs" from source "EnvSettingsSource" 失敗,validator 收不到字串。此行為已於 pydantic-settings 2.15.0(pyproject.toml 釘 >=2.14,<3)實測確認。Annotated[..., NoDecode] 只關掉該欄的自動 decode,其餘欄位不受影響;解出的原始字串再交給 field_validator 切成 tuple,並同時接受 list/tuple 輸入。EnvSettingsSource 或 settings_customise_sources() 達成同樣效果亦可,但不得只留 field_validator。app/interop/json_schema.py:
from enum import Enum
from typing import Literal, Any
from uuid import UUID
from datetime import datetime
from pydantic import BaseModel
class VersionKind(str, Enum):
CREATE = "create"
LEVEL_UP = "level_up"
BUILD_EDIT = "build_edit"
CORRECTION = "correction"
LEGACY = "legacy"
# 值集合以 persistence 現行 version_kind 為準;實作者對照 versions.py 補齊
class Envelope(BaseModel):
schema_version: Literal["unstable"]
schema_status: Literal["unstable", "locked"]
ruleset: str
content_requirements: list["PackRequirement"]
stable_key_refs_summary: int
source_character_id: UUID
source_export_id: UUID
source_app: "SourceApp"
exported_at: datetime
class PackRequirement(BaseModel):
pack: str
version: str
class SourceApp(BaseModel):
name: Literal["adventure-table"]
channel: Literal["web", "standalone"]
commit: str | None = None
build: str | None = None
class ExportedVersion(BaseModel):
version_no: int
version_kind: VersionKind # strict enum
parent_version_no: int | None
superseded_by_version_no: int | None
change_note: str | None
build_payload: dict[str, Any] # domain validator 於 pipeline 執行
builder_provenance: dict[str, Any] | None # BuilderDraftPayload validator 於 pipeline 執行
created_at: datetime
class ExportedCharacter(BaseModel):
name: str
ruleset: str
class ExportedState(BaseModel):
state_payload: dict[str, Any] # CharacterState validator 於 pipeline 執行
class ExportPayload(BaseModel):
character: ExportedCharacter
current_version_no: int
versions: list[ExportedVersion]
current_state: ExportedState
class CharacterExport(BaseModel):
envelope: Envelope
payload: ExportPayload
VersionKind 的具體值以 persistence 現行值集合為準;實作者於 M03-B 開工時 grep version_kind 補齊,避免遺漏。
content_requirements 計算content_ref_walker.collect_build_refs() 走訪每個 versions[*].build_payload,collect_state_refs() 走訪 current_state.state_payload。stable_key_refs_summarylen(build_refs_union_over_all_versions) + len(state_refs)。{sanitized_character_name}-v{current_version_no}-{exported_at_utc}.json[A-Za-z0-9_\-\.],其他字元換 _;截斷至 60 字元;empty 時 fallback character。GET /api/characters/{character_id}/export
→ 200 OK application/json
Content-Disposition: attachment; filename="..."
X-Adventure-Table-Character-Archived: "true" | "false"
→ 404 character_not_found
builder_provenance 欄位與 versioned draft seeding SSOTAlembic migration(<next>_m03b_builder_provenance.py):
add_column("character_versions", "builder_provenance", JSON().with_variant(JSONB(), "postgresql"), nullable=True)Confirm 路徑寫入:
apps/server/app/domain/character_builder/creation.py:Version 1 Confirm 時,把 BuilderDraftPayload.model_dump() 寫入。apps/server/app/domain/character_builder/versions.py:Level Up Confirm 時,把 versioned draft payload snapshot 寫入。Versioned draft seeding SSOT(apps/server/app/domain/character_builder/versions.py:344 附近):
def _seed_versioned_draft_payload(
character: PersistedCharacter,
base_version: PersistedCharacterVersion,
registry: ContentRegistry,
stored_draft_payload: BuilderDraftPayload | None,
) -> BuilderDraftPayload:
# 1. 優先讀 character_versions.builder_provenance
provenance = base_version.builder_provenance
if provenance is not None:
try:
return BuilderDraftPayload.model_validate(provenance)
except ValidationError:
# 記 log,退到 fallback;不因 provenance 損壞而 crash Level Up
pass
# 2. 舊 draft row
if stored_draft_payload is not None:
return stored_draft_payload
# 3. 最後手段:legacy 反推
return legacy_payload_from_build(character, registry)
現行 legacy_payload_from_build 保留但改為 last-resort fallback。
POST /api/characters/import
Content-Type: application/json
Query:
dry_run: bool = false
Body: raw bytes (self-parsed)
→ 200 OK ImportResult (dry_run=true)
→ 201 Created ImportResult (dry_run=false, success)
→ 400 import_rejected + reason code
→ 413 payload_too_large
Endpoint 骨架:
from fastapi import APIRouter, Request, HTTPException, status
from pydantic import ValidationError
MAX_BODY_BYTES = 5 * 1024 * 1024
@router.post("/import", status_code=status.HTTP_200_OK)
async def import_character(request: Request, dry_run: bool = False) -> ImportResult:
raw = await request.body()
if len(raw) > MAX_BODY_BYTES:
raise import_reject(413, "payload_too_large", {"limit": MAX_BODY_BYTES})
try:
parsed = json.loads(raw)
except json.JSONDecodeError as exc:
raise import_reject(400, "invalid_envelope_shape", {"reason": str(exc)})
try:
payload = CharacterExport.model_validate(parsed)
except ValidationError as exc:
# 依 error location 分派:envelope 段 → invalid_envelope_shape;payload 段 → invalid_payload_shape
raise _dispatch_validation_error(exc)
result = preview_import(payload, registry, imports_repo)
if not dry_run:
result = commit_import(payload, result, session)
return result
不使用 typed body 綁定,避免 pydantic 錯誤被 FastAPI 全域 RequestValidationError handler 轉為 422 validation_failed。全域 handler 保持既有行為,不觸碰。
ImportResult:
class UnresolvedRef(BaseModel):
stable_key: str
kind: str
pack: str
index: str
origin: Literal["build", "state"]
version_no: int | None # build ref 帶所在 version;state ref 為 None
class ImportPreview(BaseModel):
name: str
ruleset: str
highest_level: int
class_summary: list[str]
version_count: int
current_version_no: int
class DuplicateHint(BaseModel):
prior_import_count: int
most_recent_imported_at: datetime
class ImportResult(BaseModel):
landing_mode: Literal["character", "draft", "draft_with_history_loss"]
resolved_count: int
unresolved_refs: list[UnresolvedRef]
character_preview: ImportPreview
duplicate_hint: DuplicateHint | None
committed: bool
landed_character_id: UUID | None
landed_draft_id: UUID | None
source_export_id: UUID
以 M02-G / M02-H 建立的 machine-readable + params 契約為模板:
invalid_envelope_shape(400)invalid_payload_shape(400)unsupported_schema_status(400)unsupported_ruleset(400)ruleset_mismatch(400)version_chain_gap(400)version_chain_out_of_order(400)current_state_version_missing(400)version_lineage_invalid(400)version_lineage_self_reference(400)version_lineage_direction_invalid(400)version_lineage_cycle(400)invalid_version_kind(400)invalid_build_shape(400)invalid_builder_provenance(400)state_shape_invalid(400)build_references_invalid(400)state_inconsistent_with_build(400)draft_reconstruction_unavailable(400)payload_too_large(413)前端把 code + params 翻成 zh-TW / en presentation,比照 M02-H builder issue 契約。
ValidationError → code 映射(必要 special case)schema_status: Literal["unstable", "locked"] 與 version_kind: VersionKind 都在 CharacterExport.model_validate() 階段失敗,不會抵達 preview_import() 的任何語意步驟。因此 unsupported_schema_status 與 invalid_version_kind 不可能由語意檢查產生,必須在 endpoint 捕捉 ValidationError 時特判:
_SPECIAL_CASE_BY_FIELD = {
"schema_status": "unsupported_schema_status",
"version_kind": "invalid_version_kind",
}
def map_validation_error(exc: ValidationError) -> str:
"""ValidationError → rejection code。順序不依賴 pydantic 的 error 排列。"""
hits: list[tuple[tuple, str]] = []
for err in exc.errors():
loc = err["loc"]
for field, code in _SPECIAL_CASE_BY_FIELD.items():
if field in loc:
hits.append((loc, code))
if hits:
# 多個 special case 同時命中時取 loc 排序最前者,確保同一輸入回同一 code
return min(hits, key=lambda item: tuple(map(str, item[0])))[1]
# 泛用 shape code:envelope 段 vs payload 段
if any(err["loc"] and err["loc"][0] == "payload" for err in exc.errors()):
return "invalid_payload_shape"
return "invalid_envelope_shape"
schema_version(Literal["unstable"])刻意不列為 special case,歸 invalid_envelope_shape;這是明文決定,避免它成為下一個同型爭議。ValidationError 同時含 version_kind 錯誤與其他欄位錯誤時,回 invalid_version_kind。app/interop/content_ref_walker.py:
collect_build_refs(build_payload: dict) -> Iterable[ContentRef]。collect_state_refs(state_payload: dict) -> Iterable[ContentRef]。ContentRef = {stable_key: str, kind: str, pack: str, index: str}。raise UnknownRefShape。apps/server/app/domain/character/schemas.py):
CharacterState.conditions[].condition_refCharacterState.prepared_spells[].spell_keyCharacterState.inventory_state.inventory_entries[].item_ref(依現行 InventoryEntry 結構)CharacterState.active_infusions[].infusion_refCharacterState.spell_storing_item.spell_ref(若存在)def preview_import(
payload: CharacterExport,
registry: ContentRegistry,
imports_repo: ImportRecordsRepo,
) -> ImportResult:
# 1. Envelope 語意檢查:只做 ruleset(unsupported_ruleset)。
# schema_status / version_kind 已於 CharacterExport.model_validate() 階段
# 被 5.2.1 的 special-case 映射攔下,不在此判定。
# 2. Version chain 一致性 → reject
# 3. Version lineage 完整性(self-ref / direction / cycle)→ reject
# 4. For each version:
# _ = CharacterBuild.model_validate(v.build_payload) → reject on failure
# 5. For each v where v.builder_provenance is not None:
# _ = BuilderDraftPayload.model_validate(v.builder_provenance) → reject invalid_builder_provenance
# 6. state = CharacterState.model_validate(payload.current_state.state_payload) → reject
# 7. Ruleset 三重 cross-check → reject ruleset_mismatch
# 8. build_refs_by_version = {v.version_no: list(collect_build_refs(v.build_payload)) for v in versions}
# state_refs = list(collect_state_refs(state_payload))
# 9. resolved / unresolved 分類;unresolved 標示 origin + version_no
# 10. Landing mode:
# build_unresolved = [r for r in unresolved if r.origin == "build"]
# state_unresolved = [r for r in unresolved if r.origin == "state"]
# if not build_unresolved and not state_unresolved:
# for v in versions:
# validate_build_references(build_v, registry) → reject
# validate_state_against_build(state, current_build, registry) → reject
# landing_mode = "character"
# elif build_unresolved:
# current_provenance = payload.versions[current_version_no].builder_provenance
# if current_provenance is None:
# reject draft_reconstruction_unavailable
# landing_mode = "draft"
# else: # only state unresolved
# current_provenance = payload.versions[current_version_no].builder_provenance
# if current_provenance is None:
# reject draft_reconstruction_unavailable
# landing_mode = "draft_with_history_loss"
# 11. duplicate_hint = imports_repo.find_hint(envelope.source_character_id)
# 12. character_preview from current_version_build
# 13. committed=False, source_export_id=envelope.source_export_id
landing_mode == "character"(兩階段 insert)def commit_import_as_character(
payload: CharacterExport,
session: DBSession,
) -> UUID:
with session.begin():
new_character_id = uuid4()
session.execute(insert(characters).values(
id=new_character_id,
name=payload.payload.character.name,
ruleset=payload.payload.character.ruleset,
current_version_id=None,
))
version_id_by_no = {v.version_no: uuid4() for v in payload.payload.versions}
# Pass 1: INSERT with NULL lineage
for v in payload.payload.versions:
session.execute(insert(character_versions).values(
id=version_id_by_no[v.version_no],
character_id=new_character_id,
version_no=v.version_no,
build_payload=v.build_payload,
version_kind=v.version_kind.value,
parent_version_id=None,
superseded_by_version_id=None,
change_note=v.change_note,
builder_provenance=v.builder_provenance,
created_at=v.created_at,
))
# Pass 2: UPDATE lineage fields
for v in payload.payload.versions:
parent_id = (
version_id_by_no.get(v.parent_version_no)
if v.parent_version_no is not None
else None
)
superseded_id = (
version_id_by_no.get(v.superseded_by_version_no)
if v.superseded_by_version_no is not None
else None
)
if parent_id is not None or superseded_id is not None:
session.execute(update(character_versions).where(
character_versions.c.id == version_id_by_no[v.version_no]
).values(
parent_version_id=parent_id,
superseded_by_version_id=superseded_id,
))
current_version_id = version_id_by_no[payload.payload.current_version_no]
session.execute(update(characters).where(
characters.c.id == new_character_id
).values(current_version_id=current_version_id))
session.execute(insert(character_states).values(
character_id=new_character_id,
state_payload=payload.payload.current_state.state_payload,
))
session.execute(insert(character_import_records).values(
id=uuid4(),
character_id=new_character_id,
draft_id=None,
source_character_id=payload.envelope.source_character_id,
source_export_id=payload.envelope.source_export_id,
landing_mode="character",
imported_at=datetime.utcnow(),
))
return new_character_id
Preview 已跑完的 domain validators 於 commit 開始時再跑一次(防禦 dry-run 與 commit 之間 registry 狀態變動)。
landing_mode == "draft" / "draft_with_history_loss"payload.versions[current_version_no].builder_provenance 建立 fresh CREATE Draft,走既有 builder_drafts 模組的 create flow。source_character_id / source_export_id 記入 character_import_records,landing_mode 值對應("draft" 或 "draft_with_history_loss"),draft_id=<new draft id>。builder_provenance 為 None 於 current version)於 preview 階段已被 reject,不進 commit。Alembic migration <next revision>_m03c_character_import_records.py:
character_import_records
id UUID PK
character_id UUID NULL FK → characters.id ON DELETE SET NULL
draft_id UUID NULL FK → character_build_drafts.id ON DELETE SET NULL
source_character_id UUID NOT NULL
source_export_id UUID NOT NULL
landing_mode VARCHAR(32) NOT NULL
imported_at TIMESTAMPTZ NOT NULL DEFAULT now()
INDEX (source_character_id)
INDEX (source_export_id)
CHECK (character_id IS NULL OR draft_id IS NULL) # 至多一欄非 NULL
character_id / draft_id 二選一 nullable:character 落地時 draft_id NULL,反之亦然。建立當下恰好一欄非 NULL 由 service 層保證,DB constraint 只擋「兩欄同時非 NULL」。ON DELETE SET NULL,目標被永久刪除後該欄會變 NULL,原本合法的列會變成兩欄皆 NULL 而違反 XOR,使 DELETE 直接失敗。這與「記錄不刪」以及 D.3 的 ON DELETE SET NULL 驗收互相矛盾,沒有 interpretation 空間。source_character_id 仍留作 dedupe 提示,此時 character_id / draft_id 皆為 NULL 屬預期終態。PRAGMA foreign_keys=ON hook 保證。def find_duplicate_hint(
imports_repo: ImportRecordsRepo,
source_character_id: UUID,
) -> DuplicateHint | None:
records = imports_repo.list_by_source(source_character_id)
if not records:
return None
return DuplicateHint(
prior_import_count=len(records),
most_recent_imported_at=max(r.imported_at for r in records),
)
ExportCharacterButton.tsx:接 characterId;點擊呼叫 GET /api/characters/{id}/export 並觸發 browser download(fetch → blob → URL.createObjectURL → 隱藏 <a> click)。ImportCharacterDialog.tsx:從 Workshop 空狀態按鈕與角色列表區「匯入角色」按鈕開啟。<input type="file" accept="application/json">:選檔後 file.text() 取字串。<textarea>:貼上 JSON 字串。JSON.parse → POST /api/characters/import?dry_run=true(Content-Type: application/json)。resolved_count、unresolved_refs.length、landing_mode 語意化文字、character_preview、duplicate_hint。build @ vN / state),前 10 筆顯示 kind / pack / index,其餘折疊。landing_mode === "draft_with_history_loss" 時,dialog 頂顯示 warning banner:「Current State 與 Version History 都不會保留」;使用者需二次確認。landing_mode 導向 Character Sheet 或 Builder Draft。zh-TW / en 雙語 UI 同步交付。CapabilityProvider.tsx:於 SPA bootstrap 呼叫 GET /api/meta/capabilities,把結果放進 React context。useCapability("room") 之類 hook 讓 navigation / route table 讀取。CapabilityDisabledPage。if (channel === "standalone") ...;分流集中於 capability 表。apps/web/src/i18n/copy/。duplicate_hint 模板、rejection code 對應訊息、capability_disabled 頁面、landing 資料檔提示區、draft_with_history_loss warning banner。zh-TW 一律純中文;en 純英文;不混語。M03-D 開工第一件事:把現有全部 migration(0001 至當時 head,含 M03-B、M03-C 新增者)逐一在 SQLite 上跑一次 upgrade → downgrade → upgrade,抓出:
postgresql.JSONB 直接引用(不透過 with_variant)。batch_op 的 alter_column / drop_constraint / add_constraint。server_default 使用 Postgres-only function(gen_random_uuid() 等)。ARRAY、INET、CIDR 等)。sa.JSON().with_variant(postgresql.JSONB(), "postgresql") 全面採用。alter_column 一律包 batch_op。server_default 用 dialect-neutral(例:sa.text("...") 分 dialect)。於 app/db.py 建 engine 時掛 hook:
from sqlalchemy import event
from sqlalchemy.engine import Engine
@event.listens_for(Engine, "connect")
def _enable_sqlite_fk(dbapi_connection, connection_record):
module_name = type(dbapi_connection).__module__
if module_name.startswith(("sqlite3", "pysqlite")):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
apps/server/tests/test_m03d_migration_sqlite.py:
alembic upgrade head → 驗證 tables / columns 與 metadata.create_all 一致(比對前兩側均排除 alembic_version)。alembic downgrade base → 除 alembic_version(row 數 0)外應無殘留 table。alembic upgrade head 二次 → 確認冪等。apps/server/tests/test_m03d_sqlite_fk.py:
PRAGMA foreign_keys 值為 1。psycopg extrasapps/server/pyproject.toml:
[project]
dependencies = [
"alembic>=1.18,<2",
"fastapi>=0.128,<1",
"pydantic-settings>=2.14,<3",
"sqlalchemy>=2.0,<3",
"uvicorn[standard]>=0.35,<1",
]
[project.optional-dependencies]
web = [
"psycopg[binary]>=3.2,<4",
]
dev = [
"httpx>=0.28,<1",
"pytest>=9,<10",
]
standalone = [
"pyinstaller>=6.10",
]
.[web,dev]。.[standalone]。本節所有內容都在 PyInstaller 的行為之下才成立,而 _MEIPASS 下的 alembic ScriptDirectory、uvicorn.Config("app.standalone:app", ...) 的字串式 import 與 hiddenimports 缺漏,unit test 一律測不到。因此依實作規格 E.0,先做一個只含 launcher + alembic resources + app.standalone 的最小 freeze 並讓它跑起來(建出 SQLite、migrate 到 head、/api/meta/capabilities 回 200),再繼續 8.5 的完整 spec、8.6 build 腳本與前端整合。
這不是額外工作量——最小 spec 通過後直接長成正式 spec,不維護兩份。它換到的是:路徑與 dynamic import 的失敗在只有兩三個變因時就爆開,而不是在十項功能疊完之後一次爆。
app/api/error_handlers.py:
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse
from app.api.errors import APIError
from app.domain.character.validation import CharacterValidationError
from app.persistence.characters import (
CharacterArchivedError,
CharacterNotArchivedError,
CharacterNotFoundError,
)
def register_exception_handlers(app: FastAPI) -> None:
@app.exception_handler(APIError)
def handle_api_error(_request: Request, exc: APIError) -> JSONResponse: ...
@app.exception_handler(CharacterNotFoundError)
def handle_character_not_found(...) -> JSONResponse: ...
# ... 其餘 handler 從 app/main.py 搬過來
app/main.py 相對應段落刪除,改為 register_exception_handlers(app)。
app/api/meta.py(factory pattern,非 module-level global router):
from typing import Literal
from fastapi import APIRouter
from pydantic import BaseModel
class Capabilities(BaseModel):
channel: Literal["web", "standalone"]
capabilities: dict[str, bool]
def _build_capabilities(channel: Literal["web", "standalone"]) -> Capabilities:
base = {
"character_builder": True,
"character_import_export": True,
"room": False,
"campaign": False,
"session": False,
"seat": False,
"combat": False,
"timeline": False,
"ai_actor": False,
}
return Capabilities(channel=channel, capabilities=base)
def create_meta_router(channel: Literal["web", "standalone"]) -> APIRouter:
router = APIRouter(prefix="/api/meta", tags=["meta"])
caps = _build_capabilities(channel)
@router.get("/capabilities", response_model=Capabilities)
def get_capabilities() -> Capabilities:
return caps
return router
app.main 呼叫 create_meta_router("web");app.standalone 呼叫 create_meta_router("standalone")。沒有 module-level meta_router global;同 process import 兩個 entry 時 handler 不會互相污染。
app/standalone.pyfrom fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
from app.api import (
character_builder_router,
characters_router,
content_presentation_router,
reference_router,
)
from app.api.error_handlers import register_exception_handlers
from app.api.meta import create_meta_router
from app.config import settings
from app.content import load_default_content_registry
from app.paths import resolve_database_url, resolve_spa_root
def _require_sqlite() -> None:
"""單機版硬守衛:resolved URL 不是 SQLite 就立刻中止。
沒有這條時,DB URL 解析錯誤會延後到 alembic upgrade 才以 psycopg 缺失的
形式爆開(bundle 刻意 exclude psycopg),根因難以對應。
"""
url = resolve_database_url()
if not url.startswith("sqlite+pysqlite://"):
raise RuntimeError(
"單機版只能使用 SQLite,實際解析到的 database URL 並非 SQLite。"
f"(resolved={url!r})請檢查 ADVENTURE_TABLE_DATABASE_PATH。"
)
def create_standalone_app() -> FastAPI:
_require_sqlite()
content_registry = load_default_content_registry()
app = FastAPI(
title=f"{settings.app_name} (standalone)",
docs_url=None,
redoc_url=None,
openapi_url=None,
)
app.state.content_registry = content_registry
app.include_router(reference_router)
app.include_router(content_presentation_router)
app.include_router(characters_router)
app.include_router(character_builder_router)
app.include_router(create_meta_router("standalone"))
register_exception_handlers(app)
spa_root = resolve_spa_root()
if spa_root is not None:
assets_dir = spa_root / "assets"
if assets_dir.is_dir():
app.mount("/assets", StaticFiles(directory=assets_dir), name="assets")
@app.get("/{full_path:path}", include_in_schema=False)
async def spa_fallback(full_path: str) -> FileResponse:
# 明確排除 /api/ 前綴,讓未知 API 路徑走 FastAPI 預設 404
if full_path.startswith("api/") or full_path == "api":
raise HTTPException(status_code=404, detail="Not Found")
candidate = spa_root / full_path
if candidate.is_file():
return FileResponse(candidate)
return FileResponse(spa_root / "index.html")
return app
app = create_standalone_app()
關鍵:
app.main。_require_sqlite() 於 app 組裝時就跑:這讓「單機版落到 Postgres URL」變成不需要 freeze、不需要 CI 就測得到的失敗,而非只有雙擊 EXE 才發現。docs_url / redoc_url / openapi_url 全部 None:release 給朋友的產物不需要 OpenAPI 頁。/api/ 前綴。app/main.pyregister_exception_handlers(app)。app.include_router(create_meta_router("web"))。app/launcher.pyfrom pathlib import Path
import os
import sys
import signal
import socket
import threading
import time
import webbrowser
import uvicorn
from alembic.config import Config
from alembic import command
from app.paths import (
mark_launcher_mode,
resolve_content_root,
resolve_database_path,
resolve_database_url,
resolve_spa_root,
)
def _find_free_port(start: int = 8000, end: int = 8100) -> int:
for port in range(start, end):
with socket.socket() as s:
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"找不到 {start}-{end} 範圍內可用的 port")
def _alembic_config_path() -> Path:
if getattr(sys, "frozen", False):
# bundle 內的 alembic 資源(PyInstaller datas)
meipass = Path(getattr(sys, "_MEIPASS"))
return meipass / "alembic" / "alembic.ini"
# dev / test:repo 內原本位置
return Path(__file__).resolve().parents[1] / "alembic" / "alembic.ini"
def _run_migrations() -> None:
cfg_path = _alembic_config_path()
if not cfg_path.is_file():
raise RuntimeError(f"找不到 alembic.ini: {cfg_path}")
config = Config(str(cfg_path))
config.set_main_option("sqlalchemy.url", resolve_database_url())
# script_location 於 alembic.ini 中為相對;PyInstaller datas 已保留目錄結構
config.set_main_option("script_location", str(cfg_path.parent))
command.upgrade(config, "head")
def _print_banner(port: int) -> None:
print(f"Adventure Table Standalone")
print(f" Database: {resolve_database_path()}")
print(f" Content root: {resolve_content_root()}")
print(f" SPA root: {resolve_spa_root()}")
print(f" Listening on: http://127.0.0.1:{port}/")
print(f" Press Ctrl+C or close this window to stop.")
def main() -> int:
# 第一步就把資料檔路徑釘死,之後所有 resolver / alembic / engine 都讀同一個值。
# 順序是「先解析、再釘死」:mark_launcher_mode() 讓 resolve_database_path()
# 於非 frozen 執行時也能落到 <cwd>/adventure-table.sqlite3,因此 E.5 的
# env → settings.database_path → frozen exe_dir → launcher cwd 完全由該
# resolver 實作。不可先 setdefault 一個預設值再解析——那會讓透過 .env
# 設定 settings.database_path 的情況被自己塞進去的預設值蓋掉。
mark_launcher_mode()
db_path = resolve_database_path()
if db_path is None:
raise RuntimeError("無法解析 SQLite 資料檔路徑")
db_path = db_path.resolve()
# 解析結果已經尊重過外部覆寫,這裡寫回環境變數只為了把絕對路徑釘死,
# 避免之後 cwd 改變時 alembic / app.standalone 重新解析出不同的值。
os.environ["ADVENTURE_TABLE_DATABASE_PATH"] = str(db_path)
try:
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path.touch(exist_ok=True)
except OSError as exc:
raise RuntimeError(
f"資料檔路徑無法寫入: {db_path}({exc})"
) from exc
_run_migrations()
port = _find_free_port()
_print_banner(port)
config = uvicorn.Config("app.standalone:app", host="127.0.0.1", port=port, log_level="info")
server = uvicorn.Server(config)
thread = threading.Thread(target=server.run, daemon=True)
thread.start()
while not server.started:
time.sleep(0.1)
webbrowser.open(f"http://127.0.0.1:{port}/")
try:
while thread.is_alive():
thread.join(timeout=1.0)
except KeyboardInterrupt:
server.should_exit = True
thread.join()
return 0
if __name__ == "__main__":
sys.exit(main())
關鍵:
alembic.ini,不倚賴 CWD。script_location 設為 alembic.ini 所在目錄,讓 ScriptDirectory 於 frozen 時能找到 versions/*.py。resolve_database_url() 為 URL 唯一來源。main() 的第一件事是解析並寫入 ADVENTURE_TABLE_DATABASE_PATH,在 _run_migrations() 之前完成。沒有這一步,雙擊 EXE 時 resolve_database_url() 會落到 settings.database_url(PostgreSQL),而 bundle 因 excludes psycopg 只會噴 driver 缺失堆疊。這是 web 測試與 Python 測試全綠、只有雙擊 EXE 才會發現的失敗模式,因此路徑決策不得留給「呼叫者」。resolve_database_path() 的實際絕對路徑,不得印 <settings> 之類的佔位字串——banner 是使用者唯一能看到解析結果的地方。mkdir + touch)先於 migration,讓「路徑無法寫入」以清楚訊息中止(E.5 要求)。apps/server/pyinstaller/standalone.spec:
Analysis 的 datas 內嵌 alembic resources:
datas = [
("../alembic/alembic.ini", "alembic"),
("../alembic/env.py", "alembic"),
("../alembic/versions", "alembic/versions"),
]
路徑依 spec 檔實際位置調整。
datas 不 內嵌 data/ 或 web/;由 build 腳本複製到產物根目錄。hiddenimports 明列 alembic migration modules(動態載入所需的 import 提示)與 pydantic 內建 codecs。console=True(保留 console window)。onefile=False(one-folder)。excludes 明列 psycopg。README-standalone.txt固定內容大綱:
.sqlite3 複製過來即可)。README-standalone.zh-TW.txt / README-standalone.en.txt,或雙語並列於同一檔)。scripts/build-standalone.cmd1. 建立 tmp venv on Windows。
2. venv 安裝 apps/server 主相依 + standalone extra;不裝 web extra。
3. cd apps/web && npm ci && npm run build → 產物在 apps/web/dist/。
4. cd apps/server && pyinstaller pyinstaller/standalone.spec。
5. 產物根目錄下建立 data/ 與 web/:
copy data/ → dist/adventure-table-standalone/data/
copy apps/web/dist/ → dist/adventure-table-standalone/web/
6. 複製 LICENSE.txt、README-standalone.*.txt。
7. Zip 為 adventure-table-standalone-<version>.zip。
--version <tag> 參數,寫入 launcher 的 build id。--skip-frontend 用於 iterate backend 打包(開發用)。dist/、build/ 重跑不留污染。.github/workflows/m03-standalone.ymlname: M03 Standalone Windows Build
on:
push:
branches: [main]
pull_request:
types: [labeled]
workflow_dispatch:
permissions:
contents: read
jobs:
standalone-build:
if: github.event_name != 'pull_request' || github.event.label.name == 'standalone-build'
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with: { python-version: '3.13' }
- uses: actions/setup-node@v4
with: { node-version: '24' }
- name: Resolve standalone artifact version
id: version
shell: pwsh
run: |
$sha = "$"
"value=m03f-$($sha.Substring(0, 12))" >> $env:GITHUB_OUTPUT
- name: Build standalone
shell: cmd
run: scripts\build-standalone.cmd --version $
- name: Smoke test
shell: cmd
run: .standalone-venv\Scripts\python.exe scripts\smoke_standalone.py dist\adventure-table-standalone --timeout 30
- uses: actions/upload-artifact@v4
with:
name: adventure-table-standalone-$
path: dist/adventure-table-standalone-$.zip
if-no-files-found: error
關鍵:
v* 觸發。 發版一律在本機跑 scripts\build-standalone.cmd --version <版本>;理由見實作規格 F.2(repo 將轉 private,private repo 的 Release asset 無匿名下載連結)。permissions 因此維持 contents: read。.standalone-venv 直接跑,不另外裝 Python 相依。docs/M03/release-notes-template.md 於 M03-F 內建立,內含 JSON schema unstable 說明,供本機發版時複製使用。scripts/smoke_standalone.py:
GET /api/meta/capabilities 200 且 channel="standalone"。GET /api/characters → 200 & 空清單。apps/server/tests/test_m03_import_boundary.py:
def test_character_distribution_import_graph_has_no_multiplayer_dependencies():
guarded_modules = [
"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",
]
forbidden = re.compile(r"(?:^|\.)(?:rooms?|sessions?|seats?|campaigns?|party_rosters?)(?:\.|$)")
for mod_name in guarded_modules:
graph = collect_import_graph(mod_name)
offenders = [m for m in graph if forbidden.search(m)]
assert not offenders, f"{mod_name} 透過 {offenders} 觸及多人層"
def test_standalone_import_graph_never_reaches_web_entrypoint():
graph = collect_import_graph("app.standalone")
assert "app.main" not in graph, "app.standalone 不得 import app.main"
collect_import_graph 使用 ast.parse 靜態抽 import / from 語句遞迴,不呼叫 importlib.import_module。M03 期間新增的環境變數(皆走 AliasChoices):
ADVENTURE_TABLE_CONTENT_ROOT:覆寫 content root。ADVENTURE_TABLE_DATABASE_PATH(standalone):覆寫 SQLite 檔位置。launcher 於 main() 最前面先 mark_launcher_mode() 再 resolve_database_path(),由該 resolver 一手實作 E.5 順序(env → settings.database_path → frozen <exe_dir>/adventure-table.sqlite3 → launcher <cwd>/…),解析出的絕對路徑才寫回本環境變數釘死;最終 URL 由 resolve_database_url() 決定。不得先寫入預設值再解析,否則透過 .env 設定的 settings.database_path 會被預設值蓋掉。ADVENTURE_TABLE_SPA_ROOT(standalone / 進階):覆寫前端 SPA build 根目錄。ADVENTURE_TABLE_ENABLED_CONTENT_PACKS:逗號分隔覆寫 pack 集合,經 NoDecode + field_validator parse(見 3.4)。保留:DATABASE_URL(不加 ADVENTURE_TABLE_ 前綴),維持 Docker Compose 現行部署契約。
Launcher 於啟動時計算絕對路徑後寫入對應環境變數,再起 uvicorn。
zh-TW / en presentation 於 M03-C 交付當下同批加入。新增 code:ruleset_mismatch / version_lineage_self_reference / version_lineage_direction_invalid / version_lineage_cycle / invalid_version_kind / invalid_builder_provenance / payload_too_large 均需兩語文案。duplicate_hint 的訊息模板走 params。draft_with_history_loss warning banner 兩語齊備。capability_disabled 頁面於 M03-E 交付當下同批加入兩語。{"error": {"code": "<rejection_code>", "message": "...", "params": {...}}};比照 M02-H 已建立的 machine + params 結構。validation_failed 語意保留給其他 endpoint。map_validation_error(),不得就地另寫一份判斷邏輯。settings.enabled_content_packs(例:srd5.1 缺失)→ 中止並印訊息。這通常代表 <exe_dir>/data 沒放好。resolve_database_url() 非 SQLite → _require_sqlite() 中止,訊息含實際 URL 與該檢查的環境變數名(見 8.2)。M03 期間新增/可能新增的 migration 順序:
0001 P0-A baseline
0002 P0-C character core
0003 P1-A builder drafts
0004 P1-F character creation
0005 P1-G character versions
0006 character archive
(M01-K 與後續 M01 Subphase 可能新增:feats / spells / 其他)
<next revision> M03-B builder_provenance on character_versions
<next revision> M03-C character_import_records(含「至多一欄非 NULL」CheckConstraint)
以下不是 M03 scope,但 M03 建立的守門條件會影響 P2:
characters 表禁止 room / campaign / session / seat 欄位。party_rosters 表以 join table 綁 Campaign。app.main,不掛在 app.standalone。create_meta_router("web") 內把 room / campaign / session 等 capability 覆寫為 True。app.standalone 不 import app.main 是永久契約。M03 完成之後任何試圖跨這條線的 PR 都會撞上 CI 失敗;這是 M03 的政治產出。
M03-A 開工建議次序:
apps/server/app/paths.py 與對應 test(不動 registry)。Settings.enabled_content_packs,content/__init__.py 的 monkey-patch 移除。parents[N] 硬編位置一起改;加靜態 test 禁止舊常數與 legacy monkey-patch 重新出現。resolve_database_url() 落地,get_database_engine 與 alembic/env.py 全部改走它。Settings 用 AliasChoices,不啟用 env_prefix,enabled_content_packs 加 field_validator。M03-B 開工建議:
VersionKind strict enum)。content_ref_walker(build + state)。character_versions.builder_provenance migration + Confirm 寫入。versions.py:344 附近 draft seed 路徑,落地 versioned draft seeding SSOT。character_export.build_export_payload。測試指南.md。以下 subphase 類推,各自完成後 commit;不與其他 subphase 併 commit。