Case Eight — The Smell of Fear (1991). Act II, full colour. Five rooms, 100 points, verified at 100/100 with two verified deaths. The puzzle is identification: Meinheimer has an eidetic memory, so the double cannot be caught out on a fact — he can be caught out on a manner. Ask him two questions and he answers "about four hundred", "roughly", "give or take", and a man who remembers everything never says roughly. The catch is gated on having heard the approximations, so the player has to earn it. Accusing him without that is a death. Also in: the Blue Note, the SWAT tank through the wall into the zoo (which arms the lion that kills Hapsburg two rooms later), the mariachi infiltration, and a nuclear device disarmed by tripping over its power cable. tools/lint.py: three of the four bugs that cost real time here were the same class, and I walked into sets_flag-on-a-takeable twice — once in Case Ten, then again in Case Eight after documenting it. So the rules are a linter now, not a paragraph. It caught two live instances of that trap (case-08 blueprint, case-10 wig) and a scoring mismatch on the first run. All five cases lint clean and all five still win. play.sh is now a case picker: bare invocation lists the cases and asks, or pass a number. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
101 lines
4.1 KiB
Python
101 lines
4.1 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):
|
|
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():
|
|
names = [o["name"] for o in doc.get("objects", [])]
|
|
for o in doc.get("objects", []):
|
|
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)
|
|
# The parser matches loosely, so one name must not shadow another.
|
|
for other in names:
|
|
if other != o["name"] and other.startswith(o["name"]):
|
|
say(f"room{n}: '{o['name']}' is a prefix of '{other}' — "
|
|
f"the parser will match the wrong object.")
|
|
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():
|
|
cases = sys.argv[1:] 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)
|
|
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()
|