Files

144 lines
5.1 KiB
Python

#!/usr/bin/env python3
"""Assemble src/<module>/*.lua into a single loadable script.
Roblox executors run one chunk from one HTTP request, so the shipped file has
to be self-contained. Keeping the sources split and concatenating here is what
lets the project stay readable without giving that up.
Usage:
python build.py # build every module in src/
python build.py mm2 # build just src/mm2 -> mm2.lua
python build.py --check # build, then syntax-check with node check.js
"""
from __future__ import annotations
import hashlib
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parent
SRC = ROOT / "src"
SHARED = "_shared"
BANNER = """\
-- ============================================================================
--
-- ██╗ ██╗██╗████████╗████████╗██╗ ██╗ ██╗ ██╗██╗ ██╗██████╗
-- ██║ ██╔╝██║╚══██╔══╝╚══██╔══╝╚██╗ ██╔╝ ██║ ██║██║ ██║██╔══██╗
-- █████╔╝ ██║ ██║ ██║ ╚████╔╝ ███████║██║ ██║██████╔╝
-- ██╔═██╗ ██║ ██║ ██║ ╚██╔╝ ██╔══██║██║ ██║██╔══██╗
-- ██║ ██╗██║ ██║ ██║ ██║ ██║ ██║╚██████╔╝██████╔╝
-- ╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝
--
-- {title}
-- build {build} · {stamp}
--
-- GENERATED FILE — do not edit directly.
-- Sources live in src/{module}/ ; rebuild with `python build.py`.
--
-- ============================================================================
"""
TITLES = {
"mm2": "Murder Mystery 2 Script",
"jailbreak": "Jailbreak Script",
"generic": "Universal fallback module",
}
def version_of(files: list[Path]) -> str:
"""Read the Version field out of the prelude so the banner cannot drift."""
for path in files:
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith("Version") and "=" in stripped:
return stripped.split("=", 1)[1].strip().strip('",')
return "0.0.0"
def sources_for(name: str) -> list[Path]:
"""Shared sources plus the module own ones, ordered by numeric prefix.
The two directories interleave: a game defaults file has to land before
the shared config store reads it, and its Game table before the shared
movement and visuals look for one.
"""
module_dir = SRC / name
if not module_dir.is_dir():
raise SystemExit(f"no such module directory: {module_dir}")
files = list(module_dir.glob("*.lua")) + list((SRC / SHARED).glob("*.lua"))
files.sort(key=lambda p: p.name)
return files
def build_module(name: str) -> Path:
files = sources_for(name)
if not files:
raise SystemExit(f"no .lua sources for module {name}")
body_parts: list[str] = []
for path in files:
text = path.read_text(encoding="utf-8").rstrip()
label = f"{path.parent.name}/{path.name}"
body_parts.append(
f"-- ─── src/{label} "
+ "─" * max(0, 58 - len(label))
+ f"\n\n{text}\n"
)
body = "\n".join(body_parts)
digest = hashlib.sha256(body.encode("utf-8")).hexdigest()[:8]
header = BANNER.format(
title=TITLES.get(name, name),
build=f"{version_of(files)}+{digest}",
stamp=datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
module=name,
)
out = ROOT / f"{name}.lua"
out.write_text(header + body, encoding="utf-8")
lines = (header + body).count("\n") + 1
print(f" {out.name:<14} {len(files)} sources -> {lines} lines, {out.stat().st_size:,} bytes [{digest}]")
return out
def main() -> int:
args = [a for a in sys.argv[1:] if not a.startswith("-")]
check = "--check" in sys.argv
if not SRC.is_dir():
raise SystemExit("no src/ directory next to build.py")
modules = args or sorted(
p.name for p in SRC.iterdir() if p.is_dir() and p.name != SHARED
)
print(f"building {len(modules)} module(s):")
outputs = [build_module(name) for name in modules]
if check:
checker = ROOT / "check.js"
if not checker.exists():
print(" (skipping syntax check — check.js not found)")
return 0
result = subprocess.run(
["node", str(checker), *(str(p) for p in outputs)],
capture_output=True,
text=True,
)
print(result.stdout.strip())
if result.returncode != 0:
print(result.stderr.strip())
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())