policesquadquest/tools/lint.py
type-two 58471b6a8e Act Zero crossover: The Quartet — Drebin Sr vs. the Ealing incompetents
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>
2026-07-25 23:30:26 +10:00

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()