#!/usr/bin/env python3 """Build unpackable Champions of Luneraria demos for Linux, Windows (x64 and ARM64) and macOS. Run: python3 tools/luneraria/build_luneraria_demo.py [--check-only] [--only macos,windows,...] (on macOS without an accepted Xcode licence: /opt/homebrew/bin/python3.12 ...) Godot: `godot` from PATH or the GODOT environment variable (4.7.2 with its export templates). Output: ../dist/luneraria/ChampionsOfLuneraria-Linux-x64-.zip ../dist/luneraria/ChampionsOfLuneraria-Windows-x64-.zip ../dist/luneraria/ChampionsOfLuneraria-Windows-ARM64-.zip ../dist/luneraria/ChampionsOfLuneraria-macOS-universal-.zip (app bundle, ad-hoc signed) Old Saumwacht releases live in ../_archiv/saumwacht-dist and stay untouched. """ from pathlib import Path import argparse, hashlib, json, os, re, zipfile, subprocess, sys PROJECT = Path(__file__).resolve().parents[2] DIST = PROJECT.parent / "dist" / "luneraria" # One source of truth: [luneraria] config/version in project.godot (also shown in the menu). _match = re.search(r'^\[luneraria\][^\[]*?^config/version="([^"]+)"', (PROJECT / "project.godot").read_text(encoding="utf-8"), re.M | re.S) VERSION = _match.group(1) if _match else "0.0.0-unbekannt" GODOT = os.environ.get("GODOT", "godot") INTRO = f"""CHAMPIONS OF LUNERARIA · PROTOTYP {VERSION} START macOS: Das ZIP entpacken und "Champions of Luneraria" starten. Die App ist nur ad-hoc signiert (nicht notarisiert): beim ersten Start Rechtsklick > Öffnen, oder im Terminal: xattr -dr com.apple.quarantine "Champions of Luneraria.app" Universal: Apple Silicon und Intel, macOS 11 oder neuer. Windows: ChampionsOfLuneraria.exe (x64; auf Windows 11 ARM das ARM64-Paket nutzen) Linux: ./ChampionsOfLuneraria.x86_64 Grafikprobleme: --rendering-driver opengl3 (Windows/Linux) Godot und Blender werden zum Spielen nicht benötigt. Das ganze ZIP zuerst entpacken. INHALT Drei Lanes, zwei Teams à drei Rollen, Partieende nur durch einen zerstörten Ancient. Planung → automatischer Kampf → Abrechnung. Helden, Items, Angriffschampions und die Bossgrotte „Mondschlund“ mit der Schlundkrone, die als Verstärkung entsandt wird. G2: monumentale Ancients mit Zustandsstufen, Türme mit Angriff, Schaden und Ruinen. NETZWERK (Direktverbindung, experimentell, pausiert) Im Setup: Hosten oder Beitreten mit Adresse und Port (Standard 24571). Der Host ist autoritativ. Freie Plätze werden von Bots gespielt. PLATTFORMEN Entwickelt und geprüft auf Fedora 44 (bis G1) und macOS 27 / Apple M5 (G2). Windows 11 (ARM, Parallels) siehe docs/luneraria/G2-STRATEGISCHE-BAUWERKE.md. QUELLEN Doku im Quellrepo unter docs/luneraria/ """ # preset, folder, binary (None = direct zip export), zip stem PRESETS = [ ("Luneraria-Linux", "linux", "ChampionsOfLuneraria.x86_64", "ChampionsOfLuneraria-Linux-x64"), ("Luneraria-Windows", "windows", "ChampionsOfLuneraria.exe", "ChampionsOfLuneraria-Windows-x64"), ("Luneraria-Windows-ARM64", "windows-arm64", "ChampionsOfLuneraria.exe", "ChampionsOfLuneraria-Windows-ARM64"), ("Luneraria-macOS", "macos", None, "ChampionsOfLuneraria-macOS-universal"), ] def run_export(preset: str, target: Path) -> bool: cmd = [GODOT, "--headless", "--path", str(PROJECT), "--export-release", preset, str(target)] print("+", " ".join(cmd), flush=True) result = subprocess.run(cmd, capture_output=True, text=True) if result.returncode != 0 or not target.exists(): print(result.stdout[-2000:]) print(result.stderr[-2000:], file=sys.stderr) return False return True def sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for block in iter(lambda: handle.read(1 << 20), b""): digest.update(block) return digest.hexdigest() def build_folder(preset: str, folder: str, binary: str, stem: str) -> Path | None: out_dir = DIST / folder out_dir.mkdir(parents=True, exist_ok=True) if not run_export(preset, out_dir / binary): return None (out_dir / "LIESMICH.txt").write_text(INTRO, encoding="utf-8") zip_path = DIST / f"{stem}-{VERSION}.zip" with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf: for path in sorted(out_dir.iterdir()): if path.suffix == ".zip": continue info = zipfile.ZipInfo.from_file(path, path.name) if path.name == binary: info.external_attr = 0o755 << 16 info.compress_type = zipfile.ZIP_DEFLATED zf.writestr(info, path.read_bytes()) return zip_path def build_macos(preset: str, stem: str) -> Path | None: # Godot writes the app bundle straight into the zip (permissions and ad-hoc signature intact). zip_path = DIST / f"{stem}-{VERSION}.zip" if zip_path.exists(): zip_path.unlink() if not run_export(preset, zip_path): return None with zipfile.ZipFile(zip_path, "a", zipfile.ZIP_DEFLATED) as zf: zf.writestr("LIESMICH.txt", INTRO) return zip_path def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--check-only", action="store_true") parser.add_argument("--only", default="", help="comma separated folders: linux,windows,windows-arm64,macos") args = parser.parse_args() DIST.mkdir(parents=True, exist_ok=True) # Fail early on missing templates / manifests instead of shipping a broken zip. required = [ PROJECT / "assets-source/phase6/export-templates/linux_release.x86_64", PROJECT / "assets-source/phase6/export-templates/windows_release_x86_64.exe", PROJECT / "assets/luneraria/peoples_manifest.json", PROJECT / "assets/luneraria/map_props_manifest.json", PROJECT / "assets/luneraria/g2/g2_manifest.json", PROJECT / "scenes/luneraria.tscn", ] missing = [str(p) for p in required if not p.exists()] if missing: print("MISSING", missing) return 1 if args.check_only: print("CHECK_OK", VERSION) return 0 only = [x for x in args.only.split(",") if x] ok = True built = {} for preset, folder, binary, stem in PRESETS: if only and folder not in only: continue artifact = build_macos(preset, stem) if binary is None else build_folder(preset, folder, binary, stem) if artifact is None: print("EXPORT_FAIL", preset) ok = False continue built[artifact.name] = {"preset": preset, "bytes": artifact.stat().st_size, "sha256": sha256(artifact)} print("PACK", artifact, artifact.stat().st_size, "bytes", flush=True) manifest = DIST / "build-manifest.json" data = json.loads(manifest.read_text()) if manifest.exists() else {} data.setdefault("builds", {}).update(built) data["version"] = VERSION manifest.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n") return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())