singe/util/forgeStandalone.py

143 lines
6 KiB
Python

#!/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 [<title> ...] (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()