forge/util/forgeLedger.py
2026-10-06 16:57:52 -05:00

182 lines
8.3 KiB
Python

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