champions-of-luneraria/tools/port/gen_content.py
Martin Stickelbroeck 4d6ed17dca U1: Regelkern in C#, verhaltensgleich zur Godot-Referenz
Vollständiger, engineunabhängiger Regelkern (Luneraria.Core, ohne
UnityEngine): Partie mit sechs Plätzen und zwei Teams, private Planung mit
atomarer Übernahme, Gold, Käufe, Aufwertungen, Formation, drei Lanes mit
zwei Laneturmen und zwei Basistürmen je Team, Ancients, unbegrenzte Wellen,
Helden mit Stufen, Items und Talenten, Angriffschampions, Bossgrotte mit
Anspruch, Entsendung, Kopfgeld und dreirundiger Sperre, Bots und
Siegbedingung (nur Ancient-Fall). Serverautoritative Befehlsschnittstelle.

- Deterministisch: fester Takt, geordnete Maps, fortlaufende IDs, kein
  Zufall, keine Uhr, keine Threads
- Tabellen einmal aus Godot erzeugt (tools/port/gen_content.py)
- Golden-Referenzen aus Godot (tools/godot-golden): 19 Szenarien,
  120 Snapshots, 51.221 Werte, Abweichung 0 (Toleranzen dokumentiert)
- Tests: content 75, core 91, m2 73, golden 157, determinism 6 = 402/402
- Leistung (M5): Simulationsschritt 0,259 ms statt 1,587 ms in GDScript
- Referenzfehler R1 im Bot-Gedächtnis bewusst übernommen und dokumentiert
- Geplante Helden (Lucy, Ghaliga, Siegfried, Feena) im Datenmodell, inaktiv
- Doku unter docs/port (U1-RULES, PORT-STATUS, PORT-QA, U2 bis U5)

Unity-Editor-Lauf offen: Die Unity-Lizenz ist noch nicht aktiviert; gebaut
und getestet mit dem .NET 8 SDK auf Unitys Sprachstand (C# 9, .NET
Standard 2.1).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-30 14:31:18 +02:00

158 lines
9.3 KiB
Python

#!/usr/bin/env python3
"""Generates Assets/Luneraria/Core/Content/ContentData.cs from the Godot content export
(tools/godot-golden/content_export.gd). One-time bootstrap of the port: after generation the
C# file is the maintained source; a test compares it with the exported reference
(Assets/Luneraria/Tests/Core/Golden/content.json).
Usage: python3 tools/port/gen_content.py /tmp/lune_content.json
"""
import json
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[2]
OUT = ROOT / "Assets/Luneraria/Core/Content/ContentData.cs"
def s(value):
return '"' + str(value).replace("\\", "\\\\").replace('"', '\\"') + '"'
def d(value):
text = repr(float(value))
return text if ("." in text or "e" in text or "inf" in text) else text + ".0"
UNIT_FIELDS = [("id", "Id", s), ("display_name", "DisplayName", s), ("cost", "Cost", int), ("upgrade_id", "UpgradeId", s),
("max_hp", "MaxHp", d), ("damage", "Damage", d), ("attack_type", "AttackType", int), ("armor_type", "ArmorType", int),
("attack_range", "AttackRange", d), ("speed", "Speed", d), ("ability", "Ability", s), ("leak_value", "LeakValue", int),
("bounty", "Bounty", int), ("ability_description", "AbilityDescription", s), ("is_boss", "IsBoss", lambda v: "true" if v else "false"),
("income_gain", "IncomeGain", int)]
def unit_def(entry, indent):
parts = []
for key, name, fmt in UNIT_FIELDS:
if key in entry:
parts.append(f"{name} = {fmt(entry[key])}")
unknown = set(entry) - {k for k, _, _ in UNIT_FIELDS}
if unknown:
raise SystemExit(f"unknown unit keys {unknown}")
return "new UnitDef { " + ", ".join(parts) + " }"
def table(name, entries):
lines = [f" public static readonly OrderedMap<string, UnitDef> {name} = Map("]
rows = [f" {unit_def(entry, 12)}" for entry in entries.values()]
lines.append(",\n".join(rows))
lines.append(" );")
return "\n".join(lines)
def main():
data = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8"))
out = []
out.append("// <auto-generated once> by tools/port/gen_content.py from the Godot reference (luneraria/g3, 1b540f2).")
out.append("// Since the port this file is the maintained source of the rule tables; ContentTests compares it")
out.append("// with the exported reference in Assets/Luneraria/Tests/Core/Golden/content.json.")
out.append("using System.Collections.Generic;")
out.append("")
out.append("namespace Luneraria.Core")
out.append("{")
out.append(" /// <summary>Rule tables of Champions of Luneraria: troops, monsters, sends, heroes, items, champions, bots.</summary>")
out.append(" public static partial class ContentData")
out.append(" {")
out.append(table("Units", data["units"]) + "\n")
out.append(table("Enemies", data["enemies"]) + "\n")
out.append(table("Sends", data["sends"]) + "\n")
# Heroes
out.append(" public static readonly OrderedMap<string, HeroSpec> Heroes = Ordered(")
rows = []
for hero_id, hero in data["heroes"].items():
base = hero["def"]
rows.append(" new HeroSpec { " + ", ".join([f"Id = {s(hero_id)}", f"Name = {s(hero['name'])}", f"Title = {s(hero['title'])}",
f"Model = {s(hero['model'])}", f"Cost = {int(hero['cost'])}", f"RoleText = {s(hero['role'])}",
f"Provisional = {'true' if hero.get('provisional') else 'false'}", f"Summary = {s(hero['summary'])}",
"Base = " + unit_def(base, 16)]) + " }")
out.append(",\n".join(rows))
out.append(" );\n")
out.append(" public static readonly int[] LevelXp = { " + ", ".join(str(v) for v in data["level_xp"]) + " };")
out.append(" public static readonly int[] TalentLevels = { " + ", ".join(str(v) for v in data["talent_levels"]) + " };\n")
out.append(" /// <summary>Per hero: tiers of two mutually exclusive choices.</summary>")
out.append(" public static readonly Dictionary<string, TalentOption[][]> Talents = new Dictionary<string, TalentOption[][]>")
out.append(" {")
rows = []
for hero_id, tiers in data["talents"].items():
tier_rows = []
for tier in tiers:
options = ", ".join(f"new TalentOption({s(o['id'])}, {s(o['name'])}, {s(o['text'])})" for o in tier)
tier_rows.append(" new[] { " + options + " }")
rows.append(" { " + s(hero_id) + ", new[]\n {\n" + ",\n".join(tier_rows) + "\n } }")
out.append(",\n".join(rows))
out.append(" };\n")
# Items
out.append(" public static readonly OrderedMap<string, ItemSpec> Items = Ordered(")
rows = []
known = {"name", "cost", "text", "hp_flat", "damage", "reduction", "attack_speed", "regen", "splash", "min_level"}
for item_id, item in data["items"].items():
if set(item) - known:
raise SystemExit(f"unknown item keys {set(item) - known}")
parts = [f"Id = {s(item_id)}", f"Name = {s(item['name'])}", f"Cost = {int(item['cost'])}", f"Text = {s(item['text'])}"]
for key, name in [("hp_flat", "HpFlat"), ("damage", "Damage"), ("reduction", "Reduction"), ("attack_speed", "AttackSpeed"), ("regen", "Regen"), ("splash", "Splash")]:
if key in item:
parts.append(f"{name} = {d(item[key])}")
if "min_level" in item:
parts.append(f"MinLevel = {int(item['min_level'])}")
rows.append(" new ItemSpec { " + ", ".join(parts) + " }")
out.append(",\n".join(rows))
out.append(" );\n")
# Champions
out.append(" public static readonly OrderedMap<string, ChampionSpec> Champions = Ordered(")
rows = []
known = {"name", "role", "model", "cost", "base", "text", "priority", "siege_factor", "view_scale"}
for champion_id, spec in data["champions"].items():
if set(spec) - known:
raise SystemExit(f"unknown champion keys {set(spec) - known}")
parts = [f"Id = {s(champion_id)}", f"Name = {s(spec['name'])}", f"RoleText = {s(spec['role'])}", f"Model = {s(spec['model'])}",
f"Cost = {int(spec['cost'])}", f"Base = {s(spec['base'])}", f"Text = {s(spec['text'])}", f"ViewScale = {d(spec['view_scale'])}"]
if "priority" in spec:
parts.append(f"Priority = {s(spec['priority'])}")
if "siege_factor" in spec:
parts.append(f"SiegeFactor = {d(spec['siege_factor'])}")
rows.append(" new ChampionSpec { " + ", ".join(parts) + " }")
out.append(",\n".join(rows))
out.append(" );\n")
# Roles, rows, names
out.append(" /// <summary>Base troop id to role; iteration order matters for RoleOf (first match).</summary>")
out.append(" public static readonly OrderedMap<string, string> MatchRoles = Pairs(" + ", ".join(f"{s(k)}, {s(v)}" for k, v in data["match_roles"].items()) + ");")
out.append(" public static readonly OrderedMap<string, string> HeroRoles = Pairs(" + ", ".join(f"{s(k)}, {s(v)}" for k, v in data["hero_roles"].items()) + ");")
out.append(" public static readonly string[] RowOrder = { " + ", ".join(s(v) for v in data["row_order"]) + " };")
out.append(" public static readonly string[] BotNames = { " + ", ".join(s(v) for v in data["bot_names"]) + " };")
out.append(" public static readonly string[] WavesMelee = { " + ", ".join(s(v) for v in data["waves_melee"]) + " };")
out.append(" public static readonly string[] ArmorNames = { " + ", ".join(s(v) for v in data["armor_names"]) + " };")
out.append(" public static readonly string[] DamageNames = { " + ", ".join(s(v) for v in data["damage_names"]) + " };")
out.append(" public static readonly OrderedMap<string, string> FactionNames = Pairs(" + ", ".join(f"{s(k)}, {s(v)}" for k, v in data["faction_names"].items()) + ");\n")
# Bot profiles
out.append(" /// <summary>Bot profiles in the reference order (the seed rotation depends on it).</summary>")
out.append(" public static readonly OrderedMap<string, BotProfile> BotProfiles = Ordered(")
rows = []
for name, p in data["bot_profiles"].items():
parts = [f"Name = {s(name)}", f"Front = {d(p['front'])}", f"Melee = {d(p['melee'])}", f"Ranged = {d(p['ranged'])}",
f"Splash = {d(p['splash'])}", f"Support = {d(p['support'])}", f"UpgradeFrom = {int(p['upgrade_from'])}",
f"SupportLane = {d(p['support_lane'])}", f"Hero = {s(p['hero'])}", f"HeroRound = {int(p['hero_round'])}",
"Items = new[] { " + ", ".join(s(i) for i in p["items"]) + " }",
"TalentChoices = new[] { " + ", ".join(str(int(t)) for t in p["talents"]) + " }", f"Champion = {s(p['champion'])}"]
if "boss_from" in p:
parts.append(f"BossFrom = {int(p['boss_from'])}")
parts.append(f"BossTroops = {int(p['boss_troops'])}")
rows.append(" new BotProfile { " + ", ".join(parts) + " }")
out.append(",\n".join(rows))
out.append(" );")
out.append(" }")
out.append("}")
OUT.parent.mkdir(parents=True, exist_ok=True)
OUT.write_text("\n".join(out) + "\n", encoding="utf-8")
print("written", OUT, sum(1 for _ in OUT.open(encoding="utf-8")), "lines")
if __name__ == "__main__":
main()