atlas-check shows planet count, body summary, and flags for quick proposal diagnostics. atlas-verify now accepts "unusual" as a valid star_type alongside "binary". Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
153 lines
5.0 KiB
Python
Executable File
153 lines
5.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Verify an atlas proposal JSON against integrity checks.
|
|
|
|
Usage:
|
|
tooling/atlas-verify docs/atlas/proposals/GJ273.json
|
|
tooling/atlas-verify docs/atlas/proposals/*.json
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def verify_proposal(path: Path) -> list[str]:
|
|
"""Return list of error strings. Empty list = pass."""
|
|
with open(path) as f:
|
|
p = json.load(f)
|
|
|
|
errors = []
|
|
bodies = p.get("bodies", [])
|
|
stations = p.get("stations", [])
|
|
|
|
# 1. Inhabited bodies must have names
|
|
for b in bodies:
|
|
if b.get("inhabited") and not b.get("proper_name"):
|
|
errors.append(f"Inhabited body {b['body_id']} has no proper_name")
|
|
|
|
# 2. Uninhabited bodies MAY have names (lore reasons) — no check needed
|
|
|
|
# 3. All stations must have names
|
|
for s in stations:
|
|
if not s.get("proper_name"):
|
|
errors.append(f"Station {s['station_id']} has no proper_name")
|
|
|
|
# 4. Body count minimum by star type
|
|
planets = [b for b in bodies if b["body_type"] == "planet"]
|
|
star_type = p.get("spectral_class", "M")
|
|
if star_type.startswith(("G", "F")):
|
|
min_planets = 8
|
|
elif star_type.startswith("K"):
|
|
min_planets = 7
|
|
else:
|
|
min_planets = 6
|
|
if len(planets) < min_planets:
|
|
errors.append(
|
|
f"Only {len(planets)} planets, need {min_planets}+ "
|
|
f"for {star_type} star"
|
|
)
|
|
|
|
# 5. Required structures
|
|
if not any(b["body_type"] == "oort_cloud" for b in bodies):
|
|
errors.append("No oort cloud")
|
|
|
|
horizons = [s for s in stations if s["station_type"] == "horizon"]
|
|
if not horizons:
|
|
errors.append("No horizon station")
|
|
elif not any(s.get("has_gate_infrastructure") for s in horizons):
|
|
errors.append("Horizon station missing has_gate_infrastructure: true")
|
|
|
|
if not any(b["body_type"] == "asteroid_belt" for b in bodies):
|
|
errors.append("No asteroid belt (add one unless wiki contradicts)")
|
|
|
|
# 6. Orbit consistency — top-level bodies
|
|
top_level = [b for b in bodies if not b.get("parent_body_id")]
|
|
orbits = [b["orbit_index"] for b in top_level]
|
|
if orbits != sorted(orbits):
|
|
errors.append(f"Top-level orbit_index not monotonic: {orbits}")
|
|
if len(orbits) != len(set(orbits)):
|
|
errors.append(f"Duplicate orbit_index in top-level: {orbits}")
|
|
|
|
# 7. Moon orbit consistency
|
|
parents: dict[str, list[int]] = {}
|
|
for b in bodies:
|
|
pid = b.get("parent_body_id")
|
|
if pid:
|
|
parents.setdefault(pid, []).append(b["orbit_index"])
|
|
for pid, idxs in parents.items():
|
|
if idxs != sorted(idxs):
|
|
errors.append(f"Moon orbits under {pid} not monotonic: {idxs}")
|
|
if len(idxs) != len(set(idxs)):
|
|
errors.append(f"Duplicate moon orbit_index under {pid}: {idxs}")
|
|
|
|
# 8. Parent references valid
|
|
body_ids = {b["body_id"] for b in bodies}
|
|
for b in bodies:
|
|
pid = b.get("parent_body_id")
|
|
if pid and pid not in body_ids:
|
|
errors.append(
|
|
f"Body {b['body_id']} references missing parent {pid}"
|
|
)
|
|
for s in stations:
|
|
oid = s.get("orbits_body_id")
|
|
if oid and oid not in body_ids:
|
|
errors.append(
|
|
f"Station {s['station_id']} references missing body {oid}"
|
|
)
|
|
|
|
# 9. star_type vs spectral_class consistency
|
|
star_type_field = p.get("star_type", "")
|
|
spectral = p.get("spectral_class", "")
|
|
if star_type_field and spectral and star_type_field not in ("binary", "unusual"):
|
|
# Strip dwarf/subdwarf prefixes (d, sd) to get actual class letter
|
|
s = spectral.lstrip("sd").upper()
|
|
spectral_letter = s[0] if s else ""
|
|
if spectral_letter and spectral_letter.isalpha():
|
|
if star_type_field[0].upper() != spectral_letter:
|
|
errors.append(
|
|
f"star_type '{star_type_field}' conflicts with "
|
|
f"spectral_class '{spectral}'"
|
|
)
|
|
|
|
return errors
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Verify atlas proposal JSON files"
|
|
)
|
|
parser.add_argument(
|
|
"proposals",
|
|
nargs="+",
|
|
type=Path,
|
|
help="Proposal JSON file(s) to verify",
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
# Sol is hand-authored with different rules (named uninhabited bodies, etc.)
|
|
EXCLUDE = {"GJ0.json", "GJ1221.json"}
|
|
|
|
total_errors = 0
|
|
for path in args.proposals:
|
|
if path.name in EXCLUDE:
|
|
continue
|
|
if not path.exists():
|
|
print(f"SKIP — {path} not found")
|
|
continue
|
|
errors = verify_proposal(path)
|
|
if errors:
|
|
print(f"FAIL — {path.name}")
|
|
for e in errors:
|
|
print(f" - {e}")
|
|
total_errors += len(errors)
|
|
else:
|
|
print(f"PASS — {path.name}")
|
|
|
|
if total_errors:
|
|
print(f"\n{total_errors} error(s) across {len(args.proposals)} file(s)")
|
|
sys.exit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|