← Files QuestforgeARCHIVED FILE
scripts/visual_planner.py
8.82 KB · Sep 30, 2026 · 23:13 UTC
"""Plan Questforge visual beats before native image generation."""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import asdict, dataclass
from typing import Iterable
try:
from .comic_panels import classify_visual_format
except ImportError: # pragma: no cover - direct script execution path
from comic_panels import classify_visual_format
NO_VISUAL_PATTERNS = [
"rules question",
"quick clarification",
"duda breve",
"regla",
"rules",
"dc",
"difficulty class",
"modificador",
"modifier",
"advantage",
"disadvantage",
"ventaja",
"desventaja",
]
PANORAMA_PATTERNS = [
"360",
"fotoesfera",
"photosphere",
"panorama",
"panoramico",
"pov",
"look around",
"mirar alrededor",
"ver la sala",
"ver el cuarto",
"desde mis ojos",
]
MAP_PATTERNS = [
"map",
"mapa",
"route",
"ruta",
"exits",
"salidas",
"where are",
"donde estan",
"distancia",
"positions",
"posiciones",
"fog of war",
]
INVENTORY_PATTERNS = [
"inventory",
"inventario",
"gear",
"equipo",
"mochila",
"loot",
"botin",
"monedas",
]
MERCHANT_PATTERNS = [
"merchant",
"comerciante",
"comprar",
"vender",
"tienda",
"prices",
"precios",
"bargain",
"regatear",
]
REFERENCE_PATTERNS = [
"reference",
"referencia",
"canon",
"canonical",
"ficha",
"sheet",
"front view",
"back view",
"recurring",
"recurrente",
]
@dataclass(frozen=True)
class VisualBeatPlan:
"""Decision for one possible visual beat."""
should_generate: bool
format: str
kind: str
panel_count: int
use_gallery: bool
present_image_in_chat: bool
reason: str
continuity_requirements: list[str]
next_steps: list[str]
def plan_visual_beat(
beat: str,
visual_first: bool = True,
) -> VisualBeatPlan:
"""Choose the visual surface and prompt workflow for a table beat."""
normalized_beat = " ".join(beat.strip().split())
if not normalized_beat:
raise ValueError("Beat summary cannot be empty.")
text = normalize_text(normalized_beat)
if is_non_visual_question(text):
return VisualBeatPlan(
should_generate=False,
format="none",
kind="none",
panel_count=0,
use_gallery=False,
present_image_in_chat=False,
reason="brief rules or clarification beat",
continuity_requirements=[],
next_steps=["Answer in chat without generating a visual."],
)
if contains_any(text, PANORAMA_PATTERNS):
return build_plan(
format_name="pov_360",
kind="pov-360",
panel_count=0,
reason="the beat asks for first-person spatial inspection",
visual_first=visual_first,
extra_continuity=[
"Use an equirectangular 360 panorama prompt.",
"Create or update a panorama viewer after generation.",
],
)
if contains_any(text, MAP_PATTERNS):
return build_plan(
format_name="map_or_diagram",
kind="map",
panel_count=0,
reason="the beat needs route, position, or spatial clarity",
visual_first=visual_first,
extra_continuity=[
"Apply fog of war; reveal only explored or known areas.",
],
)
if contains_any(text, INVENTORY_PATTERNS):
return build_plan(
format_name="inventory_board",
kind="inventory",
panel_count=0,
reason="the player needs a compact view of carried state",
visual_first=visual_first,
extra_continuity=[
"Preserve recurring item appearance and current quantities.",
],
)
if contains_any(text, MERCHANT_PATTERNS):
return build_plan(
format_name="merchant_board",
kind="merchant",
panel_count=0,
reason="the beat involves buying, selling, or comparing goods",
visual_first=visual_first,
extra_continuity=[
"Show only known prices and visible item qualities.",
],
)
if contains_any(text, REFERENCE_PATTERNS):
return build_plan(
format_name="reference_plate",
kind="character",
panel_count=0,
reason="the beat needs a reusable canon visual anchor",
visual_first=visual_first,
extra_continuity=[
"Capture scale, silhouette, outfit, marks, and normal poses.",
],
)
comic_plan = classify_visual_format(normalized_beat)
if comic_plan.panel_count in {2, 4, 6}:
return build_plan(
format_name="comic_page",
kind="comic-page",
panel_count=comic_plan.panel_count,
reason=comic_plan.reason,
visual_first=visual_first,
extra_continuity=[
"Use ordered panels with clear gutters and no merged timeline.",
],
)
return build_plan(
format_name="single_scene",
kind="scene",
panel_count=1,
reason="one place, one moment, one main action",
visual_first=visual_first,
extra_continuity=[],
)
def build_plan(
format_name: str,
kind: str,
panel_count: int,
reason: str,
visual_first: bool,
extra_continuity: list[str],
) -> VisualBeatPlan:
continuity_requirements = [
"Read images/visual-ledger.md for active visible states.",
"List and reuse relevant canon anchors from images/visual-index.md.",
"Preserve player-visible roll outcome, damage, gear, and object scale.",
] + extra_continuity
next_steps = [
"Choose or build the format-specific native image prompt.",
"Save the prompt with campaign_memory.py save-visual-prompt.",
"Generate the image with native Codex/ChatGPT image generation only.",
"Register the selected asset with campaign_memory.py register-visual-asset.",
]
present_image_in_chat = kind != "pov-360"
use_gallery = visual_first or kind == "pov-360"
if present_image_in_chat:
next_steps.append(
"Embed the selected static image once in chat with absolute-path Markdown."
)
if use_gallery:
next_steps.append(
"Refresh visual-gallery.html for history and #latest."
)
if kind == "pov-360":
next_steps.insert(
4,
"Create or refresh the local panorama viewer with panorama_viewer.py.",
)
if kind == "comic-page":
next_steps.insert(
1,
"Use comic_panels.py prompt or the comic-page template.",
)
return VisualBeatPlan(
should_generate=True,
format=format_name,
kind=kind,
panel_count=panel_count,
use_gallery=use_gallery,
present_image_in_chat=present_image_in_chat,
reason=reason,
continuity_requirements=continuity_requirements,
next_steps=next_steps,
)
def is_non_visual_question(text: str) -> bool:
if contains_any(text, INVENTORY_PATTERNS):
return False
if contains_any(text, PANORAMA_PATTERNS + MAP_PATTERNS):
return False
return "?" in text and contains_any(text, NO_VISUAL_PATTERNS)
def contains_any(text: str, patterns: list[str]) -> bool:
return any(pattern in text for pattern in patterns)
def normalize_text(value: str) -> str:
replacements = {
"\u00e1": "a",
"\u00e9": "e",
"\u00ed": "i",
"\u00f3": "o",
"\u00fa": "u",
"\u00fc": "u",
"\u00f1": "n",
"\u00bf": "",
"\u00a1": "",
}
normalized = value.casefold()
for source, replacement in replacements.items():
normalized = normalized.replace(source, replacement)
return normalized
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
description="Plan the visual format for a Questforge table beat."
)
parser.add_argument("--beat", required=True)
parser.add_argument(
"--chat-images",
action="store_true",
help=(
"Plan static images primarily for chat. 360 assets still use a "
"local viewer."
),
)
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 = plan_visual_beat(
beat=parsed_arguments.beat,
visual_first=not parsed_arguments.chat_images,
)
print(json.dumps(asdict(result), indent=2, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 558b58b1a67473899037c87cce4d022c45a04116e18cc40d53edae21c02e321e