diff --git a/sds/cli.py b/sds/cli.py index 35445f7..a95eeea 100644 --- a/sds/cli.py +++ b/sds/cli.py @@ -1050,6 +1050,12 @@ def cmd_cluster_table(args: argparse.Namespace) -> int: return code +def cmd_conformance(args: argparse.Namespace) -> int: + from .match import conformance + + return conformance(Path(args.recording), bool(args.bless)) + + def cmd_stand_probe(args: argparse.Namespace) -> int: """Record what MegaMek permits a prone, damaged Mek of each chassis.""" from .match import stand_probe @@ -2210,6 +2216,29 @@ def main(argv: list[str] | None = None) -> int: ) cluster_table.set_defaults(func=cmd_cluster_table) + conformance = sub.add_parser( + "conformance", + help="re-ask MegaMek the questions already asked, and diff the answers", + description=( + "One JVM, no server, no match. Every fact this repository has probed " + "out of MegaMek and then built on - a class relationship, a sentinel, " + "a refusal, a step function, a terrain set - re-asked and compared " + "against bridge/conformance.txt. --bless re-records; a commit that " + "moves a figure has to say why." + ), + ) + conformance.add_argument( + "--recording", + default=str(REPO / "bridge" / "conformance.txt"), + help="the committed answers to diff against", + ) + conformance.add_argument( + "--bless", + action="store_true", + help="overwrite the recording with what MegaMek answers now", + ) + conformance.set_defaults(func=cmd_conformance) + club_probe = sub.add_parser( "club-probe", help="record what MegaMek says a mounted melee weapon does", diff --git a/sds/match.py b/sds/match.py index 741afa8..4aa06c1 100644 --- a/sds/match.py +++ b/sds/match.py @@ -937,6 +937,73 @@ def cluster_table(out: Path) -> int: return _bridge("clustertable", "sds.SdsClusterTable", [], stdout=out) +def conformance(out: Path, bless: bool) -> int: + """Re-ask MegaMek every question this repository has already asked it. + + One short JVM, no server and no match: `sds.SdsConformance` prints one + `key = value` line per pinned fact and this compares that against the + committed recording, naming the facts that moved rather than the lines. + + The probes in `bridge/` each settled a question once and then stopped + running, and their answers are load-bearing - an `instanceof` rather than a + flag, a sentinel a damage reader special-cases, the eight terrains that are + cover. A MegaMek upgrade that moved one of those would leave this + repository wrong with every test still green. This is what fails instead. + + `bless` re-records. A commit that moves a figure here has to say why. + """ + fresh = REPO / "bridge" / "build" / "conformance.observed.txt" + code = _bridge("conformance", "sds.SdsConformance", [], stdout=fresh) + if code != 0: + print(f"the conformance probe exited {code}; see {fresh}", file=sys.stderr) + return code + + observed = _facts(fresh.read_text()) + if bless: + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(fresh.read_text()) + print(f"recorded {len(observed)} facts -> {out}") + return 0 + if not out.is_file(): + print(f"no recording at {out}; run `./sds.sh conformance --bless`", file=sys.stderr) + return 1 + + recorded = _facts(out.read_text()) + moved = [k for k in recorded if k in observed and recorded[k] != observed[k]] + gone = [k for k in recorded if k not in observed] + added = [k for k in observed if k not in recorded] + if not (moved or gone or added): + print(f"conformance: {len(observed)} facts, all as recorded") + return 0 + + for key in moved: + print(f"MOVED {key}") + print(f" recorded: {recorded[key]}") + print(f" megamek: {observed[key]}") + for key in gone: + print(f"GONE {key}\n recorded: {recorded[key]}") + for key in added: + print(f"NEW {key}\n megamek: {observed[key]}") + print( + f"\nconformance: {len(moved)} moved, {len(gone)} gone, {len(added)} new." + "\nMegaMek no longer answers what this repository recorded. Read the facts" + "\nabove before blessing them: each one is something the code depends on." + ) + return 1 + + +def _facts(text: str) -> dict[str, str]: + """`key = value` lines, comments and blanks dropped.""" + facts = {} + for line in text.splitlines(): + line = line.strip() + if not line or line.startswith("#") or " = " not in line: + continue + key, _, value = line.partition(" = ") + facts[key.strip()] = value.strip() + return facts + + def stand_probe(scenario: Path, out: Path) -> int: """Ask MegaMek what a prone Mek with each set of destroyed limbs may do.