Eight cases playable, all lint clean, all verified end-to-end. John asked what 1950s bumbling criminals Drebin Sr could look good against. Clouseau is the wrong shape twice — 1963, and a bumbling detective rather than a criminal. The right seam is the Ealing comedies: The Ladykillers (1955) and The Lavender Hill Mob (1951). Done as archetype parody, never character import, which is exactly what ZAZ did to M Squad — take the form, not the people — and which lets the gang be tuned to make Sr's twist land. Five lodgers calling themselves a barbershop quartet, and in eleven weeks nobody in that house has raised the arithmetic. A mastermind who is nought for eleven and blames personnel every time. Muscle brought for the violence who offers you an aniseed ball. A driver who cannot drive and came with the goggles. A safecracker who is the best in the state and has been deaf since 1943, and has been asking about that since August. An inside man who works at a different company on a different street and has raised it four times. The twist: they tunnel eleven feet through Victorian brick for eleven weeks to reach a vault Frank Drebin Sr emptied five weeks earlier — legitimately, on his own written recommendation, signing the transfer manifest at both ends. Arrested inside an empty room. The paper calls it the finest detective work in the city in twenty years and every word of it is true. This is why he has never been caught: the visible criminals are loud and his own crimes are silent. The case is actually cracked by a seventy-nine-year-old landlady who took a bus to the police station to complain about the noise, and she never learns it. New lint rule, from a real 10-point loss here: two DIFFERENT objects awarding points for setting the same flag — points fire on the false->true flip, so whichever runs second silently pays nothing and max_score goes unreachable. Same object paying via `use` or its own dialogue is fine; those are alternatives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
138 lines
6.5 KiB
Python
138 lines
6.5 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()
|
|
paying = {} # flag -> what already awards points for setting it
|
|
|
|
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"):
|
|
# Points are awarded when a flag flips false->true. If two
|
|
# scoring things set the SAME flag, whichever fires second
|
|
# silently pays nothing and max_score becomes unreachable.
|
|
if o.get("points") and paying.get(o["sets_flag"], where) != where:
|
|
say(f"{where}: awards points for setting '{o['sets_flag']}', "
|
|
f"which {paying[o['sets_flag']]} already pays for — "
|
|
f"whichever fires second pays nothing.")
|
|
elif o.get("points"):
|
|
paying[o["sets_flag"]] = where
|
|
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"):
|
|
if c.get("points") and paying.get(c["sets_flag"], where) != where:
|
|
say(f"{where} dialogue: awards points for setting "
|
|
f"'{c['sets_flag']}', which "
|
|
f"{paying[c['sets_flag']]} already pays for — "
|
|
f"whichever fires second pays nothing.")
|
|
elif c.get("points"):
|
|
paying[c["sets_flag"]] = where
|
|
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()
|