#!/usr/bin/env python3 """Check a case for the authoring traps this project keeps hitting. python3 tools/lint.py # every case python3 tools/lint.py cases/case-08-... # one Each rule here exists because it cost a debugging session at least once. Exit status is 1 if anything is reported. """ import glob import json import os import sys ARROW = ["N", "E", "S", "W"] def check(case, warn=False): bad = [] say = lambda m: bad.append(f"{os.path.basename(case)}: {m}") man = json.load(open(os.path.join(case, "game.json"))) sprites = {os.path.basename(p)[:-4] for p in glob.glob(os.path.join(case, "sprites", "*.png"))} rooms = {} for p in sorted(glob.glob(os.path.join(case, "rooms", "room*.json"))): rooms[int(os.path.basename(p)[4:-5])] = json.load(open(p)) set_flags, need_flags, total = set(man.get("flags", {})), set(), 0 # A manifest flag is only "set" if something later flips it true. set_flags = set() for n, doc in rooms.items(): objs = doc.get("objects", []) # MRPGI's obj_matches is a BIDIRECTIONAL substring test over name + # synonyms, and match_obj takes the FIRST visible hit in array order. # So an earlier object shadows a later one whenever any of its tokens # contains (or is contained by) one of the later object's tokens — # this is how `talk barman` ended up addressing `clubbar`. toks = [[o["name"].lower()] + o.get("synonyms", "").lower().split() for o in objs] for j in range(len(objs)): name = objs[j]["name"].lower() for i in range(j): # BLOCKER: the later object's own name reaches an earlier one, # so it cannot be addressed at all. (`talk barman` -> clubbar.) if any(a in name or name in a for a in toks[i]): say(f"room{n}: '{objs[i]['name']}' SHADOWS THE NAME " f"'{name}' — that object is unreachable by name.") elif warn: # Generic synonyms overlapping ("man", "player") only make # that one word ambiguous; the specific name still works. dup = sorted({a for a in toks[i] for b in toks[j] if a in b or b in a}) if dup: say(f"room{n}: note — '{objs[i]['name']}' and " f"'{objs[j]['name']}' share loose synonym " f"'{dup[0]}'; the earlier one wins.") for o in objs: where = f"room{n}.{o['name']}" if o.get("sprite") not in sprites: say(f"{where}: sprite '{o.get('sprite')}' missing from sprites/") # THE recurring one: sets_flag fires on `use`, never on `take`. if o.get("takeable") and o.get("sets_flag"): say(f"{where}: takeable AND sets_flag '{o['sets_flag']}' — " f"taking it will NOT set the flag. Make it non-takeable, " f"or gate on `needs` (which checks inventory).") if o.get("sets_flag"): set_flags.add(o["sets_flag"]) for f in ("requires_flag", "visible_flag", "hidden_by_flag"): if o.get(f): need_flags.add(o[f]) total += o.get("points", 0) for i, node in enumerate(o.get("dialogue", [])): for c in node.get("choices", []): if c.get("sets_flag"): set_flags.add(c["sets_flag"]) if c.get("requires_flag"): need_flags.add(c["requires_flag"]) total += c.get("points", 0) if not (-1 <= c["goto"] < len(o["dialogue"])): say(f"{where}: dialogue node {i} goto={c['goto']} " f"out of range (0..{len(o['dialogue']) - 1} or -1)") for d, tgt in enumerate(doc.get("exits", [None] * 4)): if tgt is not None and tgt not in rooms: say(f"room{n}: exit {ARROW[d]} -> room{tgt}, which does not exist") for d, f in enumerate(doc.get("exit_flags", [""] * 4)): if f: need_flags.add(f) if doc.get("exits", [None] * 4)[d] is None: say(f"room{n}: exit_flags[{ARROW[d]}]='{f}' but no exit there") for f in sorted(need_flags - set_flags): say(f"flag '{f}' is required somewhere but nothing ever sets it") # Alternative dialogue branches that award the same flag inflate this sum # (the flag latch makes each award once-only), so only a SHORTFALL is a bug: # it means max_score is unreachable. if man.get("max_score") and total < man["max_score"]: say(f"only {total} points authored but max_score is " f"{man['max_score']} — the target is unreachable") return bad def main(): warn = "--warn" in sys.argv cases = [a for a in sys.argv[1:] if not a.startswith("-")] or sorted(glob.glob("cases/*/")) bad = [] for c in cases: c = c.rstrip("/") if os.path.exists(os.path.join(c, "game.json")): bad += check(c, warn) for b in bad: print(" " + b) print(f"{len(bad)} issue(s) across {len(cases)} case(s)") sys.exit(1 if bad else 0) if __name__ == "__main__": main()