magicciv/tools/sprite-generation/cli.py
Natalie 7eb2897854 feat(@projects/@magic-civilization): 🎨 Grok sprite-generation pipeline + starter orchestration
Adds a Grok image backend (grok_generator.py) behind a generator factory, a
starter-set orchestrator (starter.py, orchestrate_starter.py, starter_manifest.json)
and a Grok PoC harness with proof renders. Updates the ranker/processor/composition
prompts and sprite-config; refreshes the sprite-gallery design preview.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 17:57:01 -05:00

806 lines
32 KiB
Python

#!/usr/bin/env python3
"""Sprite generation pipeline CLI for Magic Civilization.
Usage:
python3 tools/sprite-generation/cli.py scan
python3 tools/sprite-generation/cli.py status
python3 tools/sprite-generation/cli.py generate --category terrain --variants 8
python3 tools/sprite-generation/cli.py poll --watch
python3 tools/sprite-generation/cli.py review --port 5801
python3 tools/sprite-generation/cli.py install
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
TOOL_DIR = Path(__file__).resolve().parent
PROJECT = TOOL_DIR.parent.parent
sys.path.insert(0, str(TOOL_DIR))
CONFIG_PATH = TOOL_DIR / "sprite-config.json"
DB_PATH = TOOL_DIR / "spritegen.db"
RAW_DIR = TOOL_DIR / "raw"
VARIANTS_DIR = TOOL_DIR / "variants"
HEX_MASK_PATH = TOOL_DIR / "hex_mask.png"
LOCAL_DATA = PROJECT / "public" / "games" / "age-of-dwarves" / "data"
DEMO_DATA = TOOL_DIR / "demo-data"
ASSETS_DIR = PROJECT / "public" / "games" / "age-of-dwarves" / "assets"
def _data_dir(args: argparse.Namespace) -> Path:
if hasattr(args, "data_dir") and args.data_dir:
return Path(args.data_dir)
if hasattr(args, "demo") and args.demo:
return DEMO_DATA
return LOCAL_DATA
def _load_config(backend: str | None = None) -> dict:
from engine.factory import with_backend
config = json.loads(CONFIG_PATH.read_text())
if backend:
return with_backend(config, backend)
return config
def _create_generator(args: argparse.Namespace):
from engine.factory import create_generator, backend_summary
config = _load_config(getattr(args, "backend", None))
reg = _registry()
gen = create_generator(config=config, registry=reg, raw_dir=RAW_DIR)
print(f"Generation backend: {backend_summary(config)}")
return gen, config, reg
def _add_backend_arg(parser: argparse.ArgumentParser) -> None:
from engine.factory import BACKENDS
parser.add_argument(
"--backend",
choices=BACKENDS,
help="Override sprite-config.json backend (default: model-boss)",
)
def _registry():
from engine.registry import SpriteRegistry
return SpriteRegistry(DB_PATH)
def cmd_scan(args: argparse.Namespace) -> None:
from engine.scanner import SpriteScanner
reg = _registry()
data = _data_dir(args)
print(f"Scanning data from: {data}")
demo = hasattr(args, "demo") and args.demo
sprite_type = getattr(args, "sprite_type", None)
scanner = SpriteScanner(data_dir=data, assets_dir=ASSETS_DIR, registry=reg)
if demo:
report = scanner.scan_all(skip_biome_grid=True, skip_ui=True, sprite_type=sprite_type)
else:
report = scanner.scan_all(sprite_type=sprite_type)
print(f"\nBy category:")
for cat, count in sorted(report.categories.items()):
print(f" {cat:20s} {count:4d} sprites")
print(f"\n {'TOTAL':20s} {report.new_sprites:4d} new, {report.existing_sprites} existing")
print(f" {'DIMENSIONS':20s} {report.new_dimensions:4d} new, {report.existing_dimensions} existing")
def cmd_status(args: argparse.Namespace) -> None:
reg = _registry()
stats = reg.get_stats()
if not stats["by_category"]:
print("No sprites in registry. Run 'scan' first.")
return
print(f"\n{'Category':<20s} {'needed':>8s} {'generating':>12s} {'review':>8s} {'approved':>10s} {'installed':>10s} {'rejected':>10s} {'skip':>6s} {'TOTAL':>8s}")
print("" * 102)
for cat in sorted(stats["by_category"]):
row = stats["by_category"][cat]
total = sum(row.values())
print(
f"{cat:<20s} {row.get('needed', 0):>8d} {row.get('generating', 0):>12d} "
f"{row.get('review', 0):>8d} {row.get('approved', 0):>10d} {row.get('installed', 0):>10d} "
f"{row.get('rejected', 0):>10d} {row.get('skip', 0):>6d} {total:>8d}"
)
total_row = stats["total"]
grand = sum(total_row.values())
print("" * 102)
print(
f"{'TOTAL':<20s} {total_row.get('needed', 0):>8d} {total_row.get('generating', 0):>12d} "
f"{total_row.get('review', 0):>8d} {total_row.get('approved', 0):>10d} {total_row.get('installed', 0):>10d} "
f"{total_row.get('rejected', 0):>10d} {total_row.get('skip', 0):>6d} {grand:>8d}"
)
def cmd_generate(args: argparse.Namespace) -> None:
"""Submit generation requests. Returns immediately for model-boss; Grok completes inline."""
import asyncio
gen, _config, reg = _create_generator(args)
sprites = reg.get_sprites(
category=args.category,
status="needed",
limit=args.max or 10000,
)
if args.sprite:
sprites = [s for s in sprites if s["id"] == args.sprite]
if not sprites:
print("No sprites in 'needed' status matching filters.")
return
sprite_ids = [s["id"] for s in sprites]
if args.dry_run:
print(f"Would generate {len(sprite_ids)} sprites x {args.variants} variants = {len(sprite_ids) * args.variants} jobs\n")
for sid in sprite_ids[:50]:
print(f" {sid}")
if len(sprite_ids) > 50:
print(f" ... and {len(sprite_ids) - 50} more")
return
submitted = asyncio.run(gen.submit_batch(
sprite_ids=sprite_ids,
variants_per=args.variants,
priority=args.priority,
))
backend = _config["backend"]
if backend == "model-boss":
print(f"\nSubmitted {submitted} requests. Run 'listen' to collect results.")
else:
print(f"\nGenerated {submitted} variants via {backend}. Run 'rank' to score them.")
def cmd_listen(args: argparse.Namespace) -> None:
"""Collect pending generation results (model-boss / Redis pubsub only)."""
import asyncio
gen, config, _reg = _create_generator(args)
if config["backend"] != "model-boss":
print(f"listen is a model-boss step — {config['backend']} completes at generate time.")
return
collected = asyncio.run(gen.collect_pending())
print(f"\nCollected {collected} images. Run 'rank' to score them.")
def cmd_rank(args: argparse.Namespace) -> None:
import asyncio
from engine.ranker import SpriteRanker
reg = _registry()
ranker = SpriteRanker(registry=reg, raw_dir=RAW_DIR)
if args.sprite:
result = asyncio.run(ranker.rank_and_filter(args.sprite))
print(f"\n{args.sprite}: {result['good_count']}/{len(result['ranked'])} good variants")
for r in result["ranked"]:
scores = r["scores"]
flag = "" if r["confidence"] >= 0.7 else ""
dims = " ".join(f"{k}={v:.2f}" for k, v in scores.items())
print(f" {flag} variant {r['variant_id']} (seed {r['seed']}): confidence={r['confidence']:.2f} {dims}")
if result["needs_regen"]:
print(f"\n Needs {result['deficit']} more good variants — re-generate with higher priority")
else:
print(f"Ranking all sprites in 'review' status...")
reg = _registry()
# Always filter to units unless explicitly overridden
category_filter = args.category if hasattr(args, "category") and args.category else "units"
all_sprites = reg.get_sprites(category=category_filter)
total_ready = 0
total_need_regen = 0
total_sprites = 0
for sprite in all_sprites:
if sprite["status"] == "review":
total_sprites += 1
result = asyncio.run(ranker.rank_and_filter(sprite["id"]))
if result["good_count"] >= ranker.target_approved:
total_ready += 1
else:
total_need_regen += 1
print(f"\nSummary: {total_ready} ready, {total_need_regen} need re-generation (of {total_sprites} total)")
def cmd_install(args: argparse.Namespace) -> None:
from engine.installer import SpriteInstaller
reg = _registry()
installer = SpriteInstaller(assets_dir=ASSETS_DIR, registry=reg)
count = installer.install_approved(category=args.category, dry_run=args.dry_run)
print(f"\n{'Would install' if args.dry_run else 'Installed'}: {count} sprites")
def cmd_approve(args: argparse.Namespace) -> None:
from engine.pipeline import SpritePipeline
reg = _registry()
pipeline = SpritePipeline(
registry=reg,
raw_dir=RAW_DIR,
variants_dir=VARIANTS_DIR,
assets_dir=ASSETS_DIR,
game_db_path=LOCAL_DATA / "sprites.db",
)
result = pipeline.approve_and_install(args.variant, alt_name=args.alt)
if result:
print(f"\nShipped: {result}")
else:
print("\nFailed to install.")
def cmd_reset(args: argparse.Namespace) -> None:
reg = _registry()
if not args.sprite:
print("--sprite is required for reset")
return
reg.update_sprite_status(args.sprite, "needed")
print(f"Reset {args.sprite} to 'needed'")
def cmd_review(args: argparse.Namespace) -> None:
print(f"Starting review GUI server on port {args.port}...")
print(f"Open http://localhost:{args.port} in your browser")
try:
from server import create_app
import uvicorn
app = create_app(
registry=_registry(),
raw_dir=RAW_DIR,
variants_dir=VARIANTS_DIR,
)
uvicorn.run(app, host="0.0.0.0", port=args.port)
except ImportError as e:
print(f"Error: {e}")
print("Install dependencies: pip install fastapi uvicorn")
def cmd_run(args: argparse.Namespace) -> None:
"""Full pipeline: submit → collect → rank → regen loop.
model-boss: submit → Redis pubsub collect → score → regen
grok: submit (sync) → score → regen
"""
import asyncio
from engine.ranker import SpriteRanker
gen, config, reg = _create_generator(args)
ranker = SpriteRanker(registry=reg, raw_dir=RAW_DIR)
variants_per = args.variants
MAX_REGEN_ATTEMPTS = getattr(args, "max_attempts", 15)
regen_counts: dict[str, int] = {}
# Start GUI server in background thread
import threading
def _serve():
from server import create_app
import uvicorn
app = create_app(registry=reg, raw_dir=RAW_DIR, variants_dir=VARIANTS_DIR)
uvicorn.run(app, host="0.0.0.0", port=args.port, log_level="warning")
server_thread = threading.Thread(target=_serve, daemon=True)
server_thread.start()
print(f"Review GUI: http://localhost:{args.port}/?spriteTheater=true")
async def _on_variant_complete(variant_id: int, sprite_id: str) -> None:
"""Called when a variant result arrives. Advance through scoring pipeline."""
await ranker.advance_sprite(sprite_id)
async def _run_pipeline():
loop_count = 0
while True:
loop_count += 1
# --- Phase 1: Submit all needed sprites ---
needed = reg.get_sprites(
category=args.category,
status="needed",
limit=100,
)
# Find sprites that already have in-flight (submitted) variants — don't re-queue them
in_flight_ids: set[str] = set()
if needed:
placeholders = ",".join("?" * len(needed))
ids = [s["id"] for s in needed]
rows = reg.conn.execute(
f"SELECT DISTINCT sprite_id FROM variants WHERE sprite_id IN ({placeholders}) AND job_status='submitted'",
ids,
).fetchall()
in_flight_ids = {r[0] for r in rows}
to_submit = []
for candidate in (needed or []):
sid = candidate["id"]
if sid in in_flight_ids:
continue # already has pending variants — don't double-queue or burn regen_counts
if regen_counts.get(sid, 0) < MAX_REGEN_ATTEMPTS:
to_submit.append(sid)
regen_counts[sid] = regen_counts.get(sid, 0) + 1
else:
reg.update_sprite_status(sid, "review")
print(f" {sid}: max regen attempts, moving to review")
submitted_this_loop = 0
if to_submit:
print(f"\n[loop {loop_count}] Submitting {len(to_submit)} sprites x {variants_per} variants...")
submitted_this_loop = await gen.submit_batch(
sprite_ids=to_submit,
variants_per=variants_per,
priority="high",
on_complete=_on_variant_complete,
)
# --- Phase 2: Collect pending results (model-boss only) ---
collected = await gen.collect_pending(on_complete=_on_variant_complete)
# --- Phase 3: Evaluate sprites and decide regen ---
if collected > 0 or submitted_this_loop > 0:
# Check which sprites now have enough good variants
review_sprites = reg.get_sprites(
category=args.category,
status="review",
limit=1000,
)
for sprite in (review_sprites or []):
result = await ranker.rank_and_filter(sprite["id"])
good = result["good_count"]
total = len(result["ranked"])
if result["needs_regen"]:
attempt = regen_counts.get(sprite["id"], 0)
if attempt < MAX_REGEN_ATTEMPTS:
reg.update_sprite_status(sprite["id"], "needed")
print(f" {sprite['id']}: {good}/{total} good — needs regen")
else:
if result["ranked"]:
best = result["ranked"][0]
gate_ok = best.get("gate_passed", True)
status = "gates passed" if gate_ok else "gate failed"
print(f" {sprite['id']}: {good}/{total} good — {status}, best={best['confidence']:.0%}")
# --- Status ---
stats = reg.get_stats()
total_row = stats["total"]
needed_count = total_row.get("needed", 0)
review_count = total_row.get("review", 0)
done_count = total_row.get("approved", 0) + total_row.get("installed", 0)
total_count = sum(total_row.values())
queued = reg.conn.execute(
"SELECT COUNT(*) FROM variants WHERE job_status = 'submitted'"
).fetchone()[0]
print(f"\n[loop {loop_count}] {needed_count} needed | {queued} queued | {review_count} review | {done_count} done / {total_count} total")
if needed_count == 0 and queued == 0:
print("\nAll sprites processed. Ctrl+C to stop.")
try:
await asyncio.sleep(30)
except asyncio.CancelledError:
break
else:
await asyncio.sleep(2)
try:
asyncio.run(_run_pipeline())
except KeyboardInterrupt:
print("\nStopping pipeline.")
def cmd_test_prompt(args: argparse.Namespace) -> None:
"""Generate test images with current prompt formula — no DB writes."""
import asyncio
import base64
from pathlib import Path
from engine.prompts import compose_prompt, get_negative
config = _load_config(getattr(args, "backend", None))
backend = config["backend"]
entity = {
"name": args.entity.replace("_", " ").title(),
"description": "",
"combat_type": args.combat_type,
}
dims = {"race": args.race, "gender": args.gender}
prompt = compose_prompt("units", entity, dims)
negative = get_negative("units", combat_type=args.combat_type)
outdir = Path(args.outdir)
outdir.mkdir(exist_ok=True)
if backend == "grok":
from engine.grok_generator import GrokSpriteGenerator, _adapt_prompt_for_grok
from engine.registry import SpriteRegistry
grok_prompt = _adapt_prompt_for_grok(prompt, negative)
gen = GrokSpriteGenerator(config=config, registry=SpriteRegistry(DB_PATH), raw_dir=RAW_DIR)
print(f"Backend: grok-build ({config['grok']['model']} CLI)")
print(f"Adapted prompt: {grok_prompt[:200]}...")
async def _grok_variants() -> None:
for i, seed in enumerate(args.seeds):
image_bytes, duration_ms = await gen._generate_image(grok_prompt)
name = f"{args.entity}_{args.race}_{args.gender[0]}_grok_{i}_s{seed}.png"
path = outdir / name
path.write_bytes(image_bytes)
print(f" variant {i} (seed tag {seed}) → {path} ({duration_ms} ms)")
asyncio.run(_grok_variants())
else:
import httpx
model = config["model"]
guidance = config["defaults"].get("guidance_scale", 7.5)
steps = config["defaults"].get("steps", 25)
print(f"Backend: model-boss ({model}) Guidance: {guidance} Steps: {steps}")
print(f"Prompt: {prompt[:200]}...")
print(f"Negative: {negative[:100]}...")
print(f"Seeds: {args.seeds}")
async def generate_one(seed: int) -> None:
async with httpx.AsyncClient(timeout=120.0) as client:
body = {
"model": model, "prompt": prompt, "negative_prompt": negative,
"width": 1024, "height": 1024, "steps": steps,
"guidance_scale": guidance, "seed": seed,
}
resp = await client.post(f"{config['api_base']}/v1/images/generations", json=body)
if resp.status_code != 200:
print(f" seed={seed}: FAILED ({resp.status_code})")
return
data = resp.json()
img_b64 = None
if "data" in data and data["data"]:
img_b64 = data["data"][0].get("b64_json")
if not img_b64 and "images" in data:
img_b64 = data["images"][0]
if img_b64:
name = f"{args.entity}_{args.race}_{args.gender[0]}_s{seed}.png"
path = outdir / name
path.write_bytes(base64.b64decode(img_b64))
print(f" seed={seed}{path}")
for seed in args.seeds:
asyncio.run(generate_one(seed))
print(f"\nResults in {outdir}")
def cmd_refresh_prompts(args: argparse.Namespace) -> None:
"""Rebuild all stored prompts from current templates."""
from engine.prompts import compose_prompt, get_negative
reg = _registry()
sprites = reg.get_sprites(category=args.category, limit=100000)
updated = 0
for s in sprites:
eid = s["entity_id"]
sid = s["id"]
# Parse race/gender from entity_id
dims = {}
parts = eid
if parts.endswith("_m"):
dims["gender"] = "male"
parts = parts[:-2]
elif parts.endswith("_f"):
dims["gender"] = "female"
parts = parts[:-2]
for race in ["dwarves", "humans", "high_elves", "orcs"]:
suffix = f"_{race}"
if parts.endswith(suffix):
dims["race"] = race
parts = parts[:-len(suffix)]
break
# Get stored dimensions
dim_rows = reg.conn.execute(
"SELECT dimension_type, dimension_value FROM sprite_dimensions WHERE sprite_id=?",
(sid,),
).fetchall()
for r in dim_rows:
dims[r["dimension_type"]] = r["dimension_value"]
# Determine combat type from entity_id
base = parts.lower()
combat_type = "melee"
if any(w in base for w in ["archers", "bowmen", "crossbow", "longbow", "musket", "rifle"]):
combat_type = "ranged"
elif any(w in base for w in ["cavalry", "heavy_cavalry"]):
combat_type = "cavalry"
elif any(w in base for w in ["fishing", "trawler", "deep_sea", "marine"]):
combat_type = "marine"
elif any(w in base for w in ["founder", "wanderer", "engineer"]):
combat_type = "civilian"
elif any(w in base for w in ["magician", "field_medic", "field_commander", "tactician", "siege_master"]):
combat_type = "specialist"
entity = {
"name": parts.replace("_", " ").title(),
"description": "",
"combat_type": combat_type,
"entity_id": eid,
}
prompt = compose_prompt(args.category, entity, dims)
neg = get_negative(args.category, combat_type=combat_type)
reg.conn.execute("UPDATE sprites SET prompt=?, negative_prompt=? WHERE id=?", (prompt, neg, sid))
updated += 1
if args.clear_scores:
result = reg.conn.execute(
"UPDATE variants SET notes=NULL, rating=NULL "
"WHERE is_approved=0 AND rating != -1 AND sprite_id IN "
"(SELECT id FROM sprites WHERE category=?)",
(args.category,),
)
print(f"Cleared {result.rowcount} scores")
reg.conn.execute(
"UPDATE sprites SET status='needed' WHERE category=? AND status='review'",
(args.category,),
)
reg.conn.commit()
print(f"Updated {updated} {args.category} prompts")
def cmd_export(args: argparse.Namespace) -> None:
import csv
import io
reg = _registry()
sprites = reg.get_sprites(limit=100000)
if args.format == "json":
print(json.dumps(sprites, indent=2))
else:
if not sprites:
print("No sprites in registry.")
return
writer = csv.DictWriter(sys.stdout, fieldnames=sprites[0].keys())
writer.writeheader()
for s in sprites:
writer.writerow(s)
def cmd_starter(args: argparse.Namespace) -> None:
"""Delegate to orchestrate_starter.py for the curated EA sprite set."""
import orchestrate_starter as orch
if args.starter_cmd == "register":
orch.cmd_register(args)
elif args.starter_cmd == "status":
orch.cmd_status(args)
elif args.starter_cmd == "monitor":
orch.cmd_monitor(args)
elif args.starter_cmd == "run":
orch.cmd_run(args)
def cmd_monitor(args: argparse.Namespace) -> None:
"""Monitor tier progression — report when Opus has 3 candidates for user review."""
import sqlite3
import time
from datetime import datetime
conn = sqlite3.connect(str(DB_PATH))
conn.row_factory = sqlite3.Row
opus_ready = {}
check_count = 0
print("\n" + "="*80)
print("SPRITE PIPELINE MONITOR — Tier Progression Tracker")
print("="*80)
while True:
# Get tier status for all sprites
status = conn.execute("""
SELECT
sprite_id,
COUNT(*) as total_variants,
SUM(CASE WHEN COALESCE(review_tier, 0) >= 0 THEN 1 ELSE 0 END) as tier0_scored,
SUM(CASE WHEN COALESCE(review_tier, 0) >= 1 THEN 1 ELSE 0 END) as tier1_scored,
SUM(CASE WHEN COALESCE(review_tier, 0) >= 2 THEN 1 ELSE 0 END) as tier2_scored,
SUM(CASE WHEN COALESCE(review_tier, 0) >= 3 THEN 1 ELSE 0 END) as fully_approved
FROM variants
WHERE job_status='completed' AND raw_path IS NOT NULL
GROUP BY sprite_id
ORDER BY tier2_scored DESC, tier1_scored DESC, tier0_scored DESC
""").fetchall()
status = [dict(r) for r in status]
# Group by tier
in_vlm = [s for s in status if s['tier0_scored'] > 0 and s['tier1_scored'] == 0]
in_haiku = [s for s in status if s['tier1_scored'] > 0 and s['tier2_scored'] == 0]
in_opus = [s for s in status if s['tier2_scored'] > 0 and s['fully_approved'] == 0]
timestamp = datetime.now().strftime("%H:%M:%S")
check_count += 1
# Print summary
print(f"\n[{timestamp}] Check #{check_count}")
print(f" VLM (tier 0): {len(in_vlm):2d} sprites scoring")
print(f" Haiku (tier 1): {len(in_haiku):2d} sprites scoring")
print(f" Opus (tier 2): {len(in_opus):2d} sprites in review")
# Check Opus progress
if in_opus:
print(f"\n Opus candidates in progress:")
for s in in_opus:
print(f" {s['sprite_id']}: {s['tier2_scored']:2d}/{s['total_variants']} in tier 2")
# Check for 3-candidate ready sprites
new_ready = []
for s in in_opus:
candidates = conn.execute("""
SELECT id, seed, rating, notes, COALESCE(review_tier, 0) as review_tier
FROM variants
WHERE sprite_id = ?
AND job_status = 'completed'
AND raw_path IS NOT NULL
AND COALESCE(review_tier, 0) >= 2
AND COALESCE(rating, 0) > 0
ORDER BY COALESCE(rating, 0) DESC
LIMIT 3
""", (s['sprite_id'],)).fetchall()
candidates = [dict(c) for c in candidates]
if len(candidates) >= 3 and s['sprite_id'] not in opus_ready:
opus_ready[s['sprite_id']] = candidates
new_ready.append((s['sprite_id'], candidates))
# Report newly ready sprites
if new_ready:
print(f"\n{'='*80}")
print(f"🎯 OPUS READY! {len(new_ready)} sprite(s) have 3 candidates for user final review:")
print(f"{'='*80}")
for sprite_id, candidates in new_ready:
print(f"\n{sprite_id}:")
for i, cand in enumerate(candidates, 1):
print(f" {i}. variant #{cand['id']} (seed {cand['seed']}, rating {cand['rating']:.1f}/5)")
if len(opus_ready) > 0:
print(f"\n📊 Total ready for final review: {len(opus_ready)} sprite(s)")
sys.stdout.flush()
time.sleep(30) # Check every 30 seconds
def main() -> None:
parser = argparse.ArgumentParser(
description="Sprite generation pipeline for Magic Civilization",
prog="sprite-gen",
)
parser.add_argument("--data-dir", type=str, help="Override game data directory path")
parser.add_argument("--demo", action="store_true", help="Use minimal demo data (1 per domain)")
sub = parser.add_subparsers(dest="command")
# run — full pipeline orchestrator
p = sub.add_parser("run", help="Run full pipeline: generate → rank → regen loop + GUI")
_add_backend_arg(p)
p.add_argument("--port", type=int, default=5850, help="GUI server port (default: 5850)")
p.add_argument("--category", type=str, help="Only process one category")
p.add_argument("--variants", type=int, default=8, help="Variants per sprite per batch (default: 8)")
p.add_argument("--max-attempts", type=int, default=15, dest="max_attempts",
help="Max regen attempts per sprite (default: 15 = up to 120 total variants)")
p.set_defaults(func=cmd_run)
# start — launch GUI server only
p = sub.add_parser("start", help="Launch review GUI server")
p.add_argument("--port", type=int, default=5850, help="Server port (default: 5850)")
p.set_defaults(func=cmd_review)
# scan
p = sub.add_parser("scan", help="Scan game data, populate sprite registry")
p.add_argument("--sprite-type", dest="sprite_type", type=str,
choices=["terrain", "biome_grid", "units", "buildings", "resources", "improvements", "spells", "ui"],
help="Only scan this category (e.g. --sprite-type units)")
p.set_defaults(func=cmd_scan)
# status
p = sub.add_parser("status", help="Show sprite counts by category and status")
p.set_defaults(func=cmd_status)
# generate — submit to queue (returns immediately)
p = sub.add_parser("generate", help="Generate variants (model-boss queue or Grok API)")
_add_backend_arg(p)
p.add_argument("--category", type=str, help="Only generate one category")
p.add_argument("--sprite", type=str, help="Generate single sprite by ID")
p.add_argument("--variants", type=int, default=4, help="Variants per sprite (default: 4)")
p.add_argument("--priority", choices=["low", "normal", "high"], default="normal")
p.add_argument("--max", type=int, help="Max sprites to generate")
p.add_argument("--dry-run", action="store_true", help="Show what would be generated")
p.set_defaults(func=cmd_generate)
# listen — collect results via pubsub
p = sub.add_parser("listen", help="Collect pending model-boss results via Redis pubsub")
_add_backend_arg(p)
p.set_defaults(func=cmd_listen)
# rank
p = sub.add_parser("rank", help="AI-rank variants using Haiku vision")
p.add_argument("--category", type=str, help="Only rank one category")
p.add_argument("--sprite", type=str, help="Rank single sprite by ID")
p.set_defaults(func=cmd_rank)
# install
p = sub.add_parser("install", help="Copy approved sprites to game assets")
p.add_argument("--category", type=str, help="Only install one category")
p.add_argument("--dry-run", action="store_true")
p.set_defaults(func=cmd_install)
# approve
p = sub.add_parser("approve", help="Approve variant → process → install → manifest")
p.add_argument("variant", type=int, help="Variant ID to approve (e.g. 129)")
p.add_argument("--alt", type=str, help="Install as alternate (e.g. --alt front)")
p.set_defaults(func=cmd_approve)
# reset
p = sub.add_parser("reset", help="Reset sprite back to needed status")
p.add_argument("--sprite", type=str, required=True, help="Sprite ID to reset")
p.set_defaults(func=cmd_reset)
# export
p = sub.add_parser("export", help="Export registry as CSV or JSON")
p.add_argument("--format", choices=["csv", "json"], default="csv")
p.set_defaults(func=cmd_export)
# test-prompt — rapid prompt iteration without touching the DB
p = sub.add_parser("test-prompt", help="Generate test images with current prompt formula (no DB)")
_add_backend_arg(p)
p.add_argument("--entity", type=str, default="spearmen", help="Unit entity_id to test")
p.add_argument("--race", type=str, default="dwarves")
p.add_argument("--gender", type=str, default="male")
p.add_argument("--combat-type", type=str, default="melee")
p.add_argument("--seeds", type=int, nargs="+", default=[42, 123, 777], help="Seeds to test")
p.add_argument("--outdir", type=str, default="/tmp/prompt-test")
p.set_defaults(func=cmd_test_prompt)
# refresh-prompts — rebuild all stored prompts from current templates
p = sub.add_parser("refresh-prompts", help="Rebuild all stored prompts with current templates")
p.add_argument("--category", type=str, default="units")
p.add_argument("--clear-scores", action="store_true", help="Also clear existing scores for re-ranking")
p.set_defaults(func=cmd_refresh_prompts)
# monitor — track tier progression and report when Opus has candidates
p = sub.add_parser("monitor", help="Monitor tier progression; report when Opus has 3 candidates for user review")
p.set_defaults(func=cmd_monitor)
# starter — curated EA sprite set orchestration
p = sub.add_parser("starter", help="Register/run/monitor the Game-1 starter sprite set")
p.add_argument("starter_cmd", choices=["register", "status", "run", "monitor"],
help="starter subcommand (delegates to orchestrate_starter.py)")
p.add_argument("--backend", choices=["model-boss", "grok"], default="grok")
p.add_argument("--variants", type=int, default=2)
p.add_argument("--batch-size", type=int, default=4)
p.add_argument("--max-attempts", type=int, default=5)
p.add_argument("--reset", action="store_true")
p.add_argument("--once", action="store_true")
p.set_defaults(func=cmd_starter)
args = parser.parse_args()
if args.command is None:
args.port = 5850
cmd_review(args)
else:
args.func(args)
if __name__ == "__main__":
main()