#!/usr/bin/env python3 # The ledger of FORGE.md section 17: every capability the library's converted titles use, which # titles use it, and how the converter maps it, gathered from util/forgePortKaris.lua's report # (FORGE_LEDGER) for each description it made. Nothing is run but the converter; no game plays. # # python3 util/forgeLedger.py [] (default: testScripts/ports/ledger.md) # # SINGE_LIBRARY the ported originals (default: ~/claude/singetest/ported) # # A status is the converter's own: "forge" is a field of Forge's model, "framework" something # Forge plays under the framework's names or numbers, "lua" something the game's own Lua still does. # The runs-without-its-Lua column is testScripts/ports/standalone.txt as last proved, with its date. import collections import datetime import hashlib import os import re 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")) OUT = sys.argv[1] if len(sys.argv) > 1 else os.path.join(REPO, "testScripts", "ports", "ledger.md") STANDING = os.path.join(REPO, "testScripts", "ports", "standalone.txt") MADE_BY = "by the port converter" SCRIPT = re.compile(r'^\s*script\s*=\s*"([^"]+)"', re.M) STATUSES = ("lua", "framework", "forge") EXAMPLES = 6 def descriptions(): """Every description the converter made, as (title, directory, main script, name).""" found = [] for root, dirs, files in os.walk(LIBRARY): dirs.sort() 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() if MADE_BY not in text: continue script = SCRIPT.search(text) if not script: continue title = os.path.relpath(path, LIBRARY)[:-len(".forge")] found.append((title, root, script.group(1), name[:-len(".forge")])) return found def others(): """The descriptions another tool made, named so the ledger says what it does not cover.""" missing = [] for root, dirs, files in os.walk(LIBRARY): for name in files: path = os.path.join(root, name) if name.endswith(".forge") and MADE_BY not in open(path, errors="replace").read(): missing.append(os.path.relpath(path, LIBRARY)) return sorted(missing) def addonsHash(directory): path = os.path.join(directory, "Script", "addons.singe") return hashlib.md5(open(path, "rb").read()).hexdigest() if os.path.exists(path) else None def report(directory, script, name): with tempfile.NamedTemporaryFile(suffix=".ledger", delete=False) as handle: path = handle.name try: done = subprocess.run(["lua5.4", os.path.join("util", "forgePortKaris.lua"), directory, script, name], cwd=REPO, env=dict(os.environ, FORGE_LEDGER=path), capture_output=True, text=True, timeout=120) lines = [line.rstrip("\n").split("\t") for line in open(path) if line.strip()] return lines, (done.stderr.strip().splitlines() or [""])[-1] if done.returncode else None finally: os.remove(path) def main(): standing = set() proved = "never" if os.path.exists(STANDING): standing = {line.strip() for line in open(STANDING) if line.strip() and not line.startswith("#")} proved = datetime.date.fromtimestamp(os.path.getmtime(STANDING)).isoformat() uses = collections.defaultdict(set) # (status, capability) -> titles details = collections.defaultdict(set) # (status, capability) -> details perTitle = {} failed = [] titles = descriptions() # The add-ons most titles ship unchanged are the framework's own: their hooks are the stock # ones, not the game's Lua. hashes = collections.Counter(h for h in (addonsHash(row[1]) for row in titles) if h) stock = hashes.most_common(1)[0][0] if hashes else None for title, directory, script, name in titles: lines, error = report(directory, script, name) if error is not None: failed.append((title, error)) continue if stock and addonsHash(directory) == stock: lines = [["framework", capability, "stock add-ons"] if capability.startswith("hook.") else [status, capability, detail] for status, capability, detail in lines] counts = collections.Counter() family = "?" for status, capability, detail in lines: uses[(status, capability)].add(title) if detail: details[(status, capability)].add(detail) counts[status] += 1 if capability.startswith("family."): family = capability[len("family."):] folder = title.split("/")[1] if title.startswith("hypseus/") else title.split("/")[0] perTitle[title] = (family, counts, "yes" if folder in standing else "not on the list") out = [] out.append("# The ledger (FORGE.md section 17)") out.append("") out.append("Generated by `util/forgeLedger.py` on %s from the converter's report for each" % datetime.date.today().isoformat()) out.append("description it made; do not edit by hand. **forge** is a field of Forge's own model,") out.append("**framework** something Forge plays under the framework's names or numbers, **lua**") out.append("something the game's own Lua still does. Rows are sorted by status, then by how many") out.append("titles use them: the top of the table is what most blocks titles from being Forge's own.") out.append("A hook from the add-ons file most titles ship unchanged (%d of them) is the framework's" % (hashes[stock] if stock else 0)) out.append("stock one, and counts as framework. The globals are every global the game's script left") out.append("that nothing names: most are the framework's own state and picture numbers, not data.") out.append("") out.append("%d titles reported; %d failed to report; %d descriptions made by another tool (not covered)." % (len(perTitle), len(failed), len(others()))) out.append("") # The capabilities, with the globals and settings folded into one row each per status: there # are hundreds, and their names are listed in the section after. folded = collections.defaultdict(set) for (status, capability), titles in uses.items(): if capability.startswith("global.") or capability.startswith("setting."): folded[(status, capability.split(".")[0] + ".*")] |= titles else: folded[(status, capability)] |= titles out.append("## Capabilities") out.append("") out.append("| Status | Capability | Titles | Some of them | Details |") out.append("|---|---|---|---|---|") for (status, capability), titles in sorted(folded.items(), key=lambda item: (STATUSES.index(item[0][0]), -len(item[1]), item[0][1])): names = sorted(titles) more = ", ..." if len(names) > EXAMPLES else "" shown = ", ".join(title.split("/")[-1] for title in names[:EXAMPLES]) + more found = sorted(details.get((status, capability), [])) note = "; ".join(found[:8]) + ("; ..." if len(found) > 8 else "") out.append("| %s | %s | %d | %s | %s |" % (status, capability, len(names), shown, note)) out.append("") for prefix, heading in (("setting.", "Settings under the framework's names"), ("global.", "Globals nothing names yet")): rows = sorted(((capability, len(titles)) for (status, capability), titles in uses.items() if capability.startswith(prefix)), key=lambda row: (-row[1], row[0])) out.append("## %s (%d)" % (heading, len(rows))) out.append("") out.append("By how many titles use each: " + ", ".join("%s %d" % (capability[len(prefix):], count) for capability, count in rows) + ".") out.append("") out.append("## Titles") out.append("") out.append("Runs without its Lua: `standalone.txt`, last proved %s." % proved) out.append("") out.append("| Title | Family | forge | framework | lua | Runs without its Lua |") out.append("|---|---|---|---|---|---|") for title, (family, counts, standalone) in sorted(perTitle.items(), key=lambda item: (item[1][1]["lua"], item[1][1]["framework"], item[0])): out.append("| %s | %s | %d | %d | %d | %s |" % (title, family, counts["forge"], counts["framework"], counts["lua"], standalone)) out.append("") if failed: out.append("## Failed to report") out.append("") for title, error in failed: out.append("* %s: %s" % (title, error)) out.append("") out.append("## Made by another tool") out.append("") out.append(", ".join(others()) + ".") out.append("") with open(OUT, "w") as handle: handle.write("\n".join(out)) print("wrote %s: %d titles, %d capabilities, %d failed" % (OUT, len(perTitle), len(folded), len(failed))) if __name__ == "__main__": main()