#!/usr/bin/env python3 """ One-shot script: fill missing globe.png files in the wiki by copying a donor globe from the same planet_class + body_type pool. Donor selection: - Hash body_id to pick deterministically from the pool - Track used donors per system to avoid visual repetition - Skip oort_cloud and asteroid_belt (handled by generic images) Run once, then the files are baked into the wiki like authored content. Usage: python3 tooling/fill-missing-globes.py [--dry-run] """ import hashlib import shutil import sqlite3 import sys from collections import defaultdict from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent WIKI_ROOT = REPO_ROOT / "wiki" / "star-systems" DB_PATH = REPO_ROOT / "server" / "data" / "systems.db" GENERIC_TYPES = {"oort_cloud", "asteroid_belt"} GENERIC_DIR = WIKI_ROOT / "_generics" def globe_path(system_id: str, body_id: str) -> Path: sys_dir = system_id.replace(" ", "-") return WIKI_ROOT / sys_dir / "bodies" / body_id / "globe.png" def body_hash(body_id: str) -> int: return int(hashlib.sha256(body_id.encode()).hexdigest()[:8], 16) def main(): dry_run = "--dry-run" in sys.argv conn = sqlite3.connect(str(DB_PATH)) conn.row_factory = sqlite3.Row bodies = conn.execute( "SELECT body_id, system_id, body_type, planet_class FROM bodies" ).fetchall() # Build pools of existing globes by (body_type, planet_class) pools: dict[tuple[str, str], list[Path]] = defaultdict(list) missing: list[dict] = [] for b in bodies: bid = b["body_id"] bt = b["body_type"] or "unknown" pc = (b["planet_class"] or "unknown").lower() path = globe_path(b["system_id"], bid) if bt in GENERIC_TYPES: # Handled by generic images, not donors if not path.exists(): generic = GENERIC_DIR / bt / "globe.png" if generic.exists(): missing.append({ "body_id": bid, "system_id": b["system_id"], "body_type": bt, "planet_class": pc, "donor_path": generic, "source": "generic", }) continue if path.exists(): pools[(bt, pc)].append(path) else: missing.append({ "body_id": bid, "system_id": b["system_id"], "body_type": bt, "planet_class": pc, "donor_path": None, "source": "donor", }) # For each missing body, pick a donor used_per_system: dict[str, set[str]] = defaultdict(set) assigned = 0 skipped = 0 log_lines = [] for m in missing: if m["source"] == "generic": # Generic image copy target = globe_path(m["system_id"], m["body_id"]) if dry_run: log_lines.append( f"[GENERIC] {m['body_id']} <- {m['donor_path'].name} ({m['body_type']})" ) else: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(m["donor_path"], target) log_lines.append( f"[GENERIC] {m['body_id']} <- {m['donor_path']}" ) assigned += 1 continue bt = m["body_type"] pc = m["planet_class"] key = (bt, pc) pool = pools.get(key, []) if not pool: # Try broader match: same body_type, any class for (pbt, ppc), p in pools.items(): if pbt == bt and p: pool = p break if not pool: log_lines.append( f"[SKIP] {m['body_id']} — no donors for {bt}/{pc}" ) skipped += 1 continue # Pick donor by hash, avoid repeats in same system sys_id = m["system_id"] h = body_hash(m["body_id"]) pool_size = len(pool) used = used_per_system[sys_id] donor = None for offset in range(pool_size): candidate = pool[(h + offset) % pool_size] candidate_key = str(candidate) if candidate_key not in used: donor = candidate used.add(candidate_key) break if donor is None: # All donors used in this system — allow repeat from least-used donor = pool[h % pool_size] target = globe_path(sys_id, m["body_id"]) donor_bid = donor.parent.name if dry_run: log_lines.append( f"[DONOR] {m['body_id']} <- {donor_bid} ({bt}/{pc})" ) else: target.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(donor, target) log_lines.append( f"[DONOR] {m['body_id']} <- {donor_bid} ({bt}/{pc})" ) assigned += 1 conn.close() # Report prefix = "[DRY RUN] " if dry_run else "" print(f"{prefix}Assigned: {assigned}, Skipped: {skipped}") print() for line in log_lines: print(line) if __name__ == "__main__": main()