← Files QuestforgeARCHIVED FILE

scripts/visual_reuse.py

11.2 KB · Sep 30, 2026 · 23:13 UTC

↓ Download file

"""Build live scene-frame prompts from reusable Questforge visual anchors."""

from __future__ import annotations

import argparse
import json
import sys
from dataclasses import asdict, dataclass
from pathlib import Path
from typing import Iterable

try:
    from .campaign_memory import (
        VisualIndexEntry,
        get_campaign_paths,
        list_visual_assets,
        save_visual_prompt,
        slugify,
    )
except ImportError:  # pragma: no cover - direct script execution path
    from campaign_memory import (
        VisualIndexEntry,
        get_campaign_paths,
        list_visual_assets,
        save_visual_prompt,
        slugify,
    )


DEFAULT_ANCHOR_STATUS = ["canon"]
DEFAULT_STYLE = (
    "immersive fantasy realism, grounded materials, clear silhouettes, "
    "table-readable staging, dramatic plausible light, no unwanted film grain, "
    "no readable text"
)


@dataclass(frozen=True)
class AnchorSummary:
    """A compact canon anchor summary for scene prompt construction."""

    kind: str
    label: str
    status: str
    asset_path: str
    prompt_path: str
    prompt_excerpt: str


@dataclass(frozen=True)
class SceneFramePromptResult:
    """Artifacts created for a live scene frame."""

    campaign_root: str
    prompt_path: str
    anchor_count: int
    anchors: list[AnchorSummary]


def create_scene_frame_prompt(
    campaign_root: Path,
    session_number: int,
    scene_number: int,
    label: str,
    action: str,
    purpose: str = "",
    context: str = "",
    roll_summary: str = "",
    outcome: str = "",
    style: str = DEFAULT_STYLE,
    composition: str = "",
    anchor_statuses: list[str] | None = None,
    anchor_kinds: list[str] | None = None,
    anchor_labels: list[str] | None = None,
    require_asset: bool = False,
) -> SceneFramePromptResult:
    """Save a scene-frame prompt that explicitly reuses visual anchors."""

    paths = get_campaign_paths(campaign_root)
    if not paths.root.exists():
        raise FileNotFoundError(f"Missing campaign root: {campaign_root}")
    if not action.strip():
        raise ValueError("Scene action cannot be empty.")

    anchors = select_anchors(
        paths.root,
        statuses=anchor_statuses or DEFAULT_ANCHOR_STATUS,
        kinds=anchor_kinds,
        labels=anchor_labels,
        require_asset=require_asset,
    )
    if not anchors:
        raise ValueError(
            "No reusable visual anchors matched. Mark references as canon or "
            "pass a different --anchor-status."
        )

    summaries = [summarize_anchor(paths.root, anchor) for anchor in anchors]
    prompt = build_scene_prompt(
        action=action,
        purpose=purpose,
        context=context,
        roll_summary=roll_summary,
        outcome=outcome,
        style=style,
        composition=composition,
        anchors=summaries,
        visual_ledger=read_visual_ledger_excerpt(paths.root),
    )
    prompt_path = save_visual_prompt(
        paths.root,
        session_number=session_number,
        scene_number=scene_number,
        kind="scene",
        label=label,
        prompt=prompt,
    )
    return SceneFramePromptResult(
        campaign_root=str(paths.root),
        prompt_path=str(prompt_path),
        anchor_count=len(summaries),
        anchors=summaries,
    )


def select_anchors(
    campaign_root: Path,
    statuses: list[str],
    kinds: list[str] | None = None,
    labels: list[str] | None = None,
    require_asset: bool = False,
) -> list[VisualIndexEntry]:
    """Select reusable anchors, preserving visual-index order."""

    entries = list_visual_assets(
        campaign_root,
        statuses=statuses,
        kinds=kinds,
        require_asset=require_asset,
    )
    if not labels:
        return entries

    labels_by_slug = {slugify(label): label for label in labels}
    selected = [
        entry for entry in entries if slugify(entry.label) in labels_by_slug
    ]
    found_slugs = {slugify(entry.label) for entry in selected}
    missing_labels = [
        label
        for slug, label in labels_by_slug.items()
        if slug not in found_slugs
    ]
    if missing_labels:
        raise ValueError(
            "Missing requested reusable visual anchors: "
            + ", ".join(missing_labels)
        )
    return selected


def summarize_anchor(
    campaign_root: Path,
    entry: VisualIndexEntry,
) -> AnchorSummary:
    return AnchorSummary(
        kind=entry.kind,
        label=entry.label,
        status=entry.status,
        asset_path=entry.asset_path,
        prompt_path=entry.prompt_path,
        prompt_excerpt=read_prompt_excerpt(campaign_root, entry.prompt_path),
    )


def read_prompt_excerpt(
    campaign_root: Path,
    prompt_path: str,
    max_characters: int = 520,
) -> str:
    path = campaign_root / prompt_path
    if not path.exists():
        return ""
    text = path.read_text(encoding="utf-8").strip()
    if text.startswith("---"):
        parts = text.split("---", 2)
        if len(parts) == 3:
            text = parts[2]
    normalized = " ".join(
        line.strip() for line in text.splitlines() if line.strip()
    )
    if len(normalized) <= max_characters:
        return normalized
    return normalized[: max_characters - 1].rsplit(" ", 1)[0] + "..."


def build_scene_prompt(
    action: str,
    anchors: list[AnchorSummary],
    purpose: str = "",
    context: str = "",
    roll_summary: str = "",
    outcome: str = "",
    style: str = DEFAULT_STYLE,
    composition: str = "",
    visual_ledger: str = "",
) -> str:
    anchor_lines = "\n".join(format_anchor(anchor) for anchor in anchors)
    purpose_text = purpose or "show the current player-facing moment clearly"
    context_text = context or "current campaign scene"
    composition_text = (
        composition
        or "foreground decision point, readable character scale, clear threat "
        "or object placement, background kept secondary"
    )
    resolved_lines = build_resolved_table_lines(roll_summary, outcome)
    ledger_text = (
        f"\nActive visual ledger states:\n{visual_ledger}\n"
        if visual_ledger.strip()
        else ""
    )
    return f"""Original 5E-compatible fantasy campaign scene frame, unofficial and not using official D&D settings, logos, product art, named copyrighted characters, or commercial adventure identity.

Purpose: {sentence_text(purpose_text)}
Context: {sentence_text(context_text)}
Action: {sentence_text(action)}
{resolved_lines}
Style: {sentence_text(style)}
Composition: {sentence_text(composition_text)}

Canon anchors to preserve:
{anchor_lines}
{ledger_text}

Continuity constraints: reuse the listed anchors instead of redesigning them; preserve scale, silhouette, palette, visible gear, markings, and landmark relationships. If an anchor has an asset path, use that file as the visual reference when native image generation supports image inputs. Do not reveal unexplored map areas, hidden enemies, or secret mechanisms unless already discovered in play.
Avoid: official logos, official setting identifiers, copied product art, readable text, watermarks, and continuity-breaking redesigns.
Post-generation review: compare the result against the listed anchors before marking the new scene as canon; mark it as variant if useful but drifted, or rejected if it breaks continuity.
"""


def read_visual_ledger_excerpt(
    campaign_root: Path,
    max_characters: int = 700,
) -> str:
    ledger_path = campaign_root / "images" / "visual-ledger.md"
    if not ledger_path.exists():
        return ""
    lines = []
    for line in ledger_path.read_text(encoding="utf-8").splitlines():
        stripped_line = line.strip()
        if not stripped_line.startswith("|"):
            continue
        if (
            stripped_line.startswith("| ---")
            or "Must Preserve" in stripped_line
        ):
            continue
        if (
            "Current Visual State" in stripped_line
            or "Stable Layout" in stripped_line
        ):
            continue
        if "Format | Use For" in stripped_line:
            continue
        lines.append(stripped_line)
    text = " ".join(lines)
    if len(text) <= max_characters:
        return text
    return text[: max_characters - 1].rsplit(" ", 1)[0] + "..."


def build_resolved_table_lines(roll_summary: str, outcome: str) -> str:
    lines = []
    if roll_summary.strip():
        lines.append(f"Roll: {sentence_text(roll_summary)}")
    if outcome.strip():
        lines.append(f"Resolved outcome: {sentence_text(outcome)}")
    if not lines:
        return ""
    return "\n".join(lines)


def sentence_text(value: str) -> str:
    stripped_value = value.strip()
    if stripped_value.endswith((".", "!", "?")):
        return stripped_value
    return stripped_value + "."


def format_anchor(anchor: AnchorSummary) -> str:
    asset_text = anchor.asset_path or "no selected asset yet"
    excerpt_text = (
        f" Prior prompt summary: {anchor.prompt_excerpt}"
        if anchor.prompt_excerpt
        else ""
    )
    return (
        f"- Preserve {anchor.label} ({anchor.kind}, {anchor.status}); "
        f"asset: {asset_text}; prompt: {anchor.prompt_path}."
        f"{excerpt_text}"
    )


def build_parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(
        description=(
            "Create a Questforge scene-frame prompt from reusable visual "
            "anchors."
        )
    )
    parser.add_argument("--campaign-root", required=True, type=Path)
    parser.add_argument("--session", required=True, type=int)
    parser.add_argument("--scene", required=True, type=int)
    parser.add_argument("--label", required=True)
    parser.add_argument("--action", required=True)
    parser.add_argument("--purpose", default="")
    parser.add_argument("--context", default="")
    parser.add_argument("--roll", dest="roll_summary", default="")
    parser.add_argument("--outcome", default="")
    parser.add_argument("--style", default=DEFAULT_STYLE)
    parser.add_argument("--composition", default="")
    parser.add_argument("--anchor-status", action="append")
    parser.add_argument("--anchor-kind", action="append")
    parser.add_argument("--anchor-label", action="append")
    parser.add_argument("--require-asset", action="store_true")
    return parser


def main(arguments: Iterable[str] | None = None) -> int:
    if hasattr(sys.stdout, "reconfigure"):
        sys.stdout.reconfigure(encoding="utf-8")
    parsed_arguments = build_parser().parse_args(arguments)
    result = create_scene_frame_prompt(
        campaign_root=parsed_arguments.campaign_root,
        session_number=parsed_arguments.session,
        scene_number=parsed_arguments.scene,
        label=parsed_arguments.label,
        action=parsed_arguments.action,
        purpose=parsed_arguments.purpose,
        context=parsed_arguments.context,
        roll_summary=parsed_arguments.roll_summary,
        outcome=parsed_arguments.outcome,
        style=parsed_arguments.style,
        composition=parsed_arguments.composition,
        anchor_statuses=parsed_arguments.anchor_status,
        anchor_kinds=parsed_arguments.anchor_kind,
        anchor_labels=parsed_arguments.anchor_label,
        require_asset=parsed_arguments.require_asset,
    )
    print(json.dumps(asdict(result), indent=2, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

SHA-256: 2ee0cf8ede011549ab20515bd6cf5ccc86a81fdf9019b34712247b452b534797