Act I, monochrome. Five rooms, 100 points, verified at 100/100 with four verified deaths. Built out of John's Police Quest material. PQ1's famous 8-bit integer underflow is not an easter egg here, it is the central puzzle: Frank signs for a $40 operational float clipped to a note reading DO NOT EXCEED YOUR FLOAT, the back room costs $2,500, and exceeding the float by one dollar wraps the department ledger's eight-bit field to 255 — the terminal appends the cents column and authorises $2,550.00. He keeps drinking to 132 because it will not do to stop suddenly. Accounts raise it in March; it goes to a hearing. PQ1's Gremlin runs through the whole case and is never resolved: chickens on the Captain's desk, on Al's shoulder, on the murdered guest star, and one in the winning freeze-frame. Nobody has said the word "chicken" out loud in eleven years. No file was ever opened. Then the stage: six memorised jokes, in order, and every "improvise" option is fatal — plus a full glass on a stool three feet from the microphone, which is how Danny Cavanaugh died and which you must not touch. Linter gained the rule that actually caused this session's bug: MRPGI's obj_matches is a BIDIRECTIONAL substring test over name AND synonyms, and match_obj takes the first hit in array order, so an earlier object can make a later one unreachable by its own name. `talk barman` was addressing `clubbar`. It found two more: `sideboard` swallowing `cardtable`, and `bouncer` swallowing `bunkerdoor` — the last of which shipped broken in Case Ten. All six cases lint clean and all six still win. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
121 lines
5.3 KiB
Python
121 lines
5.3 KiB
Python
#!/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()
|