policesquadquest/tools/lint.py
type-two 72815f8c55 Case Six: Testimony of Evil — the whiskey exploit is the puzzle
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>
2026-07-25 23:11:22 +10:00

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