#!/usr/bin/env python3 # Which Forge ports play from their description alone, with none of the game's own Lua? # # The qte and kimmy behaviours load only what the description names, so deleting its "script" and # "addons" lines leaves the loop nothing but the snapshot the converter wrote. This runs each # framework title that way, against its original, through the same comparison the ports are proved # with (FORGE.md 14.1), and writes the ones that agree to testScripts/ports/standalone.txt. # util/forgePorts.py reads that list and assembles those ports without the game's Lua. # # python3 util/forgeStandalone.py [ ...] (no arguments does them all) # python3 util/forgeStandalone.py --hosted [<title> ...] # # --hosted runs each title as it ships instead, with its Lua where it is: the check to run after # regenerating descriptions, since a converter that has learned something writes a description the # loop reads differently. It writes no list. # # SINGE the engine binary (default: the harness picks the newest build) # SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported) # # A title is tested on a hard-linked copy of itself, so the library is never edited and the videos # are not duplicated. A title whose description still needs its Lua stays off the list and keeps # it: startConf drawing at random and specialScore's own scoring are code, not data. import os import re import shutil import subprocess import sys import tempfile REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) LIBRARY = os.path.expanduser(os.environ.get("SINGE_LIBRARY", "~/claude/singetest/ported")) LIST = os.path.join(REPO, "testScripts", "ports", "standalone.txt") HOSTED = re.compile(r'^\s*(script|addons)\s*=\s*"[^"]*",\s*$') def titles(): """Every description the qte or kimmy behaviour plays, as (name, family, game argument, description path). The library's own are a directory and a script; a Hypseus title is its folder under hypseus/.""" found = [] for root, dirs, files in os.walk(LIBRARY): for name in sorted(f for f in files if f.endswith(".forge")): path = os.path.join(root, name) text = open(path, errors="replace").read() loop = re.search(r'behaviours = \{ \{ kind = "(\w+)" \} \}', text) if not loop or loop.group(1) not in ("qte", "kimmy"): continue script = re.search(r'^\s*script\s*=\s*"([^"]+)",', text, re.M) if not script: continue relative = os.path.relpath(path, LIBRARY) if relative.startswith("hypseus" + os.sep): title = relative.split(os.sep)[1] found.append((title, "hypseus", title, path)) else: folder = relative.split(os.sep)[0] found.append((folder, "karis", "%s/%s" % (folder, script.group(1)), path)) return found def strip(source, into): """The title, hard-linked so no video is copied, with its description's hosting lines gone. The description itself is replaced rather than edited, since a hard link is shared.""" os.makedirs(into, exist_ok=True) target = os.path.join(into, os.path.basename(source)) shutil.rmtree(target, ignore_errors=True) subprocess.run(["cp", "-al", source, target], check=True) return target def unhost(descriptions): for path in descriptions: kept = [line for line in open(path, errors="replace").read().splitlines(True) if not HOSTED.match(line)] os.remove(path) # The link, not the library's file. with open(path, "w") as out: out.writelines(kept) def compare(family, game, library): done = subprocess.run([os.path.join(REPO, "testScripts", "ports", "compare.sh"), family, game], cwd=REPO, env=dict(os.environ, SINGE_LIBRARY=library), capture_output=True, text=True, timeout=1800) out = done.stdout + done.stderr if "plays as the original does" in out: return True, [line for line in out.splitlines() if "plays as" in line][0] first = [line for line in out.splitlines() if ("differ" in line) or ("Error" in line) or ("FAIL" in line)] return False, first[0] if first else "no verdict" def main(): wanted = sys.argv[1:] hosted = "--hosted" in wanted if hosted: wanted.remove("--hosted") rows = [row for row in titles() if not wanted or row[0] in wanted] if not rows: sys.exit("forgeStandalone: no titles to test") work = tempfile.mkdtemp(prefix="forgeStandalone.") passed = [] for title, family, game, path in rows: if hosted: # As it ships: the library itself, nothing copied, nothing stripped. ok, saying = compare(family, game, LIBRARY) print("%-28s %s" % (title, "agrees" if ok else "DIFFERS"), flush=True) print(" %s" % saying, flush=True) if ok: passed.append(title) continue # The title, and for a Hypseus one the hypseus/ folder the harness looks under. if family == "hypseus": library = os.path.join(work, "hypseus") copied = strip(os.path.join(LIBRARY, "hypseus", title), library) else: library = work copied = strip(os.path.join(LIBRARY, title), library) inside = [os.path.join(r, f) for r, _, fs in os.walk(copied) for f in fs if f.endswith(".forge")] unhost(inside) ok, saying = compare(family, game, library) print("%-28s %s" % (title, "STANDS ALONE" if ok else "needs its Lua"), flush=True) print(" %s" % saying, flush=True) if ok: passed.append(title) shutil.rmtree(copied, ignore_errors=True) shutil.rmtree(work, ignore_errors=True) if hosted: print("\n%d of %d agree as they ship" % (len(passed), len(rows))) elif not wanted: with open(LIST, "w") as out: out.write("# Titles whose Forge port plays from its description alone, proved by\n") out.write("# util/forgeStandalone.py. util/forgePorts.py leaves the game's own Lua\n") out.write("# out of these ports. Regenerate the list; do not edit it by hand.\n") for title in sorted(passed): out.write(title + "\n") print("\n%d of %d stand alone; written to %s" % (len(passed), len(rows), LIST)) else: print("\n%d of %d stand alone (the list is only written by a full run)" % (len(passed), len(rows))) if __name__ == "__main__": main()