adventure-table

M03 — 開發設計方針

Phase:M03 — Standalone Character Builder Distribution 本文件定義 M03-A~M03-G 的具體實作契約:模組、API、資料模型、資料流、接線、必要技術決策。驗收意圖見 實作規格.md;測試證據見 測試指南.md

最後更新:2026-09-04


1. 設計目標


2. Module / package layout

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,避免撞號。


3. Path 解析(M03-A 主題)

3.1 新增 apps/server/app/paths.py

from 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.pymain() 最前面設一個 module-level flag(或設 ADVENTURE_TABLE_DATABASE_PATH 本身即可,見 8.4);實作者擇一,但必須有明確機制讓「非 frozen 的 launcher dev 執行」拿到 SQLite 而不是 Postgres。

這條 fallback 順序是硬契約resolve_database_url() 只在 resolve_database_path()None 時才回 settings.database_urlsettings.database_url 預設是 PostgreSQL,是 web entry 的正確值;若 standalone 走到它,症狀不是「連不到 DB」——E.7 的 excludes 明列 psycopg,實際會在 alembic upgrade 階段噴 driver 找不到的堆疊,難以對應到根因。因此 8.2 另設啟動硬守衛。

實作者於 M03-A 開工時以檔案實際位置驗證 parents[3] 深度,若 apps 結構調整則同步修正。

3.2 呼叫點更新

3.3 Startup failure handling

3.4 Settings 契約

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 需要 NoDecodefield_validator 兩件事一起才成立:


4. JSON schema(M03-B 主題)

4.1 Pydantic model

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 補齊,避免遺漏。

4.2 content_requirements 計算

4.3 stable_key_refs_summary

4.4 匯出檔命名

4.5 Endpoint

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

4.6 builder_provenance 欄位與 versioned draft seeding SSOT

Alembic migration(<next>_m03b_builder_provenance.py):

Confirm 路徑寫入:

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。


5. Import pipeline(M03-C 主題)

5.1 API contract 與 raw body 路徑

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

5.2 Rejection codes

以 M02-G / M02-H 建立的 machine-readable + params 契約為模板:

前端把 code + params 翻成 zh-TW / en presentation,比照 M02-H builder issue 契約。

5.2.1 ValidationError → code 映射(必要 special case)

schema_status: Literal["unstable", "locked"]version_kind: VersionKind 都在 CharacterExport.model_validate() 階段失敗,不會抵達 preview_import() 的任何語意步驟。因此 unsupported_schema_statusinvalid_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"

5.3 Content ref walker

app/interop/content_ref_walker.py

5.4 Preview

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

5.5 Commit — 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 狀態變動)。

5.6 Commit — landing_mode == "draft" / "draft_with_history_loss"

5.7 Import records table

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

5.8 Duplicate hint

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),
    )

6. Frontend integration(M03-B / M03-C / M03-E)

6.1 Export button

6.2 Import dialog

6.3 Capability contract

6.4 Localization


7. Alembic on SQLite(M03-D 主題)

7.1 現況掃描

M03-D 開工第一件事:把現有全部 migration(0001 至當時 head,含 M03-B、M03-C 新增者)逐一在 SQLite 上跑一次 upgrade → downgrade → upgrade,抓出:

7.2 修法

7.3 SQLite FK PRAGMA event listener

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()

7.4 CI gate

7.5 psycopg extras

apps/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",
]

8. Standalone entry / launcher(M03-E 主題)

8.0 實作順序:先過最小 frozen smoke

本節所有內容都在 PyInstaller 的行為之下才成立,而 _MEIPASS 下的 alembic ScriptDirectoryuvicorn.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 的失敗在只有兩三個變因時就爆開,而不是在十項功能疊完之後一次爆。

8.1 Neutral shared modules

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.pyfactory 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 不會互相污染。

8.2 app/standalone.py

from 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()

關鍵

8.3 app/main.py

8.4 app/launcher.py

from 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())

關鍵

8.5 PyInstaller spec 要點

apps/server/pyinstaller/standalone.spec

8.6 README-standalone.txt

固定內容大綱:


9. Build 腳本

9.1 scripts/build-standalone.cmd

1. 建立 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。

10. CI workflow(M03-F 主題)

10.1 .github/workflows/m03-standalone.yml

name: 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

關鍵

10.2 Smoke test

scripts/smoke_standalone.py

10.3 Import boundary test

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"

10.4 Standalone composition test


11. Env vars & config

M03 期間新增的環境變數(皆走 AliasChoices):

保留DATABASE_URL(不加 ADVENTURE_TABLE_ 前綴),維持 Docker Compose 現行部署契約。

Launcher 於啟動時計算絕對路徑後寫入對應環境變數,再起 uvicorn。


12. Localization contract


13. 錯誤處理與觀察

13.1 Import endpoint

13.2 Launcher

13.3 Server startup(standalone)


14. Migration timeline

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)

15. 未來 P2 依賴

以下不是 M03 scope,但 M03 建立的守門條件會影響 P2:

M03 完成之後任何試圖跨這條線的 PR 都會撞上 CI 失敗;這是 M03 的政治產出。


16. Handoff 給 M03 coder

M03-A 開工建議次序:

  1. apps/server/app/paths.py 與對應 test(不動 registry)。
  2. Registry / rules / api.dependencies / alembic.env.py 呼叫改為呼叫 paths 函式;同時把 enabled pack 常數改為 Settings.enabled_content_packscontent/__init__.py 的 monkey-patch 移除。
  3. Repo-wide sweep:找出其他 parents[N] 硬編位置一起改;加靜態 test 禁止舊常數與 legacy monkey-patch 重新出現。
  4. resolve_database_url() 落地,get_database_enginealembic/env.py 全部改走它。
  5. SettingsAliasChoices,不啟用 env_prefixenabled_content_packsfield_validator
  6. 跑既有 pytest / Playwright 全綠;Docker Compose 啟動不需要新環境變數。
  7. 提交 M03-A。

M03-B 開工建議:

  1. 定義 pydantic model(含 VersionKind strict enum)。
  2. 實作 content_ref_walker(build + state)。
  3. 實作 character_versions.builder_provenance migration + Confirm 寫入。
  4. 修改 versions.py:344 附近 draft seed 路徑,落地 versioned draft seeding SSOT。
  5. 實作 character_export.build_export_payload
  6. 加 endpoint;前端 export 按鈕。
  7. 測試證據依 測試指南.md
  8. 提交 M03-B。

以下 subphase 類推,各自完成後 commit;不與其他 subphase 併 commit。