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>
This commit is contained in:
Martin Stickelbroeck 2026-09-30 14:31:18 +02:00
parent bd45defed8
commit 4d6ed17dca
55 changed files with 9570 additions and 0 deletions

View file

@ -0,0 +1,119 @@
// <auto-generated once> by tools/port/gen_content.py from the Godot reference (luneraria/g3, 1b540f2).
// Since the port this file is the maintained source of the rule tables; ContentTests compares it
// with the exported reference in Assets/Luneraria/Tests/Core/Golden/content.json.
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>Rule tables of Champions of Luneraria: troops, monsters, sends, heroes, items, champions, bots.</summary>
public static partial class ContentData
{
public static readonly OrderedMap<string, UnitDef> Units = Map(
new UnitDef { Id = "g01", DisplayName = "Kohlenbeißer", Cost = 45, UpgradeId = "g02", MaxHp = 260.0, Damage = 17.0, AttackType = 0, ArmorType = 0, AttackRange = 1.1, Speed = 2.4, Ability = "g01", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 3. Treffer: Brand, 4 Schaden/s für 3 s." },
new UnitDef { Id = "g02", DisplayName = "Schlackenklaue", Cost = 90, UpgradeId = "", MaxHp = 690.0, Damage = 44.0, AttackType = 0, ArmorType = 1, AttackRange = 1.2, Speed = 2.4, Ability = "g02", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 3. Treffer: Brand, 12 Schaden/s für 3 s." },
new UnitDef { Id = "g03", DisplayName = "Schlackenwacht", Cost = 70, UpgradeId = "g04", MaxHp = 650.0, Damage = 11.0, AttackType = 0, ArmorType = 1, AttackRange = 1.2, Speed = 2.4, Ability = "g03", LeakValue = 0, Bounty = 0, AbilityDescription = "Erste 8 s: 15 % weniger erlittener Schaden." },
new UnitDef { Id = "g04", DisplayName = "Basaltbastion", Cost = 150, UpgradeId = "", MaxHp = 1650.0, Damage = 27.0, AttackType = 0, ArmorType = 1, AttackRange = 1.4, Speed = 2.4, Ability = "g04", LeakValue = 0, Bounty = 0, AbilityDescription = "Erste 10 s: 25 % weniger Schaden, danach 10 %." },
new UnitDef { Id = "g05", DisplayName = "Funkenlanze", Cost = 65, UpgradeId = "g06", MaxHp = 240.0, Damage = 24.0, AttackType = 1, ArmorType = 0, AttackRange = 5.5, Speed = 2.0, Ability = "g05", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 4. Treffer: 50 % zusätzlicher Grundschaden." },
new UnitDef { Id = "g06", DisplayName = "Glasballiste", Cost = 140, UpgradeId = "", MaxHp = 640.0, Damage = 65.0, AttackType = 1, ArmorType = 0, AttackRange = 6.5, Speed = 2.0, Ability = "g06", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 4. Treffer: 75 % zusätzlicher Grundschaden." },
new UnitDef { Id = "g07", DisplayName = "Aschenwerfer", Cost = 95, UpgradeId = "g08", MaxHp = 310.0, Damage = 23.0, AttackType = 2, ArmorType = 0, AttackRange = 4.5, Speed = 2.0, Ability = "g07", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder Treffer: 30 % Flächenschaden an bis zu 3 weiteren Zielen in 1,5 m." },
new UnitDef { Id = "g08", DisplayName = "Kraterrufer", Cost = 190, UpgradeId = "", MaxHp = 820.0, Damage = 58.0, AttackType = 2, ArmorType = 1, AttackRange = 5.0, Speed = 2.0, Ability = "g08", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder Treffer: 40 % Flächenschaden an bis zu 4 weiteren Zielen in 2 m." },
new UnitDef { Id = "g09", DisplayName = "Herdhüter", Cost = 85, UpgradeId = "g10", MaxHp = 420.0, Damage = 12.0, AttackType = 2, ArmorType = 2, AttackRange = 3.5, Speed = 2.0, Ability = "g09", LeakValue = 0, Bounty = 0, AbilityDescription = "Alle 6 s: bis zu 3 Verbündete in 3 m erhalten für 4 s +10 % Grundangriffsschaden." },
new UnitDef { Id = "g10", DisplayName = "Sonnenesse", Cost = 170, UpgradeId = "", MaxHp = 1080.0, Damage = 30.0, AttackType = 2, ArmorType = 2, AttackRange = 4.0, Speed = 2.0, Ability = "g10", LeakValue = 0, Bounty = 0, AbilityDescription = "Alle 6 s: bis zu 4 Verbündete in 4 m erhalten für 4 s +15 % Grundangriffsschaden und 45 Heilung." },
new UnitDef { Id = "w01", DisplayName = "Dornhopser", Cost = 50, UpgradeId = "w02", MaxHp = 290.0, Damage = 18.0, AttackType = 0, ArmorType = 0, AttackRange = 1.1, Speed = 2.4, Ability = "w01", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 3. Treffer: −20 % Bewegung für 2 s." },
new UnitDef { Id = "w02", DisplayName = "Rankenschnapper", Cost = 100, UpgradeId = "", MaxHp = 800.0, Damage = 47.0, AttackType = 0, ArmorType = 1, AttackRange = 1.3, Speed = 2.4, Ability = "w02", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 3. Treffer: −30 % Bewegung und −10 % Angriffstempo für 3 s." },
new UnitDef { Id = "w03", DisplayName = "Korkrücken", Cost = 75, UpgradeId = "w04", MaxHp = 620.0, Damage = 12.0, AttackType = 1, ArmorType = 1, AttackRange = 1.2, Speed = 2.4, Ability = "w03", LeakValue = 0, Bounty = 0, AbilityDescription = "Heilt sich alle 5 s um 35 HP, sobald er verwundet ist." },
new UnitDef { Id = "w04", DisplayName = "Ringborke", Cost = 155, UpgradeId = "", MaxHp = 1560.0, Damage = 31.0, AttackType = 1, ArmorType = 1, AttackRange = 1.4, Speed = 2.4, Ability = "w04", LeakValue = 0, Bounty = 0, AbilityDescription = "Heilt sich alle 5 s um 100 HP, sobald er verwundet ist." },
new UnitDef { Id = "w05", DisplayName = "Samenschleuder", Cost = 60, UpgradeId = "w06", MaxHp = 230.0, Damage = 23.0, AttackType = 1, ArmorType = 0, AttackRange = 5.5, Speed = 2.0, Ability = "w05", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 4. Treffer: Keimbrand, 3 Schaden/s für 4 s." },
new UnitDef { Id = "w06", DisplayName = "Zapfenkrone", Cost = 135, UpgradeId = "", MaxHp = 630.0, Damage = 61.0, AttackType = 1, ArmorType = 0, AttackRange = 6.0, Speed = 2.0, Ability = "w06", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 4. Treffer: Keimbrand, 10 Schaden/s für 4 s." },
new UnitDef { Id = "w07", DisplayName = "Sporenkelch", Cost = 100, UpgradeId = "w08", MaxHp = 370.0, Damage = 22.0, AttackType = 2, ArmorType = 2, AttackRange = 4.0, Speed = 2.0, Ability = "w07", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 3. Treffer: 60 % Grundschaden an bis zu 3 weiteren Zielen in 2 m." },
new UnitDef { Id = "w08", DisplayName = "Pilzkathedrale", Cost = 195, UpgradeId = "", MaxHp = 940.0, Damage = 59.0, AttackType = 2, ArmorType = 2, AttackRange = 4.5, Speed = 2.0, Ability = "w08", LeakValue = 0, Bounty = 0, AbilityDescription = "Jeder 3. Treffer: 80 % Grundschaden an bis zu 4 weiteren Zielen in 2,5 m." },
new UnitDef { Id = "w09", DisplayName = "Tauhüter", Cost = 90, UpgradeId = "w10", MaxHp = 390.0, Damage = 10.0, AttackType = 2, ArmorType = 0, AttackRange = 4.0, Speed = 2.0, Ability = "w09", LeakValue = 0, Bounty = 0, AbilityDescription = "Alle 4 s: heilt den am stärksten verwundeten Verbündeten in 4 m um 65 HP." },
new UnitDef { Id = "w10", DisplayName = "Quellgeflecht", Cost = 180, UpgradeId = "", MaxHp = 1000.0, Damage = 27.0, AttackType = 2, ArmorType = 2, AttackRange = 4.5, Speed = 2.0, Ability = "w10", LeakValue = 0, Bounty = 0, AbilityDescription = "Alle 4 s: heilt bis zu 3 verwundete Verbündete in 4,5 m um je 80 HP." }
);
public static readonly OrderedMap<string, UnitDef> Enemies = Map(
new UnitDef { Id = "e01", DisplayName = "Splitterling", MaxHp = 90.0, Damage = 6.0, AttackType = 0, ArmorType = 0, AttackRange = 1.0, Speed = 3.0, Ability = "e01", LeakValue = 25, Bounty = 3 },
new UnitDef { Id = "e02", DisplayName = "Schalenläufer", MaxHp = 240.0, Damage = 9.0, AttackType = 1, ArmorType = 1, AttackRange = 1.2, Speed = 2.0, Ability = "e02", LeakValue = 55, Bounty = 6 },
new UnitDef { Id = "e03", DisplayName = "Nebelrest", MaxHp = 150.0, Damage = 11.0, AttackType = 2, ArmorType = 2, AttackRange = 3.5, Speed = 2.2, Ability = "e03", LeakValue = 40, Bounty = 5 },
new UnitDef { Id = "e04", DisplayName = "Fadenschütze", MaxHp = 130.0, Damage = 12.0, AttackType = 1, ArmorType = 0, AttackRange = 6.0, Speed = 2.4, Ability = "e04", LeakValue = 35, Bounty = 5 },
new UnitDef { Id = "e05", DisplayName = "Bruchstampfer", MaxHp = 480.0, Damage = 16.0, AttackType = 0, ArmorType = 1, AttackRange = 1.4, Speed = 1.6, Ability = "e05", LeakValue = 90, Bounty = 10 },
new UnitDef { Id = "e06", DisplayName = "Knotenrufer", MaxHp = 190.0, Damage = 7.0, AttackType = 2, ArmorType = 2, AttackRange = 4.0, Speed = 2.0, Ability = "e06", LeakValue = 45, Bounty = 6 },
new UnitDef { Id = "e07", DisplayName = "Bruchhirsch", MaxHp = 1100.0, Damage = 28.0, AttackType = 0, ArmorType = 0, AttackRange = 1.4, Speed = 1.8, Ability = "e07", LeakValue = 250, Bounty = 30, IsBoss = true },
new UnitDef { Id = "e08", DisplayName = "Riegelkoloss", MaxHp = 1400.0, Damage = 30.0, AttackType = 1, ArmorType = 1, AttackRange = 1.5, Speed = 1.5, Ability = "e08", LeakValue = 330, Bounty = 40, IsBoss = true },
new UnitDef { Id = "e09", DisplayName = "Maskenrichter", MaxHp = 1800.0, Damage = 34.0, AttackType = 2, ArmorType = 2, AttackRange = 5.0, Speed = 1.8, Ability = "e09", LeakValue = 400, Bounty = 50, IsBoss = true },
new UnitDef { Id = "e10", DisplayName = "Schlundkrone", MaxHp = 3000.0, Damage = 50.0, AttackType = 1, ArmorType = 1, AttackRange = 1.5, Speed = 1.3, Ability = "e10", LeakValue = 600, Bounty = 70, IsBoss = true }
);
public static readonly OrderedMap<string, UnitDef> Sends = Map(
new UnitDef { Id = "s01", DisplayName = "Nadelspäher", Cost = 20, MaxHp = 100.0, Damage = 8.0, AttackType = 1, ArmorType = 0, AttackRange = 4.5, Speed = 2.8, Ability = "s01", LeakValue = 30, Bounty = 3, IncomeGain = 3 },
new UnitDef { Id = "s02", DisplayName = "Rostschieber", Cost = 40, MaxHp = 240.0, Damage = 8.0, AttackType = 0, ArmorType = 1, AttackRange = 1.2, Speed = 1.8, Ability = "s02", LeakValue = 55, Bounty = 6, IncomeGain = 5 },
new UnitDef { Id = "s03", DisplayName = "Dunstsänger", Cost = 80, MaxHp = 320.0, Damage = 10.0, AttackType = 2, ArmorType = 2, AttackRange = 5.0, Speed = 2.0, Ability = "s03", LeakValue = 70, Bounty = 9, IncomeGain = 9 },
new UnitDef { Id = "s04", DisplayName = "Keilbrecher", Cost = 130, MaxHp = 650.0, Damage = 20.0, AttackType = 1, ArmorType = 1, AttackRange = 1.3, Speed = 1.6, Ability = "s04", LeakValue = 120, Bounty = 14, IncomeGain = 12 },
new UnitDef { Id = "s05", DisplayName = "Risszehrer", Cost = 200, MaxHp = 1000.0, Damage = 30.0, AttackType = 2, ArmorType = 2, AttackRange = 4.0, Speed = 1.7, Ability = "s05", LeakValue = 180, Bounty = 20, IncomeGain = 17 }
);
public static readonly OrderedMap<string, HeroSpec> Heroes = Ordered(
new HeroSpec { Id = "held_ringborke", Name = "Ringborke", Title = "Wächterin der Wurzeln", Model = "w04", Cost = 260, RoleText = "Front / Schutz", Provisional = true, Summary = "Hält die Front, heilt sich selbst und schützt nahe Verbündete.", Base = new UnitDef { MaxHp = 2200.0, Damage = 34.0, AttackType = 1, ArmorType = 1, AttackRange = 1.4, Speed = 2.4, Ability = "w04" } },
new HeroSpec { Id = "held_quellgeflecht", Name = "Quellgeflecht", Title = "Hüterin der Quelle", Model = "w10", Cost = 260, RoleText = "Unterstützung / Heilung", Provisional = true, Summary = "Heilt Verbündete in 4,5 m und stärkt die Armee im Gefecht.", Base = new UnitDef { MaxHp = 1300.0, Damage = 28.0, AttackType = 2, ArmorType = 2, AttackRange = 4.5, Speed = 2.0, Ability = "w10" } },
new HeroSpec { Id = "held_funkenlanze", Name = "Funkenlanze", Title = "Schützin des Glutpakts", Model = "g05", Cost = 260, RoleText = "Fernkampf / Schaden", Provisional = true, Summary = "Hoher Einzelschaden aus der zweiten Reihe.", Base = new UnitDef { MaxHp = 950.0, Damage = 46.0, AttackType = 1, ArmorType = 0, AttackRange = 6.0, Speed = 2.0, Ability = "g05" } }
);
public static readonly int[] LevelXp = { 0, 120, 280, 480, 720, 1000, 1320, 1680, 2080, 2520 };
public static readonly int[] TalentLevels = { 3, 6 };
/// <summary>Per hero: tiers of two mutually exclusive choices.</summary>
public static readonly Dictionary<string, TalentOption[][]> Talents = new Dictionary<string, TalentOption[][]>
{
{ "held_ringborke", new[]
{
new[] { new TalentOption("borkenhaut", "Borkenhaut", "Eigenschutz: +15 % Schadensminderung für die Heldin."), new TalentOption("schutzwurzeln", "Schutzwurzeln", "Gruppenschutz: alle 4 s erhalten bis zu 4 Verbündete in 4 m 10 % Schadensminderung für 4,5 s.") },
new[] { new TalentOption("stammschlag", "Stammschlag", "Einzelziel: jeder 3. Treffer +60 % Schaden."), new TalentOption("wurzelbeben", "Wurzelbeben", "Fläche: jeder 3. Treffer 50 % Schaden an bis zu 3 Gegner in 2 m.") }
} },
{ "held_quellgeflecht", new[]
{
new[] { new TalentOption("tiefe_quelle", "Tiefe Quelle", "Einzelziel: heilt den schwächsten Verbündeten um 190 statt drei um je 80."), new TalentOption("weite_quelle", "Weite Quelle", "Fläche: heilt bis zu 5 Verbündete in 5,5 m um je 65.") },
new[] { new TalentOption("quellschild", "Quellschild", "Schutz: Geheilte erhalten 4 s lang einen Schild von 70."), new TalentOption("keimsaat", "Keimsaat", "Druck: jeder 2. Treffer setzt Keimbrand, 9 Schaden/s für 4 s.") }
} },
{ "held_funkenlanze", new[]
{
new[] { new TalentOption("durchschlag", "Durchschlag", "Einzelziel: jeder 4. Treffer ×2,1 statt ×1,5."), new TalentOption("splitterschuss", "Splitterschuss", "Fläche: jeder 4. Treffer zusätzlich 45 % an bis zu 3 Gegner in 2 m.") },
new[] { new TalentOption("fernsicht", "Fernsicht", "+1,5 m Reichweite."), new TalentOption("glutsalve", "Glutsalve", "+20 % Angriffstempo.") }
} }
};
public static readonly OrderedMap<string, ItemSpec> Items = Ordered(
new ItemSpec { Id = "basaltschild", Name = "Basaltschild", Cost = 180, Text = "+350 Lebenspunkte", HpFlat = 350.0 },
new ItemSpec { Id = "glutklinge", Name = "Glutklinge", Cost = 180, Text = "+18 % Schaden", Damage = 0.18 },
new ItemSpec { Id = "rankenmantel", Name = "Rankenmantel", Cost = 200, Text = "+10 % Schadensminderung", Reduction = 0.1 },
new ItemSpec { Id = "funkenfokus", Name = "Funkenfokus", Cost = 200, Text = "+15 % Angriffstempo", AttackSpeed = 0.15 },
new ItemSpec { Id = "quellamulett", Name = "Quellamulett", Cost = 220, Text = "Regeneriert 1,5 % der Lebenspunkte pro Sekunde", Regen = 0.015 },
new ItemSpec { Id = "kristallsplitter", Name = "Kristallsplitter", Cost = 260, Text = "Jeder Treffer: 25 % an bis zu 2 Gegner in 1,5 m · ab Stufe 3", Splash = 0.25, MinLevel = 3 }
);
public static readonly OrderedMap<string, ChampionSpec> Champions = Ordered(
new ChampionSpec { Id = "ch_schutz", Name = "Rostschieber", RoleText = "Schutz", Model = "s02", Cost = 150, Base = "s02", Text = "Schildträger: startet mit Schild, gibt alle 6 s bis zu 4 Verbündeten in 4 m einen Schild von 120.", ViewScale = 1.25 },
new ChampionSpec { Id = "ch_druck", Name = "Dunstsänger", RoleText = "Druck auf Unterstützer", Model = "s03", Cost = 150, Base = "s03", Text = "Sucht Unterstützer und Fernkämpfer und verlangsamt deren Angriffe.", ViewScale = 1.25, Priority = "support" },
new ChampionSpec { Id = "ch_belagerung", Name = "Keilbrecher", RoleText = "Belagerung", Model = "s04", Cost = 180, Base = "s04", Text = "Greift bevorzugt Gebäude an und verursacht dort ×1,8 Schaden.", ViewScale = 1.3, Priority = "building", SiegeFactor = 1.8 }
);
/// <summary>Base troop id to role; iteration order matters for RoleOf (first match).</summary>
public static readonly OrderedMap<string, string> MatchRoles = Pairs("g01", "melee", "g03", "front", "g05", "ranged", "g07", "splash", "g09", "support", "w01", "melee", "w03", "front", "w05", "ranged", "w07", "splash", "w09", "support");
public static readonly OrderedMap<string, string> HeroRoles = Pairs("held_ringborke", "front", "held_quellgeflecht", "support", "held_funkenlanze", "ranged");
public static readonly string[] RowOrder = { "front", "melee", "splash", "ranged", "support" };
public static readonly string[] BotNames = { "Eichrufer", "Aschenzunge", "Quellwächter", "Glutfaust", "Moosauge", "Basaltherz", "Rindenkrone", "Funkenschritt" };
public static readonly string[] WavesMelee = { "e01", "e02", "e05" };
public static readonly string[] ArmorNames = { "Leicht", "Schwer", "Ätherisch" };
public static readonly string[] DamageNames = { "Klinge", "Durchschlag", "Impuls" };
public static readonly OrderedMap<string, string> FactionNames = Pairs("glutpakt", "Glutpakt", "wurzelbund", "Wurzelbund");
/// <summary>Bot profiles in the reference order (the seed rotation depends on it).</summary>
public static readonly OrderedMap<string, BotProfile> BotProfiles = Ordered(
new BotProfile { Name = "ausgewogen", Front = 0.3, Melee = 0.25, Ranged = 0.25, Splash = 0.1, Support = 0.1, UpgradeFrom = 6, SupportLane = 0.0, Hero = "held_ringborke", HeroRound = 4, Items = new[] { "basaltschild", "glutklinge", "rankenmantel" }, TalentChoices = new[] { 0, 1 }, Champion = "" },
new BotProfile { Name = "verteidigung", Front = 0.35, Melee = 0.2, Ranged = 0.25, Splash = 0.1, Support = 0.1, UpgradeFrom = 7, SupportLane = 0.15, Hero = "held_ringborke", HeroRound = 3, Items = new[] { "basaltschild", "rankenmantel", "glutklinge" }, TalentChoices = new[] { 1, 1 }, Champion = "ch_schutz" },
new BotProfile { Name = "druck", Front = 0.2, Melee = 0.35, Ranged = 0.25, Splash = 0.15, Support = 0.05, UpgradeFrom = 8, SupportLane = 0.0, Hero = "held_funkenlanze", HeroRound = 3, Items = new[] { "glutklinge", "funkenfokus", "kristallsplitter" }, TalentChoices = new[] { 1, 1 }, Champion = "ch_belagerung" },
new BotProfile { Name = "fernkampf", Front = 0.25, Melee = 0.1, Ranged = 0.4, Splash = 0.15, Support = 0.1, UpgradeFrom = 6, SupportLane = 0.0, Hero = "held_funkenlanze", HeroRound = 4, Items = new[] { "funkenfokus", "glutklinge", "kristallsplitter" }, TalentChoices = new[] { 0, 0 }, Champion = "ch_druck" },
new BotProfile { Name = "teamhilfe", Front = 0.3, Melee = 0.2, Ranged = 0.25, Splash = 0.1, Support = 0.15, UpgradeFrom = 6, SupportLane = 0.22, Hero = "held_quellgeflecht", HeroRound = 4, Items = new[] { "quellamulett", "rankenmantel", "basaltschild" }, TalentChoices = new[] { 1, 0 }, Champion = "ch_schutz" },
new BotProfile { Name = "bossjagd", Front = 0.35, Melee = 0.25, Ranged = 0.2, Splash = 0.1, Support = 0.1, UpgradeFrom = 6, SupportLane = 0.1, Hero = "held_ringborke", HeroRound = 3, Items = new[] { "glutklinge", "basaltschild", "rankenmantel" }, TalentChoices = new[] { 1, 1 }, Champion = "ch_belagerung", BossFrom = 5, BossTroops = 5 }
);
}
}

View file

@ -0,0 +1,47 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>Lookups into the unit tables; every call returns an independent copy.</summary>
public static class DataCatalog
{
public static readonly string[] Factions = { "glutpakt", "wurzelbund" };
/// <summary>Troop definition (copy) or null for unknown ids.</summary>
public static UnitDef GetUnit(string id)
{
UnitDef entry;
return id != null && ContentData.Units.TryGetValue(id, out entry) ? entry.Clone() : null;
}
public static UnitDef GetEnemy(string id)
{
return ContentData.Enemies[id].Clone();
}
public static UnitDef GetSend(string id)
{
return ContentData.Sends[id].Clone();
}
/// <summary>Upgrade target of a troop type, "" when fully upgraded or unknown.</summary>
public static string UpgradeOf(string id)
{
UnitDef entry;
return id != null && ContentData.Units.TryGetValue(id, out entry) ? entry.UpgradeId : "";
}
public static int CostOf(string id)
{
UnitDef entry;
return id != null && ContentData.Units.TryGetValue(id, out entry) ? entry.Cost : 0;
}
/// <summary>The five base troops of a Bund, in shop order.</summary>
public static List<string> FactionIds(string faction)
{
string prefix = faction == "wurzelbund" ? "w" : "g";
return new List<string> { prefix + "01", prefix + "03", prefix + "05", prefix + "07", prefix + "09" };
}
}
}

View file

@ -0,0 +1,200 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Combat definition of a unit type: troop, monster, send, hero, champion, boss or building.
/// One type for all, as in the reference, where definitions were dictionaries; the defaults
/// are the fallbacks the rules used for missing keys (e.g. SiegeFactor 1, HpFactor 1).
/// </summary>
public sealed class UnitDef
{
public string Id = "";
public string DisplayName = "";
public int Cost;
public string UpgradeId = "";
public double MaxHp = 1.0;
public double Damage;
/// <summary>0 Klinge, 1 Durchschlag, 2 Impuls.</summary>
public int AttackType;
/// <summary>0 Leicht, 1 Schwer, 2 Ätherisch.</summary>
public int ArmorType;
public double AttackRange = 1.0;
public double Speed = 2.0;
public string Ability = "";
public int LeakValue;
public int Bounty;
public string AbilityDescription = "";
public bool IsBoss;
public int IncomeGain;
// Set by the match for army units and heroes.
public string Role = "";
public string HeroId = "";
public int Level = 1;
public double ViewScale = 1.0;
public double Regen;
public double ItemSplash;
public List<string> Talents = new List<string>();
public double DamageBonus;
public double ReductionBonus;
public double AttackSpeedBonus;
// Attack champions.
public string ChampionId = "";
public string Priority = "";
public double SiegeFactor = 1.0;
// Monsters: wave strength factor (heals of e06 scale with it).
public double HpFactor = 1.0;
// Buildings: outer, inner, base or core.
public string Tier = "";
public UnitDef Clone()
{
var copy = (UnitDef)MemberwiseClone();
copy.Talents = new List<string>(Talents);
return copy;
}
}
public sealed class HeroSpec
{
public string Id = "";
public string Name = "";
public string Title = "";
public string Model = "";
public int Cost;
public string RoleText = "";
public bool Provisional;
public string Summary = "";
/// <summary>Level-1 values without items and talents.</summary>
public UnitDef Base = new UnitDef();
}
public sealed class TalentOption
{
public readonly string Id;
public readonly string Name;
public readonly string Text;
public TalentOption(string id, string name, string text)
{
Id = id;
Name = name;
Text = text;
}
}
public sealed class ItemSpec
{
public string Id = "";
public string Name = "";
public int Cost;
public string Text = "";
public double HpFlat;
public double Damage;
public double Reduction;
public double AttackSpeed;
public double Regen;
public double Splash;
public int MinLevel = 1;
}
public sealed class ChampionSpec
{
public string Id = "";
public string Name = "";
public string RoleText = "";
public string Model = "";
public int Cost;
/// <summary>Send type whose base values the champion scales (s02, s03, s04).</summary>
public string Base = "";
public string Text = "";
public string Priority = "";
public double SiegeFactor = 1.0;
public double ViewScale = 1.0;
}
public sealed class BotProfile
{
public string Name = "";
public double Front;
public double Melee;
public double Ranged;
public double Splash;
public double Support;
public int UpgradeFrom;
public double SupportLane;
public string Hero = "";
public int HeroRound;
public string[] Items = new string[0];
public int[] TalentChoices = new int[0];
public string Champion = "";
/// <summary>Boss hunters only; 0 = never goes to the grotto.</summary>
public int BossFrom;
public int BossTroops;
public bool HuntsBoss { get { return BossFrom > 0; } }
public double Share(string role)
{
switch (role)
{
case "front": return Front;
case "melee": return Melee;
case "ranged": return Ranged;
case "splash": return Splash;
default: return Support;
}
}
}
public static partial class ContentData
{
static OrderedMap<string, UnitDef> Map(params UnitDef[] entries)
{
var map = new OrderedMap<string, UnitDef>();
foreach (UnitDef entry in entries)
map[entry.Id] = entry;
return map;
}
static OrderedMap<string, HeroSpec> Ordered(params HeroSpec[] entries)
{
var map = new OrderedMap<string, HeroSpec>();
foreach (HeroSpec entry in entries)
map[entry.Id] = entry;
return map;
}
static OrderedMap<string, ItemSpec> Ordered(params ItemSpec[] entries)
{
var map = new OrderedMap<string, ItemSpec>();
foreach (ItemSpec entry in entries)
map[entry.Id] = entry;
return map;
}
static OrderedMap<string, ChampionSpec> Ordered(params ChampionSpec[] entries)
{
var map = new OrderedMap<string, ChampionSpec>();
foreach (ChampionSpec entry in entries)
map[entry.Id] = entry;
return map;
}
static OrderedMap<string, BotProfile> Ordered(params BotProfile[] entries)
{
var map = new OrderedMap<string, BotProfile>();
foreach (BotProfile entry in entries)
map[entry.Name] = entry;
return map;
}
static OrderedMap<string, string> Pairs(params string[] keysAndValues)
{
var map = new OrderedMap<string, string>();
for (int i = 0; i + 1 < keysAndValues.Length; i += 2)
map[keysAndValues[i]] = keysAndValues[i + 1];
return map;
}
}
}

View file

@ -0,0 +1,70 @@
namespace Luneraria.Core
{
/// <summary>
/// Every tunable number of Champions of Luneraria (reference: lune_balance.gd with
/// data/luneraria/balance_default.tres). Tests may use variants; bots never get extras.
/// </summary>
public sealed class LuneBalance
{
// Simulation
/// <summary>Fixed combat step in seconds (20 steps per second of combat time).</summary>
public double SimStep = 0.05;
/// <summary>Ends only the current combat phase, never the match.</summary>
public double CombatTimeLimit = 180.0;
public double MarchSpeedFactor = 1.6;
public double AggroRadius = 7.0;
/// <summary>Targets further away than this are dropped and re-acquired.</summary>
public double LeashRadius = 10.0;
public double LaneHalfWidth = 2.6;
// Economy
public int StartGold = 220;
public int BaseIncome = 70;
public int IncomePerRound = 4;
public int IncomeGrowthCap = 80;
public int SupplyCap = 12;
public double SellRefund = 0.75;
/// <summary>Share of lane kill gold for the lane player; the rest splits evenly in the team.</summary>
public double LaneShare = 0.5;
// Waves
public int WaveBaseCount = 3;
/// <summary>One more monster per lane every N rounds, up to WaveMaxCount.</summary>
public int WaveCountEvery = 3;
public int WaveMaxCount = 7;
public double WaveHpGrowth = 0.11;
public double WaveDamageGrowth = 0.07;
public double WaveGrowthQuadratic = 0.0015;
public double MonsterBountyScale = 2.0;
public double BountyGrowth = 0.04;
/// <summary>Monster types in rotation order; later indices unlock later.</summary>
public string[] MonsterPool = { "e01", "e02", "e04", "e03", "e06", "e05" };
// Buildings
public double TowerOuterHp = 6000.0;
public double TowerInnerHp = 7500.0;
public double BaseTowerHp = 9000.0;
public double CoreHp = 12000.0;
public double TowerDamage = 90.0;
public double BaseTowerDamage = 120.0;
/// <summary>Must exceed the longest unit reach (6.5 m + building radius).</summary>
public double TowerRange = 8.6;
public double TowerAttackInterval = 1.0;
public double BuildingRadius = 1.3;
public int BountyOuterTower = 150;
public int BountyInnerTower = 210;
public int BountyBaseTower = 270;
// Map
public double InnerTowerFraction = 0.17;
public double OuterTowerFraction = 0.35;
public double TowerSideOffset = 3.4;
public static LuneBalance Default() { return new LuneBalance(); }
public int RoundIncome(int roundNumber)
{
return BaseIncome + GdMath.Min(IncomeGrowthCap, IncomePerRound * GdMath.Max(0, roundNumber - 1));
}
}
}

View file

@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Heroes, talents, items, attack champions and the boss (reference: lune_content.gd).
/// Hero and champion roles are provisional assignments of existing models.
/// </summary>
public static class LuneContent
{
public const double HpPerLevel = 0.09;
public const double DamagePerLevel = 0.07;
public const int XpParticipation = 40;
public const int XpPerRound = 6;
public const double XpRadius = 12.0;
public const int ItemSlots = 3;
public const double ItemRefund = 0.5;
public const double ChampionHp = 2.2;
public const double ChampionDamage = 1.6;
public const int ChampionBounty = 70;
public const int ChampionBountyPerRound = 6;
public const int MaxBookings = 2;
public const string BossModel = "e10";
public const double BossGrottoHp = 3600.0;
public const double BossGrottoDamage = 55.0;
public const double BossDeployHp = 6800.0;
public const double BossDeployDamage = 88.0;
public const int BossBounty = 520;
public const int BossBountyPerRound = 60;
/// <summary>Victory in round n: deployment in n+1, locked in n+1..n+3, open again from n+4.</summary>
public const int BossLockRounds = 3;
public const int BossXp = 220;
public const int BuildingXp = 60;
public static OrderedMap<string, HeroSpec> Heroes { get { return ContentData.Heroes; } }
public static OrderedMap<string, ItemSpec> Items { get { return ContentData.Items; } }
public static OrderedMap<string, ChampionSpec> Champions { get { return ContentData.Champions; } }
public static int[] LevelXp { get { return ContentData.LevelXp; } }
public static int[] TalentLevels { get { return ContentData.TalentLevels; } }
public static TalentOption[][] TalentsOf(string heroId)
{
return ContentData.Talents[heroId];
}
public static int LevelFor(int xp)
{
int level = 1;
for (int i = 0; i < ContentData.LevelXp.Length; i++)
{
if (xp >= ContentData.LevelXp[i])
level = i + 1;
}
return level;
}
/// <summary>Combat definition of a hero with its level, items and talents.</summary>
public static UnitDef HeroDefinition(HeroState hero)
{
HeroSpec spec = ContentData.Heroes[hero.HeroId];
UnitDef definition = spec.Base.Clone();
int level = LevelFor(hero.Xp);
definition.Id = spec.Model;
definition.HeroId = hero.HeroId;
definition.DisplayName = spec.Name;
definition.MaxHp = definition.MaxHp * (1.0 + HpPerLevel * (double)(level - 1));
definition.Damage = definition.Damage * (1.0 + DamagePerLevel * (double)(level - 1));
definition.Level = level;
definition.ViewScale = 1.25;
double damageBonus = 0.0;
double reduction = 0.0;
double attackSpeed = 0.0;
foreach (string itemId in hero.Items)
{
ItemSpec item = ContentData.Items[itemId];
definition.MaxHp = definition.MaxHp + item.HpFlat;
damageBonus += item.Damage;
reduction += item.Reduction;
attackSpeed += item.AttackSpeed;
definition.Regen = definition.Regen + item.Regen;
definition.ItemSplash = definition.ItemSplash + item.Splash;
}
if (hero.Talents.Contains("borkenhaut"))
reduction += 0.15;
if (hero.Talents.Contains("fernsicht"))
definition.AttackRange = definition.AttackRange + 1.5;
if (hero.Talents.Contains("glutsalve"))
attackSpeed += 0.20;
definition.Talents = new List<string>(hero.Talents);
definition.DamageBonus = damageBonus;
definition.ReductionBonus = reduction;
definition.AttackSpeedBonus = GdMath.Min(0.5, attackSpeed);
return definition;
}
/// <summary>Attack champion of a round: a send type scaled like the waves of that round.</summary>
public static UnitDef ChampionDefinition(string championId, int roundNumber, LuneBalance balance)
{
ChampionSpec spec = ContentData.Champions[championId];
UnitDef definition = DataCatalog.GetSend(spec.Base);
definition.Id = spec.Model;
definition.ChampionId = championId;
definition.DisplayName = spec.Name;
definition.MaxHp = definition.MaxHp * ChampionHp * LuneWaves.Strength(balance, roundNumber, balance.WaveHpGrowth);
definition.Damage = definition.Damage * ChampionDamage * LuneWaves.Strength(balance, roundNumber, balance.WaveDamageGrowth);
definition.Bounty = ChampionBounty + ChampionBountyPerRound * roundNumber;
definition.Priority = spec.Priority;
definition.SiegeFactor = spec.SiegeFactor;
definition.ViewScale = spec.ViewScale;
if (championId == "ch_schutz")
definition.Ability = "ch_schutz";
return definition;
}
/// <summary>The Schlundkrone: resting in the grotto, or deployed on a lane (with bounty).</summary>
public static UnitDef BossDefinition(int roundNumber, LuneBalance balance, bool deployed)
{
UnitDef definition = DataCatalog.GetEnemy(BossModel);
definition.DisplayName = "Schlundkrone";
definition.MaxHp = (deployed ? BossDeployHp : BossGrottoHp) * LuneWaves.Strength(balance, roundNumber, balance.WaveHpGrowth);
definition.Damage = (deployed ? BossDeployDamage : BossGrottoDamage) * LuneWaves.Strength(balance, roundNumber, balance.WaveDamageGrowth);
definition.Bounty = deployed ? BossBounty + BossBountyPerRound * roundNumber : 0;
definition.IsBoss = true;
definition.ViewScale = 1.7;
definition.Speed = 1.6;
return definition;
}
public static int BossBountyFor(int roundNumber)
{
return BossBounty + BossBountyPerRound * roundNumber;
}
}
/// <summary>
/// Endless monster waves (reference: lune_waves.gd). Both teams get the identical composition
/// per lane. The count is capped; strength grows through stats and later monster types.
/// </summary>
public static class LuneWaves
{
public static double Strength(LuneBalance balance, int roundNumber, double growth)
{
double r = (double)(GdMath.Max(1, roundNumber) - 1);
return 1.0 + growth * r + balance.WaveGrowthQuadratic * r * r;
}
public static int MonsterCount(LuneBalance balance, int roundNumber)
{
int extra = (GdMath.Max(1, roundNumber) - 1) / GdMath.Max(1, balance.WaveCountEvery);
return GdMath.Min(balance.WaveMaxCount, balance.WaveBaseCount + extra);
}
public static List<string> UnlockedTypes(LuneBalance balance, int roundNumber)
{
string[] pool = balance.MonsterPool;
int available = GdMath.Clamp(1 + (GdMath.Max(1, roundNumber) - 1) / 2, 1, pool.Length);
var result = new List<string>();
for (int i = 0; i < available; i++)
result.Add(pool[i]);
return result;
}
/// <summary>Monster definitions for one lane of one team, melee first so they lead.</summary>
public static List<UnitDef> Build(LuneBalance balance, int roundNumber)
{
List<string> types = UnlockedTypes(balance, roundNumber);
double hpFactor = Strength(balance, roundNumber, balance.WaveHpGrowth);
double damageFactor = Strength(balance, roundNumber, balance.WaveDamageGrowth);
double bountyFactor = balance.MonsterBountyScale * (1.0 + balance.BountyGrowth * (double)(GdMath.Max(1, roundNumber) - 1));
var melee = new List<UnitDef>();
var ranged = new List<UnitDef>();
int count = MonsterCount(balance, roundNumber);
for (int index = 0; index < count; index++)
{
string typeId = types[(index + roundNumber) % types.Count];
UnitDef source = ContentData.Enemies[typeId];
UnitDef monster = source.Clone();
monster.MaxHp = source.MaxHp * hpFactor;
monster.Damage = source.Damage * damageFactor;
monster.HpFactor = hpFactor;
double bounty = GdMath.Clamp(GdMath.Round((double)source.Bounty * bountyFactor), 1.0, 1.0e12);
// The reference stores a 64-bit int; bounties stay far below int range in any real match.
monster.Bounty = bounty >= int.MaxValue ? int.MaxValue : (int)bounty;
if (Array.IndexOf(ContentData.WavesMelee, typeId) >= 0)
melee.Add(monster);
else
ranged.Add(monster);
}
melee.AddRange(ranged);
return melee;
}
}
}

View file

@ -0,0 +1,104 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>How a hero power is triggered.</summary>
public enum PowerTrigger
{
/// <summary>Automatic in combat (like the existing abilities).</summary>
Automatic,
/// <summary>Released by the player through the command menu during combat.</summary>
Command,
}
/// <summary>
/// Description of an ability or ultimate. Numbers are balance parameters; null means "not
/// decided yet". Nothing here is simulated until a hero is activated in a hero phase.
/// </summary>
public sealed class HeroPower
{
public string Id = "";
public string Name = "";
public string Text = "";
public PowerTrigger Trigger = PowerTrigger.Automatic;
/// <summary>Effect family in the rules vocabulary (slow, splash, damage buff, dot, ...).</summary>
public string Effect = "";
public double? Radius;
public double? Duration;
public double? Amount;
public int? MaxTargets;
public double? Cooldown;
/// <summary>Stun length for powers that stun (a new rule, see docs/port/U1-RULES.md).</summary>
public double? Stun;
/// <summary>Open design questions of this power.</summary>
public string Note = "";
}
/// <summary>A planned hero from the owner's hero concept (docs/luneraria/HELDEN-KONZEPT.md in the reference).</summary>
public sealed class PlannedHero
{
public string Id = "";
public string Name = "";
public string People = "";
public string Role = "";
/// <summary>Row in the formation, as for troops: front, melee, ranged, splash or support.</summary>
public string FormationRole = "";
public string Weapon = "";
public string Look = "";
public HeroPower Ability = new HeroPower();
public HeroPower Ultimate = new HeroPower();
public string Goal = "";
/// <summary>False until a hero phase implements and balances the kit; not purchasable before.</summary>
public bool Active;
}
/// <summary>
/// The four heroes the owner defined for Champions of Luneraria (Lucy, Ghaliga, Siegfried,
/// Feena). Part of the data model so HUD and command menu can be designed against them;
/// the match still offers only the three reference heroes (ContentData.Heroes), so the port
/// keeps behavioural parity. Ultimates are released through the command menu; the rest of
/// the combat stays automatic.
/// </summary>
public static class PlannedHeroes
{
public static readonly IReadOnlyList<PlannedHero> All = new List<PlannedHero>
{
new PlannedHero
{
Id = "lucy", Name = "Lucy", People = "Dunkelelfe", Role = "Kontrolle / Fernkampf", FormationRole = "ranged", Weapon = "Bogen",
Look = "Lange weiße Haare, grüne Augen, dunkle Haut, dunkle Lederrüstung mit dunkelblauen Akzenten, frostige Nachtästhetik.",
Ability = new HeroPower { Id = "frostpfeile", Name = "Frostpfeile", Text = "Treffer verlangsamen Gegner.", Effect = "movement_slow" },
Ultimate = new HeroPower { Id = "multischuss", Name = "Multischuss", Text = "Mehrere Pfeile auf mehrere Ziele; Kontrolle und Flächendruck statt rohem Burst.",
Trigger = PowerTrigger.Command, Effect = "multi_target_volley" },
Goal = "Gefährliche Gegnerwellen verlangsamen und Formationen stören.",
},
new PlannedHero
{
Id = "ghaliga", Name = "Ghaliga", People = "Zwerg", Role = "Frontkämpfer / Bruiser", FormationRole = "front", Weapon = "Axt",
Look = "Rote Haare, langer Bart, blaue Augen, goldene Zwergenrüstung, schwere robuste Silhouette.",
Ability = new HeroPower { Id = "wirbelhieb", Name = "Wirbelhieb", Text = "Flächenangriff um ihn herum.", Effect = "splash_around_self" },
Ultimate = new HeroPower { Id = "versteinerung", Name = "Versteinerung", Text = "Absorbiert massiv Schaden und wird extrem zäh.",
Trigger = PowerTrigger.Command, Effect = "shield_and_reduction", Note = "Bindungs-/Aggroeffekt optional: wäre eine neue Zielwahlregel." },
Goal = "Front halten, Gegner binden, Wellen abfangen.",
},
new PlannedHero
{
Id = "siegfried", Name = "Siegfried", People = "Mensch", Role = "Anführer / Frontkämpfer / Support", FormationRole = "front", Weapon = "Schwert (gold-blau)",
Look = "Kurze blonde Haare, blaue Augen, goldene Paladinrüstung.",
Ability = new HeroPower { Id = "schlachtruf", Name = "Schlachtruf", Text = "Stärkt nahe Verbündete.", Effect = "damage" },
Ultimate = new HeroPower { Id = "excalibur", Name = "Excalibur", Text = "Großer kritischer Flächenangriff mit kurzer Betäubung; trifft nicht die ganze Karte.",
Trigger = PowerTrigger.Command, Effect = "area_strike_stun", Note = "Wirkbereich (Radius) und Betäubungsdauer sind Balanceparameter; Betäubung ist eine neue Regel." },
Goal = "Verbündete stärken und kritische Kämpfe drehen.",
},
new PlannedHero
{
Id = "feena", Name = "Feena", People = "Waldelfe", Role = "Fernkampf-DPS / Boss- und Elitejägerin", FormationRole = "ranged", Weapon = "Bogen",
Look = "Lange rote Haare, grüne Augen, grün-braune Lederrüstung, flinke naturverbundene Schützin.",
Ability = new HeroPower { Id = "giftpfeile", Name = "Giftpfeile", Text = "Schaden über Zeit.", Effect = "dot" },
Ultimate = new HeroPower { Id = "turbopfeile", Name = "Turbopfeile", Text = "Stark erhöhtes Angriffstempo für kurze Zeit.",
Trigger = PowerTrigger.Command, Effect = "attack_speed", Note = "Das Angriffstempo ist heute auf +50 % gedeckelt; das Ultimate braucht eine Ausnahme oder einen höheren Deckel." },
Goal = "Hoher anhaltender Fernkampfschaden, besonders gegen starke Einzelziele.",
},
};
}
}

View file

@ -0,0 +1,14 @@
{
"name": "Luneraria.Core",
"rootNamespace": "Luneraria.Core",
"references": [],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": true
}

View file

@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Scalar helpers with the exact semantics of the GDScript built-ins the rules used
/// (clampf, maxf, minf, roundf, floori, ceili, is_equal_approx). Doubles throughout.
/// </summary>
public static class GdMath
{
public const double CmpEpsilon = 0.00001;
public static double Clamp(double value, double low, double high)
{
return value < low ? low : (value > high ? high : value);
}
public static int Clamp(int value, int low, int high)
{
return value < low ? low : (value > high ? high : value);
}
/// <summary>Godot's MAX macro: (a &gt; b) ? a : b.</summary>
public static double Max(double a, double b)
{
return a > b ? a : b;
}
/// <summary>Godot's MIN macro: (a &lt; b) ? a : b.</summary>
public static double Min(double a, double b)
{
return a < b ? a : b;
}
public static int Max(int a, int b) { return a > b ? a : b; }
public static int Min(int a, int b) { return a < b ? a : b; }
public static int FloorI(double value) { return (int)Math.Floor(value); }
public static int CeilI(double value) { return (int)Math.Ceiling(value); }
/// <summary>roundf: half away from zero (C round), not banker's rounding.</summary>
public static double Round(double value)
{
return Math.Round(value, MidpointRounding.AwayFromZero);
}
public static bool IsEqualApprox(double a, double b)
{
if (a == b)
return true;
double tolerance = CmpEpsilon * Math.Abs(a);
if (tolerance < CmpEpsilon)
tolerance = CmpEpsilon;
return Math.Abs(a - b) < tolerance;
}
/// <summary>GDScript atan2(y, x) on doubles.</summary>
public static double Atan2(double y, double x) { return Math.Atan2(y, x); }
/// <summary>
/// Stable insertion sort with a "less than" predicate. Every comparator of the rules is
/// a strict order on distinct unit ids or sequence numbers, so the result is unique;
/// stability keeps it deterministic even for near ties.
/// </summary>
public static void SortStable<T>(List<T> list, Func<T, T, bool> less)
{
for (int i = 1; i < list.Count; i++)
{
T item = list[i];
int j = i - 1;
while (j >= 0 && less(item, list[j]))
{
list[j + 1] = list[j];
j--;
}
list[j + 1] = item;
}
}
}
}

View file

@ -0,0 +1,97 @@
using System;
using System.Collections;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Map with a defined iteration order: insertion order; removing a key keeps the order of
/// the others and a key added again goes to the end. The rules iterate buildings, effects
/// and damage-over-time entries; with this map that order never depends on hashing or on
/// the history of a Dictionary (docs/port/U1-RULES.md, determinism).
/// </summary>
public sealed class OrderedMap<TKey, TValue> : IEnumerable<KeyValuePair<TKey, TValue>>
{
readonly Dictionary<TKey, int> _index = new Dictionary<TKey, int>();
readonly List<TKey> _keys = new List<TKey>();
readonly List<TValue> _values = new List<TValue>();
public int Count { get { return _keys.Count; } }
public IReadOnlyList<TKey> Keys { get { return _keys; } }
public IReadOnlyList<TValue> Values { get { return _values; } }
public bool ContainsKey(TKey key) { return _index.ContainsKey(key); }
public TValue this[TKey key]
{
get { return _values[_index[key]]; }
set
{
int at;
if (_index.TryGetValue(key, out at))
{
_values[at] = value;
return;
}
_index[key] = _keys.Count;
_keys.Add(key);
_values.Add(value);
}
}
public bool TryGetValue(TKey key, out TValue value)
{
int at;
if (_index.TryGetValue(key, out at))
{
value = _values[at];
return true;
}
value = default(TValue);
return false;
}
public TValue GetValueOrDefault(TKey key, TValue fallback)
{
int at;
return _index.TryGetValue(key, out at) ? _values[at] : fallback;
}
public bool Remove(TKey key)
{
int at;
if (!_index.TryGetValue(key, out at))
return false;
_keys.RemoveAt(at);
_values.RemoveAt(at);
_index.Remove(key);
for (int i = at; i < _keys.Count; i++)
_index[_keys[i]] = i;
return true;
}
public void Clear()
{
_index.Clear();
_keys.Clear();
_values.Clear();
}
/// <summary>Copy in the same order; values are copied with the given function.</summary>
public OrderedMap<TKey, TValue> Clone(Func<TValue, TValue> copy = null)
{
var result = new OrderedMap<TKey, TValue>();
for (int i = 0; i < _keys.Count; i++)
result[_keys[i]] = copy != null ? copy(_values[i]) : _values[i];
return result;
}
public IEnumerator<KeyValuePair<TKey, TValue>> GetEnumerator()
{
for (int i = 0; i < _keys.Count; i++)
yield return new KeyValuePair<TKey, TValue>(_keys[i], _values[i]);
}
IEnumerator IEnumerable.GetEnumerator() { return GetEnumerator(); }
}
}

View file

@ -0,0 +1,143 @@
using System;
namespace Luneraria.Core
{
/// <summary>
/// Ground vector (x, z) with 32-bit components and plain single-precision arithmetic, like
/// Godot's Vector2 (tools/godot-golden/float_probe.gd: 4000 of 4000 samples identical).
/// Scalars of the rules are doubles; they are rounded to float where they meet a vector.
/// Matching Godot's arithmetic keeps the golden comparisons tight; the product requirement
/// is determinism on one machine, not identical bits across platforms (docs/port/U1-RULES.md).
/// </summary>
[Serializable]
public struct Vec2 : IEquatable<Vec2>
{
public float X;
public float Y;
public static readonly Vec2 Zero = new Vec2(0f, 0f);
public static readonly Vec2 Right = new Vec2(1f, 0f);
public static readonly Vec2 Left = new Vec2(-1f, 0f);
public Vec2(float x, float y)
{
X = x;
Y = y;
}
/// <summary>From doubles: each component rounded to float.</summary>
public Vec2(double x, double y)
{
X = (float)x;
Y = (float)y;
}
public static Vec2 operator +(Vec2 a, Vec2 b) { return new Vec2(a.X + b.X, a.Y + b.Y); }
public static Vec2 operator -(Vec2 a, Vec2 b) { return new Vec2(a.X - b.X, a.Y - b.Y); }
public static Vec2 operator -(Vec2 a) { return new Vec2(-a.X, -a.Y); }
public static Vec2 operator *(Vec2 a, double s)
{
float f = (float)s;
return new Vec2(a.X * f, a.Y * f);
}
public static Vec2 operator *(double s, Vec2 a)
{
float f = (float)s;
return new Vec2(f * a.X, f * a.Y);
}
public static Vec2 operator /(Vec2 a, double s)
{
float f = (float)s;
return new Vec2(a.X / f, a.Y / f);
}
public static bool operator ==(Vec2 a, Vec2 b) { return a.X == b.X && a.Y == b.Y; }
public static bool operator !=(Vec2 a, Vec2 b) { return !(a == b); }
public float Length() { return MathF.Sqrt(X * X + Y * Y); }
public float LengthSquared() { return X * X + Y * Y; }
public float DistanceTo(Vec2 p)
{
float dx = X - p.X;
float dy = Y - p.Y;
return MathF.Sqrt(dx * dx + dy * dy);
}
public float DistanceSquaredTo(Vec2 p)
{
float dx = X - p.X;
float dy = Y - p.Y;
return dx * dx + dy * dy;
}
public float Dot(Vec2 p) { return X * p.X + Y * p.Y; }
public Vec2 Normalized()
{
float l = X * X + Y * Y;
if (l != 0f)
{
l = MathF.Sqrt(l);
return new Vec2(X / l, Y / l);
}
return this;
}
/// <summary>from + (to - from) * weight per component, weight rounded to float.</summary>
public Vec2 Lerp(Vec2 to, double weight)
{
float w = (float)weight;
return new Vec2(X + (to.X - X) * w, Y + (to.Y - Y) * w);
}
public Vec2 LimitLength(double length)
{
float limit = (float)length;
float l = Length();
Vec2 v = this;
if (l > 0f && limit < l)
{
v.X /= l;
v.Y /= l;
v.X *= limit;
v.Y *= limit;
}
return v;
}
public Vec2 Rotated(double angle)
{
float a = (float)angle;
float sine = MathF.Sin(a);
float cosi = MathF.Cos(a);
return new Vec2(X * cosi - Y * sine, X * sine + Y * cosi);
}
public bool Equals(Vec2 other) { return X.Equals(other.X) && Y.Equals(other.Y); }
public override bool Equals(object obj) { return obj is Vec2 other && Equals(other); }
public override int GetHashCode() { return (X.GetHashCode() * 397) ^ Y.GetHashCode(); }
public override string ToString() { return "(" + X.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + ", " + Y.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + ")"; }
}
/// <summary>Integer grid cell for the spatial buckets of a combat step.</summary>
public struct Vec2I : IEquatable<Vec2I>
{
public int X;
public int Y;
public Vec2I(int x, int y)
{
X = x;
Y = y;
}
public bool Equals(Vec2I other) { return X == other.X && Y == other.Y; }
public override bool Equals(object obj) { return obj is Vec2I other && Equals(other); }
public override int GetHashCode() { return (X * 73856093) ^ (Y * 19349663); }
}
}

View file

@ -0,0 +1,267 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Bot planner (reference: lune_bot.gd). Plans through LuneMatch.Submit exactly like a
/// human: same costs, caps and phase. Reads only public state (committed buildings, round,
/// boss state, announcements) and its own draft, never other players' drafts. No randomness.
/// </summary>
public static class LuneBot
{
/// <summary>Boss hunters attack from this Lanchester ratio on; each failed own attempt multiplies it.</summary>
public const double BossBar = 1.35;
public const double BossBarStep = 1.25;
public static string ProfileFor(LuneMatch match, int slot)
{
// A match may fix the profile per slot (balance runs); otherwise a seed-based rotation.
if (match.BotProfiles.Count == 6)
return match.BotProfiles[slot];
IReadOnlyList<string> names = ContentData.BotProfiles.Keys;
return names[(slot * 7 + match.SeedValue) % names.Count];
}
public static void Plan(LuneMatch match, int slot, string profileName = "")
{
string name = string.IsNullOrEmpty(profileName) ? ProfileFor(match, slot) : profileName;
BotProfile profile = ContentData.BotProfiles[name];
Player player = match.Players[slot];
List<string> ids = match.BaseIds(player.Bund);
var roles = new Dictionary<string, string>();
foreach (string id in ids)
roles[match.RoleOf(id)] = id;
PlanHero(match, slot, profile);
// Items and champions are planned before the army so the roster cannot drain the reserve.
PlanItems(match, slot, profile, 110);
PlanChampion(match, slot, profile);
for (int guard = 0; guard < 40; guard++)
{
List<RosterEntry> roster = player.Draft.Roster;
bool acted = false;
if (roster.Count >= profile.UpgradeFrom || roster.Count >= match.Balance.SupplyCap)
acted = TryUpgrade(match, slot, roster);
if (!acted && roster.Count < match.Balance.SupplyCap)
{
string role = NeededRole(match, roster, profile);
string lane = player.Lane;
// Deterministic help cadence (lane offset, not shared randomness) keeps mirror matches fair.
if (profile.SupportLane > 0.0)
{
int every = GdMath.Max(2, (int)GdMath.Round(1.0 / GdMath.Max(0.1, profile.SupportLane)));
int laneIndex = LuneMap.LaneIndex(player.Lane);
if ((match.RoundNumber + laneIndex) % every == 0)
lane = WeakestLane(match, player.Team);
}
acted = match.Submit(slot, Command.Buy(roles[role], lane)).Ok;
}
if (!acted && roster.Count < profile.UpgradeFrom)
acted = TryUpgrade(match, slot, roster);
if (!acted)
break;
}
PlanBoss(match, slot, profile);
match.SetReady(slot);
}
static void PlanHero(LuneMatch match, int slot, BotProfile profile)
{
Player player = match.Players[slot];
HeroState hero = player.Draft.Hero;
if (hero == null)
{
if (match.RoundNumber >= profile.HeroRound)
match.Submit(slot, Command.HeroBuy(profile.Hero));
return;
}
int level = LuneContent.LevelFor(hero.Xp);
for (int tier = 0; tier < LuneContent.TalentLevels.Length; tier++)
{
if (hero.Talents.Count == tier && level >= LuneContent.TalentLevels[tier])
match.Submit(slot, Command.Talent(tier, profile.TalentChoices[tier]));
}
}
static void PlanItems(LuneMatch match, int slot, BotProfile profile, int reserve)
{
HeroState hero = match.Players[slot].Draft.Hero;
if (hero == null || hero.Items.Count >= LuneContent.ItemSlots)
return;
foreach (string itemId in profile.Items)
{
if (hero.Items.Contains(itemId))
continue;
// Keep a reserve so the army still grows; one purchase per planning.
if (match.Players[slot].Draft.Gold >= ContentData.Items[itemId].Cost + reserve)
{
if (match.Submit(slot, Command.ItemBuy(itemId)).Ok)
return;
}
}
}
static void PlanChampion(LuneMatch match, int slot, BotProfile profile)
{
string champion = profile.Champion;
if (string.IsNullOrEmpty(champion) || match.RoundNumber < 3)
return;
Player player = match.Players[slot];
// At most one booked champion still on its way; bookings last exactly one round.
foreach (Booking booking in player.Bookings)
{
if (booking.DeployRound > match.RoundNumber)
return;
}
BotMemory memory;
if (!match.BotMemory.TryGetValue(slot, out memory))
memory = new BotMemory();
if ((memory.HasChampionRound ? memory.ChampionRound : 0) + 2 >= match.RoundNumber)
return;
if (player.Draft.Gold < ContentData.Champions[champion].Cost + 40)
return;
if (match.Submit(slot, Command.ChampionBook(champion, player.Lane)).Ok)
{
memory.HasChampionRound = true;
memory.ChampionRound = match.RoundNumber;
match.BotMemory[slot] = memory;
}
}
/// <summary>
/// Only boss-hunting bots go to the grotto, and only with a strong own army; the boss
/// state and lock are public information.
/// </summary>
static void PlanBoss(LuneMatch match, int slot, BotProfile profile)
{
Player player = match.Players[slot];
BossClaim claim = match.Boss.Claim;
if (claim != null && claim.ChooserSlot == slot && claim.DeployRound == match.RoundNumber)
{
string lane = WeakestLane(match, 1 - player.Team);
match.Submit(slot, Command.BossLaneChoice(lane));
}
if (!profile.HuntsBoss || match.RoundNumber < profile.BossFrom || !match.GrottoAvailable())
return;
List<RosterEntry> roster = player.Draft.Roster;
if (roster.Count < profile.BossTroops)
return;
var picks = new List<RosterEntry>(roster);
GdMath.SortStable(picks, (a, b) => a.Invested == b.Invested ? a.Seq < b.Seq : a.Invested > b.Invested);
int keep = GdMath.Min(profile.BossTroops - 1, picks.Count);
picks.RemoveRange(keep, picks.Count - keep);
HeroState hero = player.Draft.Hero;
// The boss formula is public. Lanchester estimate: total HP x total damage of the
// expedition against the boss. A failed own attempt (public outcome) raises the bar.
double totalHp = 0.0;
double totalDamage = 0.0;
foreach (RosterEntry entry in picks)
{
UnitDef definition = DataCatalog.GetUnit(entry.TypeId);
totalHp += definition.MaxHp;
totalDamage += definition.Damage;
}
if (hero != null)
{
UnitDef heroDef = LuneContent.HeroDefinition(hero);
totalHp += heroDef.MaxHp;
totalDamage += heroDef.Damage * (1.0 + heroDef.DamageBonus);
}
UnitDef bossDef = LuneContent.BossDefinition(match.RoundNumber, match.Balance, false);
double ratio = totalHp * totalDamage / (bossDef.MaxHp * bossDef.Damage);
BotMemory memory;
if (match.BotMemory.TryGetValue(slot, out memory))
{
// Reference behaviour (docs/port/U1-RULES.md, R1): a bot whose memory only holds a
// champion booking fails to read the boss fields and stops planning the grotto here.
if (!memory.HasBossFields)
return;
}
else
{
memory = new BotMemory { HasBossFields = true, Bar = BossBar, Sent = 0 };
}
foreach (BossLogEntry ev in match.BossLog)
{
if (ev.Round == memory.Sent && ev.Event == "failed")
{
memory.Bar = memory.Bar * BossBarStep;
memory.Sent = 0;
}
}
match.BotMemory[slot] = memory;
if (ratio < memory.Bar)
return;
memory.Sent = match.RoundNumber;
foreach (RosterEntry entry in picks)
match.Submit(slot, Command.Assign(entry.Uid, LuneMatch.Grotto));
if (hero != null)
match.Submit(slot, Command.Assign(hero.Uid, LuneMatch.Grotto));
}
static string NeededRole(LuneMatch match, List<RosterEntry> roster, BotProfile profile)
{
var counts = new Dictionary<string, int>();
foreach (RosterEntry entry in roster)
{
string role = match.RoleOf(entry.TypeId);
int current;
counts.TryGetValue(role, out current);
counts[role] = current + 1;
}
string best = "front";
double bestGap = double.NegativeInfinity;
foreach (string role in ContentData.RowOrder)
{
int have;
counts.TryGetValue(role, out have);
double gap = profile.Share(role) * (double)(roster.Count + 1) - (double)have;
if (gap > bestGap + 0.0001)
{
best = role;
bestGap = gap;
}
}
return best;
}
static bool TryUpgrade(LuneMatch match, int slot, List<RosterEntry> roster)
{
string bestUid = "";
int bestSeq = 1 << 30;
int bestCost = 1 << 30;
foreach (RosterEntry entry in roster)
{
string upgrade = DataCatalog.UpgradeOf(entry.TypeId);
if (upgrade.Length == 0)
continue;
int cost = DataCatalog.CostOf(upgrade);
if (cost < bestCost || (cost == bestCost && entry.Seq < bestSeq))
{
bestCost = cost;
bestSeq = entry.Seq;
bestUid = entry.Uid;
}
}
if (bestUid.Length == 0)
return false;
return match.Submit(slot, Command.Upgrade(bestUid)).Ok;
}
/// <summary>First lane (top, mid, bot) with the fewest standing lane towers of a team.</summary>
public static string WeakestLane(LuneMatch match, int team)
{
string weakest = "mid";
int fewest = 99;
foreach (string lane in LuneMap.Lanes)
{
int towers = match.TowersAlive(team, lane);
if (towers < fewest)
{
fewest = towers;
weakest = lane;
}
}
return weakest;
}
}
}

View file

@ -0,0 +1,973 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>A death recorded during combat; the settlement pays bounties and XP from it.</summary>
public sealed class CombatEvent
{
public int Serial;
public int Step;
public double Time;
public int Team;
public string Lane = "";
public string Zone = "lane";
/// <summary>Kind of the unit that died.</summary>
public string Kind = "";
public int UnitId;
public string DefId = "";
public int Bounty;
public int OwnerSlot = -1;
public string PersistentUid = "";
public string BuildingId = "";
public int Xp;
public List<string> XpHeroes = new List<string>();
}
/// <summary>
/// One automatic combat phase on all three lanes (reference: lune_combat.gd). Every step
/// first gathers targets, movement, hits, heals and effects from the state at the start of
/// the step, then applies them together, so processing order never decides who strikes
/// first. Deaths of a step resolve together; two Ancients falling in one step is a draw.
/// Iteration happens only over lists in spawn order and over insertion-ordered maps.
/// </summary>
public sealed class LuneCombat
{
public const int Neutral = 2;
/// <summary>Units without a target look for one every RETARGET steps, staggered by TeamIndex.</summary>
public const int Retarget = 4;
const double Cell = 4.0;
public readonly LuneMap Map;
public readonly LuneBalance Balance;
public readonly int RoundNumber;
public readonly List<LuneUnit> Units = new List<LuneUnit>();
public readonly OrderedMap<string, LuneUnit> Buildings = new OrderedMap<string, LuneUnit>();
public double Elapsed;
public int Steps;
public bool Finished;
/// <summary>cleared, timeout or core.</summary>
public string EndReason = "";
public readonly List<int> DestroyedCores = new List<int>();
public readonly List<CombatEvent> Events = new List<CombatEvent>();
public LuneUnit GrottoBoss;
public bool GrottoResolved = true;
public int GrottoClaimTeam = -1;
public int GrottoActiveTeam = -1;
/// <summary>none, success or failed.</summary>
public string GrottoOutcome = "none";
int _nextId = 1;
readonly int[] _teamCount = new int[3];
int _eventSerial;
readonly Dictionary<int, double> _pendingDamage = new Dictionary<int, double>();
readonly Dictionary<int, double> _pendingHeal = new Dictionary<int, double>();
readonly List<PendingEffect> _pendingEffects = new List<PendingEffect>();
readonly List<PendingDot> _pendingDots = new List<PendingDot>();
readonly List<PendingShield> _pendingShields = new List<PendingShield>();
// Per-step caches: building gates, march limits, spatial buckets of mobile units.
readonly Dictionary<string, bool> _attackable = new Dictionary<string, bool>();
readonly Dictionary<int, double> _limits = new Dictionary<int, double>();
readonly Dictionary<Vec2I, List<LuneUnit>>[] _grid = { new Dictionary<Vec2I, List<LuneUnit>>(), new Dictionary<Vec2I, List<LuneUnit>>(), new Dictionary<Vec2I, List<LuneUnit>>() };
struct PendingEffect
{
public LuneUnit Unit;
public string Family;
public string Kind;
public double Amount;
public double Duration;
}
struct PendingDot
{
public LuneUnit Unit;
public string Family;
public double Damage;
public double Duration;
public int AttackType;
}
struct PendingShield
{
public LuneUnit Unit;
public double Amount;
public double Duration;
}
public LuneCombat(LuneMap map, LuneBalance balance, int roundNumber)
{
Map = map;
Balance = balance;
RoundNumber = roundNumber;
}
// ---------------------------------------------------------------- setup
public LuneUnit AddBuilding(BuildingDef definition, double hp)
{
var unit = new LuneUnit();
unit.Id = _nextId++;
unit.Kind = definition.Tier == "core" ? "core" : "tower";
unit.Team = definition.Team;
unit.Lane = definition.Lane;
unit.BuildingId = definition.Id;
unit.TeamIndex = _teamCount[unit.Team]++;
unit.Def = new UnitDef { Id = definition.Id, DisplayName = definition.Id, MaxHp = definition.MaxHp, Damage = definition.Damage,
AttackRange = definition.Range, AttackType = 1, ArmorType = 1, Speed = 0.0, Ability = "", Bounty = definition.Bounty, Tier = definition.Tier };
unit.Position = definition.Position;
unit.PreviousPosition = unit.Position;
unit.MaxHp = definition.MaxHp;
unit.Hp = GdMath.Clamp(hp, 0.0, unit.MaxHp);
unit.Alive = unit.Hp > 0.0;
Units.Add(unit);
Buildings[unit.BuildingId] = unit;
return unit;
}
public LuneUnit Spawn(string kind, int team, string lane, UnitDef definition, double progress, double lateral, int slot = -1, string uid = "")
{
var unit = new LuneUnit();
unit.Id = _nextId++;
unit.Kind = kind;
unit.Team = team;
unit.Lane = lane;
unit.Def = definition;
unit.OwnerSlot = slot;
unit.PersistentUid = uid ?? "";
unit.TeamIndex = _teamCount[team]++;
unit.Progress = progress;
unit.Lateral = lateral;
unit.Position = Map.LanePoint(team, lane, progress, lateral);
unit.PreviousPosition = unit.Position;
Vec2 forward = Map.LaneForward(team, lane, progress);
unit.Facing = GdMath.Atan2(forward.X, forward.Y);
unit.MaxHp = definition.MaxHp;
unit.Hp = unit.MaxHp;
StartAbility(unit);
Units.Add(unit);
return unit;
}
public LuneUnit SpawnAt(string kind, int team, string zone, UnitDef definition, Vec2 at, int slot = -1, string uid = "")
{
LuneUnit unit = Spawn(kind, team, "mid", definition, 0.0, 0.0, slot, uid);
unit.Zone = zone;
unit.Lane = zone;
unit.Position = at;
unit.PreviousPosition = at;
return unit;
}
public LuneUnit AddGrottoBoss(UnitDef definition)
{
GrottoBoss = SpawnAt("boss", Neutral, LuneMap.GrottoZone, definition, Map.GrottoBossPoint());
GrottoBoss.Dormant = true;
GrottoResolved = false;
return GrottoBoss;
}
public Dictionary<string, bool> AliveMap()
{
var result = new Dictionary<string, bool>();
IReadOnlyList<LuneUnit> values = Buildings.Values;
for (int i = 0; i < values.Count; i++)
result[values[i].BuildingId] = values[i].Alive;
return result;
}
public bool BuildingAttackable(LuneUnit unit, Dictionary<string, bool> gates)
{
if (!unit.Alive)
return false;
bool cached;
if (_attackable.TryGetValue(unit.BuildingId, out cached))
return cached;
return LuneMap.IsAttackable(Map.BuildingDef(unit.BuildingId), gates);
}
void PrepareCaches(List<LuneUnit> living, Dictionary<string, bool> gates)
{
_attackable.Clear();
IReadOnlyList<LuneUnit> buildings = Buildings.Values;
for (int i = 0; i < buildings.Count; i++)
_attackable[buildings[i].BuildingId] = LuneMap.IsAttackable(Map.BuildingDef(buildings[i].BuildingId), gates);
_limits.Clear();
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
LanePath path = Map.LanePaths[lane];
double limit = path.Length;
for (int i = 0; i < buildings.Count; i++)
{
LuneUnit building = buildings[i];
if (building.Alive && building.Kind == "tower" && building.Team != team && building.Lane == lane)
limit = GdMath.Min(limit, path.Length - Map.BuildingDef(building.BuildingId).Progress - 1.5);
}
_limits[team * 3 + LuneMap.LaneIndex(lane)] = limit;
}
}
for (int side = 0; side < 3; side++)
_grid[side].Clear();
foreach (LuneUnit unit in living)
{
if (!unit.IsMobile())
continue;
var key = new Vec2I(GdMath.FloorI(unit.Position.X / Cell), GdMath.FloorI(unit.Position.Y / Cell));
List<LuneUnit> bucket;
if (!_grid[unit.Team].TryGetValue(key, out bucket))
{
bucket = new List<LuneUnit>();
_grid[unit.Team][key] = bucket;
}
bucket.Add(unit);
}
}
public static int[] HostileTeams(int team)
{
return team == Neutral ? new[] { 0, 1 } : new[] { 1 - team, Neutral };
}
/// <summary>Mobile units of the given teams in the cells around a point; the caller checks distance.</summary>
List<LuneUnit> NearMobile(Vec2 center, double radius, int[] teams)
{
var result = new List<LuneUnit>();
int reach = GdMath.CeilI(radius / Cell);
int cx = GdMath.FloorI(center.X / Cell);
int cy = GdMath.FloorI(center.Y / Cell);
foreach (int side in teams)
{
Dictionary<Vec2I, List<LuneUnit>> cells = _grid[side];
if (cells.Count == 0)
continue;
for (int dx = -reach; dx <= reach; dx++)
{
for (int dy = -reach; dy <= reach; dy++)
{
List<LuneUnit> bucket;
if (cells.TryGetValue(new Vec2I(cx + dx, cy + dy), out bucket))
result.AddRange(bucket);
}
}
}
return result;
}
// ---------------------------------------------------------------- step
public void Step()
{
if (Finished)
return;
double dt = Balance.SimStep;
Elapsed += dt;
Steps += 1;
_pendingDamage.Clear();
_pendingHeal.Clear();
_pendingEffects.Clear();
_pendingDots.Clear();
_pendingShields.Clear();
Dictionary<string, bool> gates = AliveMap();
var living = new List<LuneUnit>();
foreach (LuneUnit unit in Units)
{
if (unit.Alive)
{
living.Add(unit);
unit.PreviousPosition = unit.Position;
}
}
PrepareCaches(living, gates);
foreach (LuneUnit unit in living)
{
double dot = unit.AdvanceStatus(dt);
if (dot > 0.0)
QueueDamage(unit, dot, gates);
}
foreach (LuneUnit unit in living)
{
if (unit.IsMobile())
AbilityStep(unit, living);
}
foreach (LuneUnit unit in living)
{
if (unit.IsMobile() || unit.Kind == "tower")
{
bool keep = unit.Target != null && unit.Target.Alive;
if (keep || (Steps + unit.TeamIndex) % Retarget == 0)
unit.Target = ChooseTarget(unit, living, gates);
}
}
var moves = new Dictionary<int, KeyValuePair<Vec2, double>>();
foreach (LuneUnit unit in living)
{
if (unit.IsMobile())
moves[unit.Id] = PlanMove(unit, living, dt);
}
foreach (LuneUnit unit in living)
{
KeyValuePair<Vec2, double> plan;
if (!moves.TryGetValue(unit.Id, out plan))
continue;
Vec2 next = plan.Key;
Vec2 heading = next - unit.Position;
unit.Position = next;
if (heading.LengthSquared() > 0.000001)
unit.Facing = GdMath.Atan2(heading.X, heading.Y);
if (unit.Zone == "lane")
UpdateProgress(unit, plan.Value);
}
foreach (LuneUnit unit in living)
Attack(unit, living, gates, dt);
foreach (LuneUnit unit in living)
{
double heal;
if (_pendingHeal.TryGetValue(unit.Id, out heal))
unit.Heal(heal);
}
foreach (PendingShield entry in _pendingShields)
entry.Unit.GrantShield(entry.Amount, entry.Duration);
foreach (PendingEffect entry in _pendingEffects)
entry.Unit.ApplyEffect(entry.Family, entry.Kind, entry.Amount, entry.Duration);
foreach (PendingDot entry in _pendingDots)
entry.Unit.ApplyDot(entry.Family, entry.Damage, entry.Duration, entry.AttackType);
foreach (LuneUnit unit in living)
{
double damage;
if (_pendingDamage.TryGetValue(unit.Id, out damage))
unit.Take(damage);
}
foreach (LuneUnit unit in living)
{
if (unit.Hp <= 0.0 && unit.Alive)
{
unit.Alive = false;
unit.DeathTime = Elapsed;
unit.Target = null;
RecordDeath(unit);
}
}
UpdateGrotto();
CheckEnd();
}
/// <summary>Expeditions fight each other first; the boss wakes when at most one team remains.</summary>
void UpdateGrotto()
{
if (GrottoResolved || GrottoBoss == null)
return;
var teamsAlive = new List<int>();
foreach (LuneUnit unit in Units)
{
if (unit.Alive && unit.Zone == LuneMap.GrottoZone && unit.Team != Neutral && !teamsAlive.Contains(unit.Team))
teamsAlive.Add(unit.Team);
}
if (GrottoBoss.Dormant && teamsAlive.Count <= 1)
{
GrottoBoss.Dormant = false;
GrottoActiveTeam = teamsAlive.Count == 1 ? teamsAlive[0] : -1;
}
if (!GrottoBoss.Alive)
{
GrottoResolved = true;
GrottoClaimTeam = GrottoActiveTeam;
GrottoOutcome = GrottoClaimTeam >= 0 ? "success" : "failed";
}
else if (teamsAlive.Count == 0)
{
GrottoResolved = true;
GrottoOutcome = "failed";
}
}
void CheckEnd()
{
if (DestroyedCores.Count > 0)
{
Finished = true;
EndReason = "core";
return;
}
bool mobileAlive = !GrottoResolved;
foreach (LuneUnit unit in Units)
{
if (unit.Alive && unit.IsMobile() && unit.Zone == "lane")
{
mobileAlive = true;
break;
}
}
if (!mobileAlive)
{
Finished = true;
EndReason = "cleared";
}
else if (Elapsed >= Balance.CombatTimeLimit - 0.000001)
{
Finished = true;
EndReason = "timeout";
}
}
void RecordDeath(LuneUnit unit)
{
_eventSerial += 1;
var ev = new CombatEvent { Serial = _eventSerial, Step = Steps, Time = Elapsed, Team = unit.Team, Lane = unit.Lane, Zone = unit.Zone,
Kind = unit.Kind, UnitId = unit.Id, DefId = unit.Def.Id, Bounty = 0, OwnerSlot = unit.OwnerSlot, PersistentUid = unit.PersistentUid };
if (unit.Kind == "monster" || unit.Kind == "champion" || unit.Kind == "boss")
{
ev.Bounty = unit.Def.Bounty;
}
else if (unit.IsBuilding())
{
ev.Bounty = unit.Def.Bounty;
ev.BuildingId = unit.BuildingId;
if (unit.Kind == "core" && !DestroyedCores.Contains(unit.Team))
DestroyedCores.Add(unit.Team);
}
// Participation XP: every opposing hero near the death shares it, whoever landed the hit.
int xp = 0;
switch (unit.Kind)
{
case "monster": xp = 12 + RoundNumber; break;
case "champion": xp = 30 + 2 * RoundNumber; break;
case "boss": xp = LuneContent.BossXp; break;
case "troop":
case "hero": xp = 15; break;
case "tower":
case "core": xp = LuneContent.BuildingXp; break;
}
double radius = LuneContent.XpRadius + (unit.IsBuilding() ? 2.0 : 0.0);
foreach (LuneUnit other in Units)
{
if (other.Kind == "hero" && other.Alive && other.Zone == unit.Zone && other.Team != unit.Team && other.Position.DistanceTo(unit.Position) <= radius)
ev.XpHeroes.Add(other.PersistentUid);
}
ev.Xp = xp;
Events.Add(ev);
}
// ---------------------------------------------------------------- targeting and movement
static bool Hostile(LuneUnit unit, LuneUnit other)
{
return other.Alive && other.Team != unit.Team && other.Zone == unit.Zone && !other.Dormant && !unit.Dormant;
}
LuneUnit ChooseTarget(LuneUnit unit, List<LuneUnit> living, Dictionary<string, bool> gates)
{
LuneUnit current = unit.Target;
if (current != null && Hostile(unit, current) && unit.Position.DistanceTo(current.Position) <= Balance.LeashRadius)
{
if (!current.IsBuilding() || BuildingAttackable(current, gates))
{
if (unit.Kind != "tower" || unit.Position.DistanceTo(current.Position) <= unit.AttackRange())
return current;
}
}
double radius = unit.Kind == "tower" ? unit.AttackRange() : Balance.AggroRadius;
LuneUnit best = null;
double bestDistance = double.PositiveInfinity;
string priority = unit.Def.Priority;
IReadOnlyList<LuneUnit> buildings = Buildings.Values;
if (priority == "building")
{
// Siege champions go for the nearest attackable building before anything else.
for (int i = 0; i < buildings.Count; i++)
{
LuneUnit building = buildings[i];
if (Hostile(unit, building) && BuildingAttackable(building, gates))
{
double reachDistance = unit.Position.DistanceTo(building.Position) - Balance.BuildingRadius;
if (reachDistance <= Balance.AggroRadius && reachDistance < bestDistance)
{
best = building;
bestDistance = reachDistance;
}
}
}
if (best != null)
return best;
}
foreach (LuneUnit other in NearMobile(unit.Position, radius, HostileTeams(unit.Team)))
{
if (!Hostile(unit, other))
continue;
double d = unit.Position.DistanceTo(other.Position);
// Pressure champions treat support and ranged enemies as 6 m closer.
if (priority == "support" && (other.Role() == "support" || other.Role() == "ranged"))
d -= 6.0;
if (d <= radius && (d < bestDistance - 0.0001 || (Math.Abs(d - bestDistance) <= 0.0001 && other.Id < best.Id)))
{
best = other;
bestDistance = d;
}
}
if (best != null || unit.Kind == "tower")
return best;
// Buildings: nearby attackable ones; at the end of a lane the whole base is in reach.
for (int i = 0; i < buildings.Count; i++)
{
LuneUnit other = buildings[i];
if (!Hostile(unit, other) || !BuildingAttackable(other, gates))
continue;
double d = unit.Position.DistanceTo(other.Position) - Balance.BuildingRadius;
if ((d <= radius || unit.ReachedBase) && d < bestDistance)
{
best = other;
bestDistance = d;
}
}
return best;
}
double MarchLimit(LuneUnit unit)
{
double cached;
if (_limits.TryGetValue(unit.Team * 3 + LuneMap.LaneIndex(unit.Lane), out cached))
return cached;
LanePath path = Map.LanePaths[unit.Lane];
double limit = path.Length;
IReadOnlyList<LuneUnit> buildings = Buildings.Values;
for (int i = 0; i < buildings.Count; i++)
{
LuneUnit building = buildings[i];
if (building.Alive && building.Kind == "tower" && building.Team != unit.Team && building.Lane == unit.Lane)
limit = GdMath.Min(limit, path.Length - Map.BuildingDef(building.BuildingId).Progress - 1.5);
}
return limit;
}
/// <summary>
/// Returns the next position and the marching progress. Marching advances the lane
/// parameter directly, so formation offsets cannot stall a unit on the inside of a bend.
/// </summary>
KeyValuePair<Vec2, double> PlanMove(LuneUnit unit, List<LuneUnit> living, double dt)
{
double stepLength = unit.Speed() * Balance.MarchSpeedFactor * unit.MovementFactor() * dt;
Vec2 goal = unit.Position;
double travel = 0.0;
double marched = unit.Progress;
if (unit.Target != null)
{
double reach = unit.AttackRange() + (unit.Target.IsBuilding() ? Balance.BuildingRadius : 0.0);
double gap = unit.Position.DistanceTo(unit.Target.Position) - reach;
if (gap > 0.0)
{
goal = unit.Target.Position;
travel = GdMath.Min(stepLength, gap + 0.02);
}
}
else if (unit.Zone == LuneMap.GrottoZone)
{
if (unit.Kind != "boss" && GrottoBoss != null && GrottoBoss.Alive)
{
double gapBoss = unit.Position.DistanceTo(GrottoBoss.Position) - unit.AttackRange();
if (gapBoss > 0.0)
{
goal = GrottoBoss.Position;
travel = GdMath.Min(stepLength, gapBoss + 0.02);
}
}
}
else if (unit.Zone == "lane")
{
double limit = MarchLimit(unit);
Vec2 anchor = Map.LanePoint(unit.Team, unit.Lane, unit.Progress, unit.Lateral);
double off = unit.Position.DistanceTo(anchor);
if (off > 1.2)
{
// Rejoin the formation line after a chase before advancing again.
goal = anchor;
travel = GdMath.Min(stepLength, unit.Position.DistanceTo(anchor));
}
else if (unit.Progress < limit - 0.01)
{
marched = GdMath.Min(unit.Progress + stepLength, limit);
goal = Map.LanePoint(unit.Team, unit.Lane, marched, unit.Lateral);
travel = unit.Position.DistanceTo(goal);
}
else if (off > 0.05)
{
goal = anchor;
travel = GdMath.Min(stepLength, unit.Position.DistanceTo(anchor));
}
}
Vec2 next = unit.Position;
if (travel > 0.0 && goal.DistanceSquaredTo(unit.Position) > 0.000001)
next += (goal - unit.Position).Normalized() * GdMath.Min(travel, unit.Position.DistanceTo(goal));
// Separation from allies so bodies do not stack; opponents may still close to melee range.
Vec2 push = Vec2.Zero;
foreach (LuneUnit other in NearMobile(unit.Position, 1.1, new[] { unit.Team }))
{
if (other == unit || other.Zone != unit.Zone)
continue;
Vec2 offset = unit.Position - other.Position;
double distance = offset.Length();
if (distance < 1.1)
{
if (distance < 0.0001)
{
// Mirrored teams sidestep in mirrored directions.
offset = unit.Id < other.Id ? Vec2.Right : Vec2.Left;
if (unit.Team == 1)
offset = LuneMap.Mirror(offset);
distance = 0.001;
}
push += offset.Normalized() * (1.1 - distance) * 2.0;
}
}
next += push.LimitLength(0.9) * dt;
next.X = (float)GdMath.Clamp(next.X, -LuneMap.HalfExtent, LuneMap.HalfExtent);
next.Y = (float)GdMath.Clamp(next.Y, -LuneMap.HalfExtent, LuneMap.HalfExtent);
return new KeyValuePair<Vec2, double>(next, marched);
}
void UpdateProgress(LuneUnit unit, double marched)
{
LanePath path = Map.LanePaths[unit.Lane];
double limit = MarchLimit(unit);
if (unit.Target != null)
{
// After a chase the unit continues from where it now stands, never further back.
Vec2 blue = unit.Team == 0 ? unit.Position : LuneMap.Mirror(unit.Position);
marched = GdMath.Max(marched, GdMath.Min(path.Project(blue), limit));
}
unit.Progress = GdMath.Max(unit.Progress, GdMath.Min(marched, GdMath.Max(limit, unit.Progress)));
if (unit.Progress >= path.Length - 0.6)
unit.ReachedBase = true;
}
// ---------------------------------------------------------------- attacks
void Attack(LuneUnit unit, List<LuneUnit> living, Dictionary<string, bool> gates, double dt)
{
unit.InRange = false;
LuneUnit target = unit.Target;
if (target == null || !target.Alive || unit.Kind == "core")
{
unit.AttackProgress = 0.0;
return;
}
double reach = unit.AttackRange() + (target.IsBuilding() ? Balance.BuildingRadius : 0.0) + 0.035;
if (unit.Position.DistanceTo(target.Position) > reach)
{
unit.AttackProgress = 0.0;
return;
}
if (target.IsBuilding() && !BuildingAttackable(target, gates))
{
unit.AttackProgress = 0.0;
return;
}
unit.InRange = true;
double interval = unit.Kind == "tower" ? Balance.TowerAttackInterval : 1.0;
unit.AttackProgress += dt * unit.AttackSpeedFactor() / interval;
while (unit.AttackProgress >= 1.0 - 0.000001)
{
unit.AttackProgress = GdMath.Max(0.0, unit.AttackProgress - 1.0);
unit.HitCount += 1;
unit.AttackSerial += 1;
double damage = unit.Def.Damage * (1.0 + unit.DamageBonus());
if (unit.HitCount % 4 == 0)
{
if (unit.Ability() == "g05")
damage *= unit.HasTalent("durchschlag") ? 2.1 : 1.5;
else if (unit.Ability() == "g06")
damage *= 1.75;
}
if (unit.HitCount % 3 == 0 && unit.HasTalent("stammschlag"))
damage *= 1.6;
if (target.IsBuilding())
damage *= unit.Def.SiegeFactor;
QueueDamage(target, target.Mitigated(damage, unit.Def.AttackType), gates);
OnHit(unit, target, living, gates);
// Item effects come last and never trigger further on-hit effects.
double itemSplash = unit.Def.ItemSplash;
if (itemSplash > 0.0)
Splash(unit, target, living, gates, itemSplash, 1.5, 2);
}
}
public void QueueDamage(LuneUnit target, double amount, Dictionary<string, bool> gates)
{
if (amount <= 0.0 || !target.Alive)
return;
// The building gate applies to every source: ranged, splash, DoT and abilities.
if (target.IsBuilding() && !BuildingAttackable(target, gates))
return;
double current;
_pendingDamage.TryGetValue(target.Id, out current);
_pendingDamage[target.Id] = current + amount;
}
/// <summary>Damage queued for a unit in the current step (tests).</summary>
public bool HasPendingDamage(LuneUnit unit) { return _pendingDamage.ContainsKey(unit.Id); }
void QueueHeal(LuneUnit target, double amount)
{
double current;
_pendingHeal.TryGetValue(target.Id, out current);
_pendingHeal[target.Id] = current + amount;
}
// ---------------------------------------------------------------- abilities
void StartAbility(LuneUnit unit)
{
unit.AbilityReady = 6.0;
switch (unit.Ability())
{
case "w03":
case "w04":
unit.AbilityReady = 5.0;
break;
case "w09":
case "w10":
unit.AbilityReady = 4.0;
break;
case "s02":
case "ch_schutz":
unit.GrantShield(unit.MaxHp * 0.15);
break;
case "e08":
unit.GrantShield(unit.MaxHp * 0.20);
break;
}
}
List<LuneUnit> Nearby(LuneUnit unit, bool allied, Vec2 center, double radius, int maximum, LuneUnit excluded = null, bool healthPriority = false)
{
var result = new List<LuneUnit>();
// Buildings can be splash victims; they are few, so check them directly.
List<LuneUnit> pool = NearMobile(center, radius, allied ? new[] { unit.Team } : HostileTeams(unit.Team));
if (!allied)
{
IReadOnlyList<LuneUnit> buildings = Buildings.Values;
for (int i = 0; i < buildings.Count; i++)
pool.Add(buildings[i]);
}
foreach (LuneUnit other in pool)
{
if (!other.Alive || other == excluded || other.Zone != unit.Zone)
continue;
if ((other.Team == unit.Team) != allied || other.Dormant)
continue;
if (allied && other.IsBuilding())
continue;
if (other.Position.DistanceTo(center) <= radius + 0.000001)
result.Add(other);
}
GdMath.SortStable(result, (a, b) =>
{
if (healthPriority && !GdMath.IsEqualApprox(a.Hp / a.MaxHp, b.Hp / b.MaxHp))
return a.Hp / a.MaxHp < b.Hp / b.MaxHp;
double ad = a.Position.DistanceTo(center);
double bd = b.Position.DistanceTo(center);
return GdMath.IsEqualApprox(ad, bd) ? a.Id < b.Id : ad < bd;
});
if (result.Count > maximum)
result.RemoveRange(maximum, result.Count - maximum);
return result;
}
void AddEffect(LuneUnit target, string family, string kind, double amount, double duration)
{
_pendingEffects.Add(new PendingEffect { Unit = target, Family = family, Kind = kind, Amount = amount, Duration = duration });
}
void AddDot(LuneUnit target, string family, double damage, double duration, int attackType)
{
_pendingDots.Add(new PendingDot { Unit = target, Family = family, Damage = damage, Duration = duration, AttackType = attackType });
}
void AbilityStep(LuneUnit unit, List<LuneUnit> living)
{
double regen = unit.Def.Regen;
if (regen > 0.0 && unit.Hp < unit.MaxHp)
QueueHeal(unit, unit.MaxHp * regen * Balance.SimStep);
if (unit.HasTalent("schutzwurzeln") && unit.CombatTime + 0.000001 >= unit.AuraReady)
{
foreach (LuneUnit ally in Nearby(unit, true, unit.Position, 4.0, 4))
AddEffect(ally, "bark_guard", "reduction", 0.10, 4.5);
unit.AuraReady = unit.CombatTime + 4.0;
}
if (unit.CombatTime + 0.000001 < unit.AbilityReady)
return;
string ability = unit.Ability();
bool success = false;
switch (ability)
{
case "w03":
case "w04":
if (unit.Hp < unit.MaxHp)
{
QueueHeal(unit, ability == "w04" ? 100.0 : 35.0);
success = true;
}
break;
case "w09":
case "w10":
{
bool upgraded = ability == "w10";
double radius = upgraded ? 4.5 : 4.0;
int count = upgraded ? 3 : 1;
double amount = upgraded ? 80.0 : 65.0;
if (unit.HasTalent("tiefe_quelle"))
{
count = 1;
amount = 190.0;
}
else if (unit.HasTalent("weite_quelle"))
{
radius = 5.5;
count = 5;
amount = 65.0;
}
foreach (LuneUnit ally in Nearby(unit, true, unit.Position, radius, count, null, true))
{
if (ally.Hp < ally.MaxHp)
{
QueueHeal(ally, amount);
if (unit.HasTalent("quellschild"))
_pendingShields.Add(new PendingShield { Unit = ally, Amount = 70.0, Duration = 4.0 });
success = true;
}
}
break;
}
case "ch_schutz":
{
List<LuneUnit> allies = Nearby(unit, true, unit.Position, 4.0, 4);
foreach (LuneUnit ally in allies)
_pendingShields.Add(new PendingShield { Unit = ally, Amount = 120.0, Duration = 6.0 });
success = allies.Count > 0;
break;
}
case "g09":
case "g10":
{
bool upgraded = ability == "g10";
List<LuneUnit> allies = Nearby(unit, true, unit.Position, upgraded ? 4.0 : 3.0, upgraded ? 4 : 3, null, true);
foreach (LuneUnit ally in allies)
{
AddEffect(ally, "hearth_warmth", "damage", upgraded ? 0.15 : 0.10, 4.0);
if (upgraded)
QueueHeal(ally, 45.0);
}
success = allies.Count > 0;
break;
}
case "e06":
foreach (LuneUnit ally in Nearby(unit, true, unit.Position, 4.0, 100, null, true))
{
if (ally.Hp < ally.MaxHp)
{
QueueHeal(ally, 30.0 * unit.Def.HpFactor);
success = true;
break;
}
}
break;
case "s03":
case "e09":
{
List<LuneUnit> victims = Nearby(unit, false, unit.Position, ability == "e09" ? 6.0 : 3.0, ability == "e09" ? 3 : 4);
foreach (LuneUnit victim in victims)
AddEffect(victim, "dissonance", "attack_slow", 0.15, 3.0);
success = victims.Count > 0;
break;
}
case "s05":
{
List<LuneUnit> victims = Nearby(unit, false, unit.Position, 5.0, 1);
foreach (LuneUnit victim in victims)
AddEffect(victim, "rift_wound", "healing_reduction", 0.30, 4.0);
success = victims.Count > 0;
break;
}
}
if (success)
{
unit.AbilityReady = unit.CombatTime + (ability == "w03" || ability == "w04" ? 5.0 : (ability == "w09" || ability == "w10" ? 4.0 : 6.0));
unit.AbilitySerial += 1;
}
}
void OnHit(LuneUnit unit, LuneUnit primary, List<LuneUnit> living, Dictionary<string, bool> gates)
{
string ability = unit.Ability();
int hits = unit.HitCount;
if (unit.HasTalent("wurzelbeben") && hits % 3 == 0)
Splash(unit, primary, living, gates, 0.50, 2.0, 3);
if (unit.HasTalent("splitterschuss") && hits % 4 == 0)
Splash(unit, primary, living, gates, 0.45, 2.0, 3);
if (unit.HasTalent("keimsaat") && hits % 2 == 0)
AddDot(primary, "seed_burn", 9.0, 4.0, unit.Def.AttackType);
switch (ability)
{
case "w01":
case "w02":
if (hits % 3 == 0)
{
AddEffect(primary, "root_grasp", "movement_slow", ability == "w02" ? 0.30 : 0.20, ability == "w02" ? 3.0 : 2.0);
if (ability == "w02")
AddEffect(primary, "root_grasp_attack", "attack_slow", 0.10, 3.0);
}
break;
case "w05":
case "w06":
if (hits % 4 == 0)
AddDot(primary, "seed_burn", ability == "w06" ? 10.0 : 3.0, 4.0, unit.Def.AttackType);
break;
case "w07":
case "w08":
if (hits % 3 == 0)
Splash(unit, primary, living, gates, ability == "w08" ? 0.80 : 0.60, ability == "w08" ? 2.5 : 2.0, ability == "w08" ? 4 : 3);
break;
case "g01":
case "g02":
if (hits % 3 == 0)
AddDot(primary, "burn", ability == "g02" ? 12.0 : 4.0, 3.0, unit.Def.AttackType);
break;
case "g07":
Splash(unit, primary, living, gates, 0.30, 1.5, 3);
break;
case "g08":
Splash(unit, primary, living, gates, 0.40, 2.0, 4);
break;
case "e05":
if (hits % 4 == 0)
Splash(unit, primary, living, gates, 0.40, 1.5, 2);
break;
case "e07":
if (hits % 4 == 0)
Splash(unit, primary, living, gates, 0.60, 2.0, 3);
break;
case "e10":
if (hits % 4 == 0)
Splash(unit, primary, living, gates, 0.60, 2.5, 4);
break;
case "s04":
if (hits % 4 == 0)
Splash(unit, primary, living, gates, 0.50, 2.0, 3);
break;
}
}
void Splash(LuneUnit unit, LuneUnit primary, List<LuneUnit> living, Dictionary<string, bool> gates, double factor, double radius, int maximum)
{
foreach (LuneUnit victim in Nearby(unit, false, primary.Position, radius, maximum, primary))
QueueDamage(victim, victim.Mitigated(unit.Def.Damage * factor, unit.Def.AttackType), gates);
}
// ---------------------------------------------------------------- queries for views and tests
public int MobileCount(int team = -1)
{
int count = 0;
foreach (LuneUnit unit in Units)
{
if (unit.Alive && unit.IsMobile() && (team < 0 || unit.Team == team))
count++;
}
return count;
}
}
}

View file

@ -0,0 +1,345 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>Static data of one building (18 per match).</summary>
public sealed class BuildingDef
{
public string Id = "";
public int Team;
/// <summary>top, mid, bot, or base for base towers and Ancients.</summary>
public string Lane = "";
/// <summary>outer (Grenzwacht), inner (Bastion), base (Kernwacht) or core (Ancient).</summary>
public string Tier = "";
/// <summary>Lane progress from the own base; -1 for base buildings.</summary>
public double Progress;
public Vec2 Position;
public double MaxHp;
public double Damage;
public double Range;
public int Bounty;
}
/// <summary>
/// Lane polyline with cumulative lengths. Points are float vectors; the cumulative table is
/// stored as float and the total length as double, as in the reference.
/// </summary>
public sealed class LanePath
{
public readonly Vec2[] Points;
public readonly float[] Cumulative;
public readonly double Length;
public LanePath(List<Vec2> source)
{
Points = source.ToArray();
Cumulative = new float[Points.Length];
Cumulative[0] = 0f;
double length = 0.0;
for (int i = 1; i < Points.Length; i++)
{
length += Points[i].DistanceTo(Points[i - 1]);
Cumulative[i] = (float)length;
}
Length = length;
}
/// <summary>Index i of the segment [i - 1, i] that contains lane coordinate d.</summary>
int Segment(double d)
{
int low = 1;
int high = Points.Length - 1;
while (low < high)
{
int middle = (low + high) >> 1;
if (d <= Cumulative[middle])
high = middle;
else
low = middle + 1;
}
return low;
}
public Vec2 PointAt(double s)
{
double d = GdMath.Clamp(s, 0.0, Length);
int i = Segment(d);
double span = GdMath.Max(0.0001, (double)Cumulative[i] - (double)Cumulative[i - 1]);
return Points[i - 1].Lerp(Points[i], GdMath.Clamp((d - Cumulative[i - 1]) / span, 0.0, 1.0));
}
public Vec2 TangentAt(double s)
{
int i = Segment(GdMath.Clamp(s, 0.0, Length));
return (Points[i] - Points[i - 1]).Normalized();
}
/// <summary>Point and unnormalised tangent (b - a) / span at s in one pass.</summary>
public void Sample(double s, out Vec2 point, out Vec2 tangent)
{
double d = GdMath.Clamp(s, 0.0, Length);
int i = Segment(d);
Vec2 a = Points[i - 1];
Vec2 b = Points[i];
double span = GdMath.Max(0.0001, (double)Cumulative[i] - (double)Cumulative[i - 1]);
point = a.Lerp(b, GdMath.Clamp((d - Cumulative[i - 1]) / span, 0.0, 1.0));
tangent = (b - a) / span;
}
/// <summary>Lane coordinate of the closest point on the polyline.</summary>
public double Project(Vec2 position)
{
double best = double.PositiveInfinity;
double bestS = 0.0;
for (int i = 1; i < Points.Length; i++)
{
Vec2 a = Points[i - 1];
Vec2 b = Points[i];
Vec2 ab = b - a;
double t = GdMath.Clamp((double)(position - a).Dot(ab) / GdMath.Max(0.0001, (double)ab.LengthSquared()), 0.0, 1.0);
double d = position.DistanceSquaredTo(a + ab * t);
if (d < best)
{
best = d;
bestS = (double)Cumulative[i - 1] + (double)ab.Length() * t;
}
}
return bestS;
}
}
/// <summary>
/// Map in ground coordinates (x, z): Blau (team 0) in the south-west, Rot (team 1) in the
/// north-east, Rot the mirror image of Blau across x = z. Every lane is built from its Blau
/// half and that half mirrored, so lanes, towers and spawns are equal by construction
/// (reference: lune_map.gd, G1 layout). Top and Bottom have exactly the same length.
/// </summary>
public sealed class LuneMap
{
public static readonly string[] Lanes = { "top", "mid", "bot" };
public static readonly Dictionary<string, string> LaneNames = new Dictionary<string, string> { { "top", "Top" }, { "mid", "Mid" }, { "bot", "Bottom" } };
/// <summary>Catmull-Rom control points of each Blau half, base to symmetry axis.</summary>
public static readonly Vec2[] TopControl = { new Vec2(-27.4, 21.0), new Vec2(-26.4, 13.0), new Vec2(-28.4, 4.0), new Vec2(-26.2, -5.0),
new Vec2(-28.6, -13.5), new Vec2(-28.4, -21.2), new Vec2(-25.0, -25.0) };
public static readonly Vec2[] MidControl = { new Vec2(-21.6, 21.6), new Vec2(-16.4, 17.2), new Vec2(-10.9, 11.5), new Vec2(-5.6, 5.9), new Vec2(0.0, 0.0) };
public static readonly Vec2 CoreBlue = new Vec2(-30.0, 30.0);
/// <summary>Two base towers per Ancient, 6 m out and 40° either side of the Mid direction.</summary>
public static readonly Vec2[] BaseTowersBlue = { new Vec2(-29.477, 24.023), new Vec2(-24.023, 29.477) };
/// <summary>Neutral boss grotto on the symmetry axis between Top and Mid.</summary>
public static readonly Vec2 Grotto = new Vec2(-13.5, -13.5);
public const double HalfExtent = 36.0;
public static readonly Vec2 Axis = new Vec2(0.70710678, 0.70710678);
public static readonly Vec2 BlueSide = new Vec2(-0.70710678, 0.70710678);
public const double SampleSpacing = 2.0;
public const string GrottoZone = "grotto";
public readonly LuneBalance Balance;
public readonly Dictionary<string, LanePath> LanePaths = new Dictionary<string, LanePath>();
public readonly List<BuildingDef> Buildings = new List<BuildingDef>();
readonly Dictionary<string, BuildingDef> _index = new Dictionary<string, BuildingDef>();
public LuneMap(LuneBalance balance = null)
{
Balance = balance ?? LuneBalance.Default();
LanePaths["top"] = new LanePath(FullLane(SampleHalf(TopControl)));
LanePaths["mid"] = new LanePath(FullLane(SampleHalf(MidControl)));
LanePaths["bot"] = new LanePath(SolveBottom(LanePaths["top"].Length));
BuildBuildings();
}
public static int LaneIndex(string lane)
{
return System.Array.IndexOf(Lanes, lane);
}
public static Vec2 Mirror(Vec2 v) { return new Vec2(v.Y, v.X); }
public static Vec2 MirrorMid(Vec2 v) { return new Vec2(-v.Y, -v.X); }
public static List<Vec2> FullLane(List<Vec2> half)
{
var result = new List<Vec2>(half);
for (int i = half.Count - 2; i >= 0; i--)
result.Add(Mirror(half[i]));
return result;
}
/// <summary>
/// Samples the Catmull-Rom curve through the control points of a Blau half. The phantom
/// point after the axis point mirrors the one before it, so the joined lane has no kink.
/// </summary>
public static List<Vec2> SampleHalf(IList<Vec2> control)
{
var ext = new List<Vec2> { control[0] * 2.0 - control[1] };
ext.AddRange(control);
ext.Add(Mirror(control[control.Count - 2]));
var result = new List<Vec2>();
for (int i = 1; i < ext.Count - 2; i++)
{
Vec2 p0 = ext[i - 1];
Vec2 p1 = ext[i];
Vec2 p2 = ext[i + 1];
Vec2 p3 = ext[i + 2];
int steps = GdMath.Max(2, GdMath.CeilI(p1.DistanceTo(p2) / SampleSpacing));
for (int k = 0; k < steps; k++)
{
double t = (double)k / steps;
double t2 = t * t;
double t3 = t2 * t;
result.Add(0.5 * (2.0 * p1 + (p2 - p0) * t + (2.0 * p0 - 5.0 * p1 + 4.0 * p2 - p3) * t2 + (3.0 * p1 - p0 - 3.0 * p2 + p3) * t3));
}
}
result.Add(control[control.Count - 1]);
return result;
}
/// <summary>Bottom half through the south-eastern lowland; bend pulls the meander inwards.</summary>
public static Vec2[] BottomControl(double bend)
{
return new[] { new Vec2(-21.0, 27.4), new Vec2(-12.5, 27.0), new Vec2(-4.0, 27.6), new Vec2(4.0, 27.0 - bend),
new Vec2(12.0, 27.4 - 0.6 * bend), new Vec2(19.5, 27.0), new Vec2(22.2, 27.8), new Vec2(25.0, 25.0) };
}
/// <summary>Bisection on the meander so Bottom is exactly as long as Top.</summary>
public static List<Vec2> SolveBottom(double targetLength)
{
double low = 0.0;
double high = 8.0;
for (int i = 0; i < 60; i++)
{
double bend = (low + high) * 0.5;
if (new LanePath(FullLane(SampleHalf(BottomControl(bend)))).Length < targetLength)
low = bend;
else
high = bend;
}
return FullLane(SampleHalf(BottomControl((low + high) * 0.5)));
}
/// <summary>Lane coordinate s for a team's own marching progress (0 at its own base).</summary>
public double SFor(int team, string lane, double progress)
{
return team == 0 ? progress : LanePaths[lane].Length - progress;
}
public double ProgressFor(int team, string lane, double s)
{
return team == 0 ? s : LanePaths[lane].Length - s;
}
/// <summary>Blau frame point, mirrored for Rot so both sides are exact images.</summary>
public Vec2 LanePoint(int team, string lane, double progress, double lateral = 0.0)
{
Vec2 point;
Vec2 tangent;
LanePaths[lane].Sample(progress, out point, out tangent);
Vec2 blue = point + new Vec2(-tangent.Y, tangent.X) * lateral;
return team == 0 ? blue : new Vec2(blue.Y, blue.X);
}
public Vec2 LaneForward(int team, string lane, double progress)
{
LanePath path = LanePaths[lane];
Vec2 tangent = path.TangentAt(GdMath.Clamp(progress, 0.0, path.Length));
return team == 0 ? tangent : Mirror(tangent);
}
/// <summary>Grotto entrance of a team (mirrored); the boss rests slightly inside.</summary>
public Vec2 GrottoEntry(int team)
{
Vec2 blue = Grotto + new Vec2(1.2, 4.6);
return team == 0 ? blue : Mirror(blue);
}
public Vec2 GrottoBossPoint() { return Grotto + new Vec2(-1.5, -1.5); }
public Vec2 GrottoCenter() { return Grotto; }
/// <summary>Army formation area on a lane: progress from/to and lateral half width.</summary>
public static void DeploymentSpan(out double from, out double to, out double halfWidth)
{
from = 0.4;
to = 7.2;
halfWidth = 2.4;
}
public Vec2 CorePosition(int team) { return team == 0 ? CoreBlue : Mirror(CoreBlue); }
void BuildBuildings()
{
Buildings.Clear();
_index.Clear();
for (int team = 0; team < 2; team++)
{
string prefix = team == 0 ? "blau" : "rot";
foreach (string lane in Lanes)
{
LanePath path = LanePaths[lane];
foreach (string tier in new[] { "inner", "outer" })
{
double fraction = tier == "inner" ? Balance.InnerTowerFraction : Balance.OuterTowerFraction;
double progress = path.Length * fraction;
Add(new BuildingDef { Id = prefix + "_" + lane + "_" + tier, Team = team, Lane = lane, Tier = tier, Progress = progress,
Position = LanePoint(team, lane, progress, Balance.TowerSideOffset),
MaxHp = tier == "inner" ? Balance.TowerInnerHp : Balance.TowerOuterHp, Damage = Balance.TowerDamage, Range = Balance.TowerRange,
Bounty = tier == "inner" ? Balance.BountyInnerTower : Balance.BountyOuterTower });
}
}
for (int index = 0; index < 2; index++)
{
Vec2 point = BaseTowersBlue[index];
Add(new BuildingDef { Id = prefix + "_base_" + (index + 1), Team = team, Lane = "base", Tier = "base", Progress = -1.0,
Position = team == 0 ? point : Mirror(point), MaxHp = Balance.BaseTowerHp, Damage = Balance.BaseTowerDamage,
Range = Balance.TowerRange, Bounty = Balance.BountyBaseTower });
}
Add(new BuildingDef { Id = prefix + "_ancient", Team = team, Lane = "base", Tier = "core", Progress = -1.0,
Position = CorePosition(team), MaxHp = Balance.CoreHp, Damage = 0.0, Range = 0.0, Bounty = 0 });
}
}
void Add(BuildingDef definition)
{
Buildings.Add(definition);
_index[definition.Id] = definition;
}
public BuildingDef BuildingDef(string id)
{
BuildingDef definition;
return _index.TryGetValue(id, out definition) ? definition : null;
}
/// <summary>
/// Gate rule shared by every damage source: outer first, inner after its outer, base
/// towers once any lane is fully open, the Ancient after both base towers.
/// alive maps building id to alive; missing ids count as destroyed.
/// </summary>
public static bool IsAttackable(BuildingDef definition, IDictionary<string, bool> alive)
{
string prefix = definition.Team == 0 ? "blau" : "rot";
switch (definition.Tier)
{
case "outer":
return true;
case "inner":
return !Get(alive, prefix + "_" + definition.Lane + "_outer");
case "base":
foreach (string lane in Lanes)
{
if (!Get(alive, prefix + "_" + lane + "_outer") && !Get(alive, prefix + "_" + lane + "_inner"))
return true;
}
return false;
case "core":
return !Get(alive, prefix + "_base_1") && !Get(alive, prefix + "_base_2");
}
return false;
}
static bool Get(IDictionary<string, bool> alive, string id)
{
bool value;
return alive.TryGetValue(id, out value) && value;
}
}
}

View file

@ -0,0 +1,858 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>
/// Authoritative match (reference: lune_match.gd): six seats in two teams, persistent
/// ownership, planning drafts that are validated and committed atomically, combat,
/// settlement and victory. A match ends only when an Ancient falls: no round limit and no
/// decision by HP. Clients never run this; the authority applies their commands.
/// </summary>
public sealed class LuneMatch
{
public const int Ongoing = -2;
public const int Draw = -1;
public const string Grotto = "grotto";
public readonly LuneBalance Balance;
public readonly LuneMap Map;
public int SeedValue;
public int RoundNumber = 1;
public Phase Phase = Phase.Planning;
public int Winner = Ongoing;
public string EndNote = "";
public readonly List<Player> Players = new List<Player>();
/// <summary>Building HP between combats, in map order.</summary>
public readonly OrderedMap<string, double> BuildingHp = new OrderedMap<string, double>();
public LuneCombat Combat;
/// <summary>Result of the last combat; null before the first settlement.</summary>
public Settlement LastSettlement;
public readonly List<HistoryEntry> History = new List<HistoryEntry>();
public readonly BossState Boss = new BossState();
public readonly List<BossLogEntry> BossLog = new List<BossLogEntry>();
public List<string> BotProfiles = new List<string>();
public readonly Dictionary<int, BotMemory> BotMemory = new Dictionary<int, BotMemory>();
/// <summary>Remainder rotation per team so both teams follow the same distribution.</summary>
readonly int[] _rewardRotation = new int[2];
public LuneMatch(LuneBalance config = null)
{
Balance = config ?? LuneBalance.Default();
Map = new LuneMap(Balance);
}
public void Setup(MatchOptions options)
{
options = options ?? new MatchOptions();
SeedValue = options.Seed;
RoundNumber = 1;
Phase = Phase.Planning;
Winner = Ongoing;
EndNote = "";
History.Clear();
LastSettlement = null;
_rewardRotation[0] = 0;
_rewardRotation[1] = 0;
Boss.NextAvailableRound = 1;
Boss.Claim = null;
Boss.SettledRound = 0;
BossLog.Clear();
Players.Clear();
BotProfiles = new List<string>(options.Profiles ?? new List<string>());
BotMemory.Clear();
int human = options.HumanSlot;
List<string> bunds = options.Bunds ?? new List<string>();
for (int slot = 0; slot < 6; slot++)
{
string bund = slot < bunds.Count ? bunds[slot] : ((slot + SeedValue) % 2 == 0 ? "glutpakt" : "wurzelbund");
var player = new Player
{
Slot = slot, Team = slot / 3, Lane = LuneMap.Lanes[slot % 3], IsHuman = slot == human,
Name = slot == human ? "Du" : ContentData.BotNames[(slot + SeedValue) % ContentData.BotNames.Length],
Bund = bund, Gold = Balance.StartGold, Ready = slot != human, NextSeq = 1,
};
player.Draft = Snapshot(player);
Players.Add(player);
}
BuildingHp.Clear();
foreach (BuildingDef definition in Map.Buildings)
BuildingHp[definition.Id] = definition.MaxHp;
}
public Draft Snapshot(Player player)
{
var draft = new Draft { Gold = player.Gold, NextSeq = player.NextSeq, Hero = player.Hero != null ? player.Hero.Clone() : null, BossLane = "" };
foreach (RosterEntry entry in player.Roster)
draft.Roster.Add(entry.Clone());
return draft;
}
public int SlotOf(int team, string lane) { return team * 3 + LuneMap.LaneIndex(lane); }
public List<string> BaseIds(string bund) { return DataCatalog.FactionIds(bund); }
public bool GrottoAvailable() { return RoundNumber >= Boss.NextAvailableRound; }
// ---------------------------------------------------------------- planning commands
public CommandResult Submit(int slot, Command command)
{
if (Phase != Phase.Planning)
return Result(false, "Befehle sind nur in der Planung möglich.");
if (slot < 0 || slot >= Players.Count)
return Result(false, "Unbekannter Spieler.");
Player player = Players[slot];
Draft before = player.Draft.Clone();
CommandResult result = Apply(player, player.Draft, command ?? new Command());
if (result.Ok)
player.Undo.Add(before);
return result;
}
public CommandResult Undo(int slot)
{
Player player = Players[slot];
if (Phase != Phase.Planning || player.Undo.Count == 0)
return Result(false, "Nichts rückgängig zu machen.");
player.Draft = player.Undo[player.Undo.Count - 1];
player.Undo.RemoveAt(player.Undo.Count - 1);
return Result(true, "Letzte Planung zurückgenommen.");
}
static int Find(List<RosterEntry> roster, string uid)
{
for (int index = 0; index < roster.Count; index++)
{
if (roster[index].Uid == uid)
return index;
}
return -1;
}
static int NextSeq(Draft draft)
{
int seq = draft.NextSeq;
draft.NextSeq = seq + 1;
return seq;
}
bool ValidDestination(string lane)
{
return LuneMap.LaneIndex(lane) >= 0 || (lane == Grotto && GrottoAvailable());
}
CommandResult Apply(Player player, Draft draft, Command command)
{
List<RosterEntry> roster = draft.Roster;
HeroState hero = draft.Hero;
switch (command.Type ?? "")
{
case "buy":
{
string typeId = command.TypeId ?? "";
if (!BaseIds(player.Bund).Contains(typeId))
return Result(false, "Diese Truppe gehört nicht zu deinem Bund.");
if (roster.Count >= Balance.SupplyCap)
return Result(false, string.Format("Versorgungsgrenze erreicht ({0} Truppen).", Balance.SupplyCap));
UnitDef data = DataCatalog.GetUnit(typeId);
int cost = data.Cost;
if (draft.Gold < cost)
return Result(false, string.Format("Es fehlen {0} Gold.", cost - draft.Gold));
string lane = command.Lane ?? player.Lane;
if (LuneMap.LaneIndex(lane) < 0)
return Result(false, "Unbekannte Lane.");
draft.Gold -= cost;
// Per-player numbering keeps both teams' ids in the same order.
int seq = NextSeq(draft);
roster.Add(new RosterEntry { Uid = "p" + player.Slot + "-" + seq, Seq = seq, TypeId = typeId, Lane = lane, Invested = cost, Fresh = true });
return Result(true, data.DisplayName + " gekauft.");
}
case "upgrade":
{
int index = Find(roster, command.Uid ?? "");
if (index < 0)
return Result(false, "Diese Truppe gehört dir nicht.");
RosterEntry entry = roster[index];
string upgradeId = DataCatalog.UpgradeOf(entry.TypeId);
if (upgradeId.Length == 0)
return Result(false, "Bereits vollständig aufgewertet.");
UnitDef next = DataCatalog.GetUnit(upgradeId);
if (draft.Gold < next.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", next.Cost - draft.Gold));
draft.Gold -= next.Cost;
entry.TypeId = upgradeId;
entry.Invested += next.Cost;
return Result(true, "Zu " + next.DisplayName + " aufgewertet.");
}
case "sell":
{
int index = Find(roster, command.Uid ?? "");
if (index < 0)
return Result(false, "Diese Truppe gehört dir nicht.");
RosterEntry entry = roster[index];
// A purchase from this planning is cancelled at full price; committed troops sell at the refund rate.
int refund = entry.Fresh ? entry.Invested : GdMath.FloorI(entry.Invested * Balance.SellRefund);
draft.Gold += refund;
roster.RemoveAt(index);
return Result(true, string.Format("Für {0} Gold zurückgegeben.", refund));
}
case "assign":
{
string uid = command.Uid ?? "";
string lane = command.Lane ?? "";
if (!ValidDestination(lane))
return Result(false, lane == Grotto ? string.Format("Die Grotte ist bis Runde {0} gesperrt.", Boss.NextAvailableRound) : "Ungültige Zuweisung.");
if (hero != null && hero.Uid == uid)
{
hero.Lane = lane;
}
else
{
int index = Find(roster, uid);
if (index < 0)
return Result(false, "Ungültige Zuweisung.");
roster[index].Lane = lane;
}
return Result(true, lane == Grotto ? "In die Bossgrotte entsandt." : "Auf " + LuneMap.LaneNames[lane] + " eingeteilt.");
}
case "hero_buy":
{
string heroId = command.HeroId ?? "";
HeroSpec spec;
if (!ContentData.Heroes.TryGetValue(heroId, out spec))
return Result(false, "Unbekannter Held.");
if (hero != null)
return Result(false, "Pro Spieler ist ein aktiver Held erlaubt.");
if (draft.Gold < spec.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", spec.Cost - draft.Gold));
draft.Gold -= spec.Cost;
int seq = NextSeq(draft);
draft.Hero = new HeroState { Uid = "h" + player.Slot + "-" + seq, Seq = seq, HeroId = heroId, Lane = player.Lane, Xp = 0, Invested = spec.Cost, Fresh = true };
return Result(true, spec.Name + " angeworben.");
}
case "hero_cancel":
{
if (hero == null || !hero.Fresh)
return Result(false, "Nur ein Held aus dieser Planung lässt sich zurückgeben.");
int refund = hero.Invested;
foreach (string itemId in hero.Items)
refund += ContentData.Items[itemId].Cost;
draft.Gold += refund;
draft.Hero = null;
return Result(true, string.Format("Held zurückgegeben ({0} Gold).", refund));
}
case "item_buy":
{
string itemId = command.ItemId ?? "";
if (command.Uid != null && (hero == null || command.Uid != hero.Uid))
return Result(false, "Normale Truppen tragen keine Items.");
if (hero == null)
return Result(false, "Items benötigen einen Helden.");
ItemSpec item;
if (!ContentData.Items.TryGetValue(itemId, out item))
return Result(false, "Unbekanntes Item.");
if (hero.Items.Contains(itemId))
return Result(false, item.Name + " trägt dein Held bereits; Items sind einzigartig.");
if (hero.Items.Count >= LuneContent.ItemSlots)
return Result(false, string.Format("Alle {0} Itemplätze sind belegt.", LuneContent.ItemSlots));
if (LuneContent.LevelFor(hero.Xp) < item.MinLevel)
return Result(false, string.Format("{0} erfordert Stufe {1}.", item.Name, item.MinLevel));
if (draft.Gold < item.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", item.Cost - draft.Gold));
draft.Gold -= item.Cost;
hero.Items.Add(itemId);
hero.FreshItems.Add(itemId);
return Result(true, item.Name + " ausgerüstet.");
}
case "item_sell":
{
string itemId = command.ItemId ?? "";
if (hero == null || !hero.Items.Contains(itemId))
return Result(false, "Dein Held trägt dieses Item nicht.");
int cost = ContentData.Items[itemId].Cost;
bool fresh = hero.FreshItems.Contains(itemId);
int refund = fresh ? cost : GdMath.FloorI(cost * LuneContent.ItemRefund);
hero.Items.Remove(itemId);
hero.FreshItems.Remove(itemId);
draft.Gold += refund;
return Result(true, string.Format("Item für {0} Gold verkauft.", refund));
}
case "talent":
{
if (hero == null)
return Result(false, "Talente benötigen einen Helden.");
int tier = command.Tier ?? -1;
int choice = command.Choice ?? -1;
if (tier != hero.Talents.Count || tier < 0 || tier >= LuneContent.TalentLevels.Length || choice < 0 || choice > 1)
return Result(false, "Dieses Talent ist jetzt nicht wählbar.");
if (LuneContent.LevelFor(hero.Xp) < LuneContent.TalentLevels[tier])
return Result(false, string.Format("Talentstufe {0} erfordert Heldenstufe {1}.", tier + 1, LuneContent.TalentLevels[tier]));
TalentOption option = LuneContent.TalentsOf(hero.HeroId)[tier][choice];
hero.Talents.Add(option.Id);
return Result(true, "Talent " + option.Name + " gewählt.");
}
case "champion_book":
{
string championId = command.ChampionId ?? "";
string lane = command.Lane ?? player.Lane;
ChampionSpec spec;
if (!ContentData.Champions.TryGetValue(championId, out spec) || LuneMap.LaneIndex(lane) < 0)
return Result(false, "Ungültige Buchung.");
if (draft.NewBookings.Count >= LuneContent.MaxBookings)
return Result(false, string.Format("Höchstens {0} Angriffschampions pro Runde.", LuneContent.MaxBookings));
if (draft.Gold < spec.Cost)
return Result(false, string.Format("Es fehlen {0} Gold.", spec.Cost - draft.Gold));
draft.Gold -= spec.Cost;
int seq = NextSeq(draft);
draft.NewBookings.Add(new Booking { Uid = "c" + player.Slot + "-" + seq, Seq = seq, ChampionId = championId, Lane = lane, Cost = spec.Cost });
return Result(true, spec.Name + " für die nächste Runde auf " + LuneMap.LaneNames[lane] + " gebucht.");
}
case "champion_cancel":
{
string uid = command.Uid ?? "";
List<Booking> bookings = draft.NewBookings;
for (int index = 0; index < bookings.Count; index++)
{
if (bookings[index].Uid == uid)
{
draft.Gold += bookings[index].Cost;
bookings.RemoveAt(index);
return Result(true, "Buchung zurückgenommen.");
}
}
return Result(false, "Nur Buchungen aus dieser Planung lassen sich zurücknehmen.");
}
case "boss_lane":
{
BossClaim claim = Boss.Claim;
string lane = command.Lane ?? "";
if (claim == null || claim.DeployRound != RoundNumber || claim.ChooserSlot != player.Slot)
return Result(false, "Du bestimmst die Boss-Lane nicht.");
if (LuneMap.LaneIndex(lane) < 0)
return Result(false, "Unbekannte Lane.");
draft.BossLane = lane;
return Result(true, "Der Boss greift über " + LuneMap.LaneNames[lane] + " an.");
}
}
return Result(false, "Unbekannter Befehl.");
}
static CommandResult Result(bool ok, string message) { return new CommandResult(ok, message); }
public void SetReady(int slot, bool value = true)
{
if (Phase == Phase.Planning)
Players[slot].Ready = value;
}
public bool AllReady()
{
foreach (Player player in Players)
{
if (!player.Ready)
return false;
}
return true;
}
/// <summary>Public for everyone: the champion bookings and boss deployment of this round.</summary>
public List<Announcement> Announcements()
{
var result = new List<Announcement>();
foreach (Player player in Players)
{
foreach (Booking booking in player.Bookings)
{
if (booking.DeployRound == RoundNumber)
result.Add(new Announcement { Kind = "champion", Team = player.Team, Slot = player.Slot, Lane = booking.Lane, ChampionId = booking.ChampionId });
}
}
BossClaim claim = Boss.Claim;
if (claim != null && claim.DeployRound == RoundNumber)
result.Add(new Announcement { Kind = "boss", Team = claim.Team, Slot = claim.ChooserSlot, Lane = claim.Lane, Bounty = LuneContent.BossBountyFor(RoundNumber) });
return result;
}
// ---------------------------------------------------------------- phase changes
/// <summary>Validates every draft again and commits all of them at once, then starts combat.</summary>
public bool BeginCombat()
{
if (Phase != Phase.Planning)
return false;
foreach (Player player in Players)
{
if (!DraftValid(player))
player.Draft = Snapshot(player);
}
foreach (Player player in Players)
{
Draft draft = player.Draft;
player.Spent += GdMath.Max(0, player.Gold - draft.Gold);
player.Gold = draft.Gold;
player.NextSeq = draft.NextSeq;
player.Roster = new List<RosterEntry>();
foreach (RosterEntry entry in draft.Roster)
{
RosterEntry committed = entry.Clone();
committed.Fresh = false;
player.Roster.Add(committed);
}
player.Hero = draft.Hero != null ? draft.Hero.Clone() : null;
if (player.Hero != null)
{
player.Hero.Fresh = false;
player.Hero.FreshItems = new List<string>();
}
foreach (Booking booking in draft.NewBookings)
{
Booking committed = booking.Clone();
committed.DeployRound = RoundNumber + 1;
player.Bookings.Add(committed);
}
BossClaim claim = Boss.Claim;
if (claim != null && claim.ChooserSlot == player.Slot && draft.BossLane.Length > 0)
claim.Lane = draft.BossLane;
player.Undo.Clear();
}
Combat = new LuneCombat(Map, Balance, RoundNumber);
foreach (BuildingDef definition in Map.Buildings)
{
if (BuildingHp[definition.Id] > 0.0)
Combat.AddBuilding(definition, BuildingHp[definition.Id]);
}
SpawnWaves();
SpawnChampions();
SpawnDeployedBoss();
SpawnArmies();
Phase = Phase.Combat;
return true;
}
bool DraftValid(Player player)
{
Draft draft = player.Draft;
if (draft.Gold < 0 || draft.Roster.Count > Balance.SupplyCap)
return false;
List<string> allowed = BaseIds(player.Bund);
foreach (RosterEntry entry in draft.Roster)
{
string id = entry.TypeId;
string baseId = id;
foreach (string candidate in allowed)
{
if (DataCatalog.UpgradeOf(candidate) == id)
baseId = candidate;
}
if (!allowed.Contains(baseId) || !ValidDestination(entry.Lane))
return false;
}
HeroState hero = draft.Hero;
if (hero != null)
{
List<string> items = hero.Items;
if (items.Count > LuneContent.ItemSlots || !ValidDestination(hero.Lane))
return false;
for (int index = 0; index < items.Count; index++)
{
if (items.IndexOf(items[index]) != index || !ContentData.Items.ContainsKey(items[index]))
return false;
}
if (hero.Talents.Count > LuneContent.TalentLevels.Length)
return false;
}
return draft.NewBookings.Count <= LuneContent.MaxBookings;
}
void SpawnWaves()
{
List<UnitDef> wave = LuneWaves.Build(Balance, RoundNumber);
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
for (int index = 0; index < wave.Count; index++)
{
int row = index / 3;
double lateral = ((double)(index % 3) - 1.0) * 1.3;
Combat.Spawn("monster", team, lane, wave[index].Clone(), 11.0 - (double)row * 1.4, lateral);
}
}
}
}
void SpawnChampions()
{
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
int index = 0;
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
foreach (Booking booking in Players[slot].Bookings)
{
if (booking.DeployRound == RoundNumber && booking.Lane == lane)
{
UnitDef definition = LuneContent.ChampionDefinition(booking.ChampionId, RoundNumber, Balance);
Combat.Spawn("champion", team, lane, definition, 13.0, ((double)(index % 3) - 1.0) * 1.4, slot, booking.Uid);
index += 1;
}
}
}
}
}
}
void SpawnDeployedBoss()
{
BossClaim claim = Boss.Claim;
if (claim == null || claim.DeployRound != RoundNumber)
return;
UnitDef definition = LuneContent.BossDefinition(RoundNumber, Balance, true);
Combat.Spawn("boss", claim.Team, claim.Lane, definition, 14.0, 0.0, claim.ChooserSlot, "boss-" + RoundNumber);
BossLog.Add(new BossLogEntry { Round = RoundNumber, Event = "deployed", Team = claim.Team, Lane = claim.Lane });
}
/// <summary>One unit a player fields this combat: a troop or the hero.</summary>
public sealed class ArmyEntry
{
public int Slot;
public string Uid = "";
public int Seq;
public string Lane = "";
public string Role = "";
public int Invested;
public UnitDef Definition;
public string Kind = "troop";
}
public List<ArmyEntry> ArmyEntries(int slot)
{
Player player = Players[slot];
var entries = new List<ArmyEntry>();
foreach (RosterEntry entry in player.Roster)
{
entries.Add(new ArmyEntry { Slot = slot, Uid = entry.Uid, Seq = entry.Seq, Lane = entry.Lane, Role = RoleOf(entry.TypeId),
Invested = entry.Invested, Definition = TroopDefinition(entry.TypeId), Kind = "troop" });
}
HeroState hero = player.Hero;
if (hero != null)
{
int invested = hero.Invested;
foreach (string itemId in hero.Items)
invested += ContentData.Items[itemId].Cost;
UnitDef definition = LuneContent.HeroDefinition(hero);
definition.Role = ContentData.HeroRoles[hero.HeroId];
entries.Add(new ArmyEntry { Slot = slot, Uid = hero.Uid, Seq = hero.Seq, Lane = hero.Lane, Role = definition.Role,
Invested = invested, Definition = definition, Kind = "hero" });
}
return entries;
}
UnitDef TroopDefinition(string typeId)
{
UnitDef definition = DataCatalog.GetUnit(typeId);
definition.Role = RoleOf(typeId);
return definition;
}
/// <summary>Front rows first (front, melee, splash, ranged, support), then purchase order.</summary>
static List<ArmyEntry> Sorted(List<ArmyEntry> entries)
{
GdMath.SortStable(entries, (a, b) =>
{
int ra = Array.IndexOf(ContentData.RowOrder, a.Role);
int rb = Array.IndexOf(ContentData.RowOrder, b.Role);
return ra == rb ? a.Seq < b.Seq : ra < rb;
});
return entries;
}
static List<ArmyEntry> OnLane(List<ArmyEntry> entries, string lane)
{
return entries.FindAll(e => e.Lane == lane);
}
void SpawnArmies()
{
for (int team = 0; team < 2; team++)
{
foreach (string lane in LuneMap.Lanes)
{
// The lane owner's troops form the first rows; supporting players follow behind.
int owner = SlotOf(team, lane);
var order = new List<int> { owner };
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
if (slot != owner)
order.Add(slot);
}
var ordered = new List<ArmyEntry>();
foreach (int slot in order)
ordered.AddRange(Sorted(OnLane(ArmyEntries(slot), lane)));
for (int index = 0; index < ordered.Count; index++)
{
ArmyEntry entry = ordered[index];
int row = index / 4;
double lateral = ((double)(index % 4) - 1.5) * 1.2;
Combat.Spawn(entry.Kind, team, lane, entry.Definition, GdMath.Max(0.4, 7.2 - (double)row * 1.5), lateral, entry.Slot, entry.Uid);
}
}
}
// Grotto expeditions leave their lanes for the whole combat phase.
bool expedition = false;
for (int team = 0; team < 2; team++)
{
var ordered = new List<ArmyEntry>();
for (int slot = team * 3; slot < team * 3 + 3; slot++)
ordered.AddRange(Sorted(OnLane(ArmyEntries(slot), Grotto)));
Vec2 entryPoint = Map.GrottoEntry(team);
Vec2 inward = (Map.GrottoBossPoint() - entryPoint).Normalized();
Vec2 across = new Vec2(-inward.Y, inward.X) * (team == 0 ? 1.0 : -1.0);
for (int index = 0; index < ordered.Count; index++)
{
ArmyEntry entry = ordered[index];
Vec2 at = entryPoint - inward * (double)(index / 3) * 1.2 + across * ((double)(index % 3) - 1.0) * 1.1;
Combat.SpawnAt(entry.Kind, team, Grotto, entry.Definition, at, entry.Slot, entry.Uid);
expedition = true;
}
}
if (expedition)
Combat.AddGrottoBoss(LuneContent.BossDefinition(RoundNumber, Balance, false));
}
/// <summary>Role of a troop type (base or upgrade): front, melee, ranged, splash or support.</summary>
public string RoleOf(string typeId)
{
foreach (KeyValuePair<string, string> pair in ContentData.MatchRoles)
{
if (pair.Key == typeId || DataCatalog.UpgradeOf(pair.Key) == typeId)
return pair.Value;
}
return "melee";
}
public void StepCombat()
{
if (Phase != Phase.Combat || Combat == null)
return;
Combat.Step();
if (Combat.Finished)
Settle();
}
/// <summary>Runs the current combat phase to its end (tests, bot simulations).</summary>
public void FinishCombat()
{
while (Phase == Phase.Combat)
StepCombat();
}
/// <summary>Pays bounties, XP and income once; the match ends only by an Ancient.</summary>
public void Settle()
{
var gains = new int[6];
var laneKills = new OrderedMap<string, int>();
var buildingsLost = new List<string>();
var xpGain = new OrderedMap<string, int>();
foreach (CombatEvent ev in Combat.Events)
{
int dying = ev.Team;
int killer = dying < 2 ? 1 - dying : -1;
int bounty = ev.Bounty;
if (ev.Kind == "boss" && ev.Zone == "lane" && bounty > 0 && killer >= 0)
{
// The defenders of a deployed boss share its bounty evenly.
Split(gains, killer, bounty);
}
else if ((ev.Kind == "monster" || ev.Kind == "champion") && bounty > 0 && ev.Zone == "lane" && killer >= 0)
{
int owner = SlotOf(killer, ev.Lane);
int lanePart = (int)Math.Floor(bounty * Balance.LaneShare);
gains[owner] += lanePart;
Split(gains, killer, bounty - lanePart);
laneKills[ev.Lane] = laneKills.GetValueOrDefault(ev.Lane, 0) + 1;
}
else if (ev.Kind == "tower" || ev.Kind == "core")
{
buildingsLost.Add(ev.BuildingId);
Split(gains, killer, bounty);
}
if (ev.XpHeroes.Count > 0)
{
int share = GdMath.Max(1, ev.Xp / ev.XpHeroes.Count);
foreach (string uid in ev.XpHeroes)
xpGain[uid] = xpGain.GetValueOrDefault(uid, 0) + share;
}
}
foreach (LuneUnit unit in Combat.Units)
{
if (unit.IsBuilding())
BuildingHp[unit.BuildingId] = unit.Alive ? unit.Hp : 0.0;
else if (unit.Kind == "hero")
xpGain[unit.PersistentUid] = xpGain.GetValueOrDefault(unit.PersistentUid, 0) + LuneContent.XpParticipation + LuneContent.XpPerRound * RoundNumber;
}
foreach (Player player in Players)
{
int gained;
if (player.Hero != null && xpGain.TryGetValue(player.Hero.Uid, out gained))
player.Hero.Xp += gained;
}
int income = Balance.RoundIncome(RoundNumber);
foreach (Player player in Players)
{
int total = gains[player.Slot] + (Combat.DestroyedCores.Count > 0 ? 0 : income);
player.Gold += total;
player.Earned += total;
// Deployed bookings are consumed; they never return as property.
player.Bookings = player.Bookings.FindAll(b => b.DeployRound > RoundNumber);
}
// The claim is decided from this round's expedition before its units go home.
string bossResult = SettleBoss();
foreach (Player player in Players)
{
// An expedition is an order for one round: units return to their owner's lane.
foreach (RosterEntry entry in player.Roster)
{
if (entry.Lane == Grotto)
entry.Lane = player.Lane;
}
if (player.Hero != null && player.Hero.Lane == Grotto)
player.Hero.Lane = player.Lane;
}
LastSettlement = new Settlement { Round = RoundNumber, Gains = gains, Income = income, BuildingsLost = buildingsLost, EndReason = Combat.EndReason,
CombatTime = Combat.Elapsed, LaneKills = laneKills, Xp = xpGain, Boss = bossResult };
var entryHistory = new HistoryEntry { Round = RoundNumber, EndReason = Combat.EndReason, CombatTime = Combat.Elapsed, Buildings = AliveCount(),
Lost = new List<string>(buildingsLost), Boss = bossResult };
for (int slot = 0; slot < 6; slot++)
{
entryHistory.Gold[slot] = Players[slot].Gold;
entryHistory.HeroLevels[slot] = Players[slot].Hero == null ? 0 : LuneContent.LevelFor(Players[slot].Hero.Xp);
}
History.Add(entryHistory);
if (Combat.DestroyedCores.Count > 0)
{
Winner = Combat.DestroyedCores.Count >= 2 ? Draw : 1 - Combat.DestroyedCores[0];
EndNote = Winner == Draw ? "Beide Ancients fielen im selben Schritt." : "Ancient von " + (Combat.DestroyedCores[0] == 0 ? "Blau" : "Rot") + " zerstört.";
Phase = Phase.Over;
return;
}
RoundNumber += 1;
Phase = Phase.Planning;
foreach (Player player in Players)
{
player.Draft = Snapshot(player);
player.Undo.Clear();
player.Ready = !player.IsHuman;
}
}
/// <summary>
/// Boss bookkeeping at settlement; returns a short outcome label. Idempotent: a repeated
/// settlement signal for the same combat changes nothing ("repeat").
/// </summary>
public string SettleBoss()
{
int fought = Combat.RoundNumber;
if (Boss.SettledRound == fought)
return "repeat";
Boss.SettledRound = fought;
string result = "none";
BossClaim claim = Boss.Claim;
if (claim != null && claim.DeployRound == fought)
{
// The deployment happens exactly once; afterwards the claim is gone.
Boss.Claim = null;
result = "deployed";
}
if (Combat.GrottoOutcome == "success" && Boss.Claim == null && fought >= Boss.NextAvailableRound)
{
int team = Combat.GrottoClaimTeam;
int chooser = BossChooser(team);
Boss.Claim = new BossClaim { Team = team, WonRound = fought, DeployRound = fought + 1, ChooserSlot = chooser, Lane = Players[chooser].Lane };
Boss.NextAvailableRound = fought + 1 + LuneContent.BossLockRounds;
BossLog.Add(new BossLogEntry { Round = fought, Event = "won", Team = team, Chooser = chooser });
result = "won";
}
else if (Combat.GrottoOutcome == "failed")
{
BossLog.Add(new BossLogEntry { Round = fought, Event = "failed" });
result = result == "none" ? "failed" : result;
}
return result;
}
/// <summary>The team's player with the highest army value in the grotto; ties go to the lower slot.</summary>
int BossChooser(int team)
{
int best = team * 3;
int bestValue = -1;
for (int slot = team * 3; slot < team * 3 + 3; slot++)
{
int value = 0;
foreach (ArmyEntry entry in ArmyEntries(slot))
{
if (entry.Lane == Grotto)
value += entry.Invested;
}
if (value > bestValue)
{
best = slot;
bestValue = value;
}
}
return best;
}
/// <summary>Distributes an amount evenly over a team; the remainder rotates so no slot is favoured.</summary>
void Split(int[] gains, int team, int amount)
{
if (amount <= 0 || team < 0)
return;
int share = amount / 3;
int rest = amount % 3;
for (int offset = 0; offset < 3; offset++)
gains[team * 3 + offset] += share;
for (int index = 0; index < rest; index++)
gains[team * 3 + (_rewardRotation[team] + index) % 3] += 1;
_rewardRotation[team] = (_rewardRotation[team] + rest) % 3;
}
int[] AliveCount()
{
var counts = new int[2];
foreach (KeyValuePair<string, double> pair in BuildingHp)
{
if (pair.Value > 0.0)
counts[pair.Key.StartsWith("blau", StringComparison.Ordinal) ? 0 : 1] += 1;
}
return counts;
}
public bool BuildingAlive(string id)
{
double hp;
return BuildingHp.TryGetValue(id, out hp) && hp > 0.0;
}
/// <summary>Live state for displays: during combat the combat instance is authoritative.</summary>
public bool BuildingAliveNow(string id)
{
LuneUnit unit;
if (Phase == Phase.Combat && Combat != null && Combat.Buildings.TryGetValue(id, out unit))
return unit.Alive;
return BuildingAlive(id);
}
public int TowersAlive(int team, string lane, bool live = false)
{
string prefix = team == 0 ? "blau" : "rot";
Func<string, bool> probe = live ? (Func<string, bool>)BuildingAliveNow : BuildingAlive;
return (probe(prefix + "_" + lane + "_outer") ? 1 : 0) + (probe(prefix + "_" + lane + "_inner") ? 1 : 0);
}
public double CoreHp(int team) { return BuildingHp[team == 0 ? "blau_ancient" : "rot_ancient"]; }
public int GoldOf(int slot) { return Phase == Phase.Planning ? Players[slot].Draft.Gold : Players[slot].Gold; }
}
}

View file

@ -0,0 +1,253 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core
{
/// <summary>Timed effect of one family (e.g. "root_grasp"): kind, strength, time left.</summary>
public sealed class Effect
{
/// <summary>movement_slow, attack_slow, attack_speed, damage, reduction, healing_reduction.</summary>
public string Kind = "";
public double Amount;
public double Remaining;
}
/// <summary>Damage over time of one family (e.g. "burn"): ticks once per second.</summary>
public sealed class DamageOverTime
{
public double Damage;
public double Remaining;
public double NextTick = 1.0;
public int AttackType;
}
/// <summary>
/// One combat instance (reference: lune_unit.gd). Persistent ownership stays in LuneMatch;
/// this object lives for one combat phase. Damage, heals and effects are queued and applied
/// together by LuneCombat, so processing order never decides who strikes first.
/// </summary>
public sealed class LuneUnit
{
/// <summary>Attack type (row) against armour type (column): Klinge, Durchschlag, Impuls.</summary>
public static readonly double[,] DamageMatrix = { { 1.30, 0.75, 1.00 }, { 1.00, 1.30, 0.75 }, { 0.75, 1.00, 1.30 } };
public static readonly string[] MobileKinds = { "troop", "hero", "monster", "champion", "boss" };
/// <summary>Sequential per combat, assigned in spawn order (deterministic).</summary>
public int Id;
/// <summary>Spawn index within the own team; mirrored units share it (fair staggering).</summary>
public int TeamIndex;
/// <summary>troop, hero, monster, champion, boss, tower or core.</summary>
public string Kind = "troop";
public int Team;
public int OwnerSlot = -1;
public string PersistentUid = "";
public string Lane = "mid";
/// <summary>lane or grotto.</summary>
public string Zone = "lane";
public UnitDef Def = new UnitDef();
public string BuildingId = "";
public Vec2 Position;
public Vec2 PreviousPosition;
public double Facing;
public double Hp = 1.0;
public double MaxHp = 1.0;
public bool Alive = true;
public LuneUnit Target;
public double Progress;
public double Lateral;
public double AttackProgress;
public int HitCount;
public double CombatTime;
public double Shield;
public double ShieldRemaining = double.PositiveInfinity;
public readonly OrderedMap<string, Effect> Effects = new OrderedMap<string, Effect>();
public readonly OrderedMap<string, DamageOverTime> Dots = new OrderedMap<string, DamageOverTime>();
public double AbilityReady = 6.0;
public bool ReachedBase;
/// <summary>The grotto boss waits while two expeditions still fight each other.</summary>
public bool Dormant;
public double AuraReady = 4.0;
// Presentation-only counters; never read by the rules.
public int AttackSerial;
public int AbilitySerial;
public double HitFlash;
public bool InRange;
public double DeathTime = -1.0;
public bool IsBuilding() { return Kind == "tower" || Kind == "core"; }
public bool IsMobile() { return Array.IndexOf(MobileKinds, Kind) >= 0; }
public double AttackRange() { return Def.AttackRange; }
public double Speed() { return Def.Speed; }
public int ArmorType() { return GdMath.Clamp(Def.ArmorType, 0, 2); }
public string Ability() { return Def.Ability; }
public bool IsBoss() { return Kind == "boss" || Def.IsBoss; }
public double Mitigated(double raw, int attackType)
{
double value = GdMath.Max(0.0, raw) * DamageMatrix[GdMath.Clamp(attackType, 0, 2), ArmorType()];
return value * (1.0 - DamageReduction());
}
/// <summary>Applies an already mitigated amount through the shield.</summary>
public void Take(double amount)
{
if (!Alive || amount <= 0.0)
return;
double absorbed = GdMath.Min(Shield, amount);
Shield -= absorbed;
Hp = GdMath.Max(0.0, Hp - (amount - absorbed));
HitFlash = 0.18;
}
public void Heal(double amount)
{
if (Alive && !IsBuilding())
Hp = GdMath.Min(MaxHp, Hp + GdMath.Max(0.0, amount) * (1.0 - StrongestEffect("healing_reduction")));
}
public void GrantShield(double amount, double duration = double.PositiveInfinity)
{
if (Alive && amount > Shield)
{
Shield = amount;
ShieldRemaining = duration;
}
}
/// <summary>Buildings ignore crowd control and buffs; an existing family keeps the stronger values.</summary>
public void ApplyEffect(string family, string kind, double amount, double duration)
{
if (!Alive || IsBuilding())
return;
Effect effect;
if (Effects.TryGetValue(family, out effect))
{
effect.Amount = GdMath.Max(effect.Amount, amount);
effect.Remaining = GdMath.Max(effect.Remaining, duration);
}
else
{
Effects[family] = new Effect { Kind = kind, Amount = amount, Remaining = duration };
}
}
public void ApplyDot(string family, double damage, double duration, int attackType)
{
if (!Alive || IsBuilding())
return;
DamageOverTime dot;
if (Dots.TryGetValue(family, out dot))
{
if (damage > dot.Damage)
{
dot.Damage = damage;
dot.AttackType = attackType;
}
dot.Remaining = GdMath.Max(dot.Remaining, duration);
}
else
{
Dots[family] = new DamageOverTime { Damage = damage, Remaining = duration, NextTick = 1.0, AttackType = attackType };
}
}
public double StrongestEffect(string kind)
{
double amount = 0.0;
IReadOnlyList<Effect> values = Effects.Values;
for (int i = 0; i < values.Count; i++)
{
if (values[i].Kind == kind)
amount = GdMath.Max(amount, values[i].Amount);
}
return amount;
}
public double DamageBonus()
{
double result = 0.0;
IReadOnlyList<Effect> values = Effects.Values;
for (int i = 0; i < values.Count; i++)
{
if (values[i].Kind == "damage")
result += values[i].Amount;
}
return GdMath.Min(0.30, result) + Def.DamageBonus;
}
public double DamageReduction()
{
double result = StrongestEffect("reduction");
switch (Ability())
{
case "g03":
if (CombatTime < 8.0 - 0.000001)
result = GdMath.Max(result, 0.15);
break;
case "g04":
result = GdMath.Max(result, CombatTime < 10.0 - 0.000001 ? 0.25 : 0.10);
break;
}
return GdMath.Min(0.35, result + Def.ReductionBonus);
}
public double AttackSpeedFactor()
{
double slowing = StrongestEffect("attack_slow") * (IsBoss() ? 0.5 : 1.0);
double bonus = Def.AttackSpeedBonus;
return 1.0 + GdMath.Clamp(StrongestEffect("attack_speed") - slowing + bonus, -0.25, 0.5);
}
public bool HasTalent(string id) { return Def.Talents.Contains(id); }
public string Role() { return Def.Role; }
public double MovementFactor()
{
return 1.0 - GdMath.Min(0.35, StrongestEffect("movement_slow") * (IsBoss() ? 0.5 : 1.0));
}
/// <summary>Advances effect and shield timers; returns the DoT damage due this step.</summary>
public double AdvanceStatus(double delta)
{
CombatTime += delta;
HitFlash = GdMath.Max(0.0, HitFlash - delta);
var families = new List<string>(Effects.Keys);
foreach (string family in families)
{
Effect effect = Effects[family];
effect.Remaining -= delta;
if (effect.Remaining <= 0.000001)
Effects.Remove(family);
}
if (!double.IsPositiveInfinity(ShieldRemaining))
{
ShieldRemaining -= delta;
if (ShieldRemaining <= 0.000001)
Shield = 0.0;
}
double dotDamage = 0.0;
var dotFamilies = new List<string>(Dots.Keys);
foreach (string family in dotFamilies)
{
DamageOverTime dot = Dots[family];
dot.NextTick -= GdMath.Min(delta, GdMath.Max(0.0, dot.Remaining));
while (dot.NextTick <= 0.000001)
{
dotDamage += Mitigated(dot.Damage, dot.AttackType);
dot.NextTick += 1.0;
}
dot.Remaining -= delta;
if (dot.Remaining <= 0.000001)
Dots.Remove(family);
}
return dotDamage;
}
}
}

View file

@ -0,0 +1,236 @@
using System.Collections.Generic;
namespace Luneraria.Core
{
public enum Phase { Planning, Combat, Over }
/// <summary>One persistent troop of a player (committed roster or planning draft).</summary>
public sealed class RosterEntry
{
/// <summary>"p{slot}-{seq}": unique per match; ownership is the slot in the uid.</summary>
public string Uid = "";
public int Seq;
public string TypeId = "";
/// <summary>top, mid, bot or grotto (one round).</summary>
public string Lane = "";
public int Invested;
/// <summary>Bought in the current planning (full refund on sale).</summary>
public bool Fresh;
public RosterEntry Clone() { return (RosterEntry)MemberwiseClone(); }
}
/// <summary>The single hero of a player.</summary>
public sealed class HeroState
{
public string Uid = "";
public int Seq;
public string HeroId = "";
public string Lane = "";
public int Xp;
public List<string> Items = new List<string>();
public List<string> Talents = new List<string>();
public int Invested;
public bool Fresh;
/// <summary>Items bought in the current planning (full refund on sale).</summary>
public List<string> FreshItems = new List<string>();
public HeroState Clone()
{
var copy = (HeroState)MemberwiseClone();
copy.Items = new List<string>(Items);
copy.Talents = new List<string>(Talents);
copy.FreshItems = new List<string>(FreshItems);
return copy;
}
}
/// <summary>An attack champion booking: bought in round n, fights once in round n + 1.</summary>
public sealed class Booking
{
public string Uid = "";
public int Seq;
public string ChampionId = "";
public string Lane = "";
public int Cost;
/// <summary>0 while uncommitted (draft); the round it fights in once committed.</summary>
public int DeployRound;
public Booking Clone() { return (Booking)MemberwiseClone(); }
}
/// <summary>Private planning of one player; validated and committed atomically at combat start.</summary>
public sealed class Draft
{
public int Gold;
public List<RosterEntry> Roster = new List<RosterEntry>();
public int NextSeq;
public HeroState Hero;
public List<Booking> NewBookings = new List<Booking>();
/// <summary>Lane chosen by the boss chooser in this planning, "" if unchanged.</summary>
public string BossLane = "";
public Draft Clone()
{
var copy = new Draft { Gold = Gold, NextSeq = NextSeq, BossLane = BossLane, Hero = Hero != null ? Hero.Clone() : null };
foreach (RosterEntry entry in Roster)
copy.Roster.Add(entry.Clone());
foreach (Booking booking in NewBookings)
copy.NewBookings.Add(booking.Clone());
return copy;
}
}
/// <summary>One of six seats. Team = slot / 3, lane = slot % 3 (top, mid, bot).</summary>
public sealed class Player
{
public int Slot;
public int Team;
public string Lane = "";
public bool IsHuman;
public string Name = "";
public string Bund = "";
public int Gold;
public List<RosterEntry> Roster = new List<RosterEntry>();
public HeroState Hero;
public List<Booking> Bookings = new List<Booking>();
public bool Ready;
public int Earned;
public int Spent;
public int NextSeq;
public Draft Draft;
public List<Draft> Undo = new List<Draft>();
}
public sealed class BossClaim
{
public int Team;
public int WonRound;
public int DeployRound;
public int ChooserSlot;
public string Lane = "";
public BossClaim Clone() { return (BossClaim)MemberwiseClone(); }
}
/// <summary>Public boss state: the grotto opens from NextAvailableRound; Claim is null or the conquest.</summary>
public sealed class BossState
{
public int NextAvailableRound = 1;
public BossClaim Claim;
/// <summary>Round whose settlement was already applied (idempotence), 0 = none.</summary>
public int SettledRound;
}
public sealed class BossLogEntry
{
public int Round;
/// <summary>deployed, won or failed.</summary>
public string Event = "";
public int Team = -1;
public string Lane = "";
public int Chooser = -1;
}
/// <summary>Public announcement of the current round: a champion or the deployed boss.</summary>
public sealed class Announcement
{
/// <summary>champion or boss.</summary>
public string Kind = "";
public int Team;
public int Slot;
public string Lane = "";
public string ChampionId = "";
public int Bounty;
}
public sealed class Settlement
{
public int Round;
public int[] Gains = new int[6];
public int Income;
public List<string> BuildingsLost = new List<string>();
public string EndReason = "";
public double CombatTime;
public OrderedMap<string, int> LaneKills = new OrderedMap<string, int>();
public OrderedMap<string, int> Xp = new OrderedMap<string, int>();
/// <summary>none, won, failed, deployed or repeat.</summary>
public string Boss = "none";
}
public sealed class HistoryEntry
{
public int Round;
public string EndReason = "";
public double CombatTime;
public int[] Buildings = new int[2];
public int[] Gold = new int[6];
public List<string> Lost = new List<string>();
public string Boss = "";
public int[] HeroLevels = new int[6];
}
public sealed class MatchOptions
{
public int Seed = 1;
/// <summary>-1 for six bots.</summary>
public int HumanSlot = 1;
/// <summary>Six Bünde; missing entries follow the seed.</summary>
public List<string> Bunds = new List<string>();
/// <summary>Optional fixed bot profile per slot (balance runs).</summary>
public List<string> Profiles = new List<string>();
}
/// <summary>Bot-private notes per slot, derived only from its own actions and public outcomes.</summary>
public sealed class BotMemory
{
public bool HasChampionRound;
public int ChampionRound;
public bool HasBossFields;
public double Bar;
public int Sent;
}
public struct CommandResult
{
public bool Ok;
public string Message;
public CommandResult(bool ok, string message)
{
Ok = ok;
Message = message;
}
}
/// <summary>
/// A player's intent during planning. Clients only ever send these; the authority applies
/// them through LuneMatch.Submit. Null fields mean "not given" (e.g. buy without lane uses
/// the player's own lane).
/// </summary>
public sealed class Command
{
public string Type = "";
public string TypeId;
public string Lane;
public string Uid;
public string HeroId;
public string ItemId;
public string ChampionId;
public int? Tier;
public int? Choice;
public static Command Buy(string typeId, string lane = null) { return new Command { Type = "buy", TypeId = typeId, Lane = lane }; }
public static Command Upgrade(string uid) { return new Command { Type = "upgrade", Uid = uid }; }
public static Command Sell(string uid) { return new Command { Type = "sell", Uid = uid }; }
public static Command Assign(string uid, string lane) { return new Command { Type = "assign", Uid = uid, Lane = lane }; }
public static Command HeroBuy(string heroId) { return new Command { Type = "hero_buy", HeroId = heroId }; }
public static Command HeroCancel() { return new Command { Type = "hero_cancel" }; }
public static Command ItemBuy(string itemId, string uid = null) { return new Command { Type = "item_buy", ItemId = itemId, Uid = uid }; }
public static Command ItemSell(string itemId) { return new Command { Type = "item_sell", ItemId = itemId }; }
public static Command Talent(int tier, int choice) { return new Command { Type = "talent", Tier = tier, Choice = choice }; }
public static Command ChampionBook(string championId, string lane = null) { return new Command { Type = "champion_book", ChampionId = championId, Lane = lane }; }
public static Command ChampionCancel(string uid) { return new Command { Type = "champion_cancel", Uid = uid }; }
public static Command BossLaneChoice(string lane) { return new Command { Type = "boss_lane", Lane = lane }; }
}
}

View file

@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
namespace Luneraria.Core.Tests
{
/// <summary>
/// A test suite in the style of the Godot reference tests: named checks, counted, failures
/// collected with their label. The same suites run in the .NET harness (tools/dotnet) and in
/// Unity's Test Runner (Unity/CoreSuiteTests.cs); check counts stay comparable with Godot.
/// </summary>
public abstract class CheckSuite
{
public int Checks { get; private set; }
public readonly List<string> Failures = new List<string>();
public readonly List<string> Notes = new List<string>();
/// <summary>Folder with the golden files (Assets/Luneraria/Tests/Core/Golden).</summary>
public string GoldenDirectory = "";
public abstract string Name { get; }
protected abstract void RunChecks();
public bool Run()
{
Checks = 0;
Failures.Clear();
Notes.Clear();
try
{
RunChecks();
}
catch (Exception error)
{
Failures.Add("EXCEPTION " + error.GetType().Name + ": " + error.Message + "\n" + error.StackTrace);
}
return Failures.Count == 0;
}
protected void Check(bool ok, string label)
{
Checks += 1;
if (!ok)
Failures.Add(label);
}
protected void Note(string text)
{
Notes.Add(text);
}
public static IList<CheckSuite> All()
{
return new List<CheckSuite> { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite() };
}
}
}

View file

@ -0,0 +1,308 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Compares every rule table and the computed map of the C# core with the export of the
/// Godot reference (Golden/content.json, tools/godot-golden/content_export.gd): units,
/// monsters, sends, heroes, talents, items, champions, bot profiles (order), balance
/// defaults, lane polylines and building positions. Tables must match exactly; the lane
/// geometry is computed and must match within 1e-5 m (the largest deviation is noted).
/// </summary>
public sealed class ContentSuite : CheckSuite
{
public override string Name { get { return "content"; } }
const double GeometryTolerance = 0.00001;
protected override void RunChecks()
{
var data = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(GoldenDirectory, "content.json"))));
CompareUnits("units", MiniJson.Obj(data["units"]), ContentData.Units);
CompareUnits("enemies", MiniJson.Obj(data["enemies"]), ContentData.Enemies);
CompareUnits("sends", MiniJson.Obj(data["sends"]), ContentData.Sends);
CompareHeroes(MiniJson.Obj(data["heroes"]));
CompareItems(MiniJson.Obj(data["items"]));
CompareChampions(MiniJson.Obj(data["champions"]));
CompareTalents(MiniJson.Obj(data["talents"]));
CompareConstants(data);
CompareProfiles(MiniJson.Obj(data["bot_profiles"]));
CompareBalance(MiniJson.Obj(data["balance"]));
CompareMap(MiniJson.Obj(data["lanes"]), MiniJson.Arr(data["buildings"]));
}
void CompareUnits(string label, OrderedMap<string, object> expected, OrderedMap<string, UnitDef> actual)
{
Check(expected.Keys.SequenceEqual(actual.Keys), label + ": same ids in the same order");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
UnitDef unit;
if (!actual.TryGetValue(pair.Key, out unit))
{
Check(false, label + ": missing " + pair.Key);
continue;
}
var problems = new List<string>();
Compare(problems, entry, "id", unit.Id);
Compare(problems, entry, "display_name", unit.DisplayName);
Compare(problems, entry, "cost", unit.Cost);
Compare(problems, entry, "upgrade_id", unit.UpgradeId);
Compare(problems, entry, "max_hp", unit.MaxHp);
Compare(problems, entry, "damage", unit.Damage);
Compare(problems, entry, "attack_type", unit.AttackType);
Compare(problems, entry, "armor_type", unit.ArmorType);
Compare(problems, entry, "attack_range", unit.AttackRange);
Compare(problems, entry, "speed", unit.Speed);
Compare(problems, entry, "ability", unit.Ability);
Compare(problems, entry, "leak_value", unit.LeakValue);
Compare(problems, entry, "bounty", unit.Bounty);
Compare(problems, entry, "ability_description", unit.AbilityDescription);
Compare(problems, entry, "is_boss", unit.IsBoss);
Compare(problems, entry, "income_gain", unit.IncomeGain);
Check(problems.Count == 0, label + " " + pair.Key + " matches the reference " + string.Join(", ", problems));
}
}
static void Compare(List<string> problems, OrderedMap<string, object> entry, string key, object actual)
{
object expected;
if (!entry.TryGetValue(key, out expected))
return;
bool same;
if (actual is double d)
same = MiniJson.Num(expected) == d;
else if (actual is int i)
same = MiniJson.Int(expected) == i;
else if (actual is bool b)
same = expected is bool eb && eb == b;
else
same = MiniJson.Str(expected) == MiniJson.Str(actual);
if (!same)
problems.Add(key + ": " + MiniJson.Str(expected) + " != " + MiniJson.Str(actual));
}
void CompareHeroes(OrderedMap<string, object> expected)
{
Check(expected.Keys.SequenceEqual(ContentData.Heroes.Keys), "heroes: same ids in the same order");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
HeroSpec hero = ContentData.Heroes[pair.Key];
var problems = new List<string>();
Compare(problems, entry, "name", hero.Name);
Compare(problems, entry, "title", hero.Title);
Compare(problems, entry, "model", hero.Model);
Compare(problems, entry, "cost", hero.Cost);
Compare(problems, entry, "role", hero.RoleText);
Compare(problems, entry, "summary", hero.Summary);
var baseDef = MiniJson.Obj(entry["def"]);
Compare(problems, baseDef, "max_hp", hero.Base.MaxHp);
Compare(problems, baseDef, "damage", hero.Base.Damage);
Compare(problems, baseDef, "attack_type", hero.Base.AttackType);
Compare(problems, baseDef, "armor_type", hero.Base.ArmorType);
Compare(problems, baseDef, "attack_range", hero.Base.AttackRange);
Compare(problems, baseDef, "speed", hero.Base.Speed);
Compare(problems, baseDef, "ability", hero.Base.Ability);
Check(problems.Count == 0, "hero " + pair.Key + " matches " + string.Join(", ", problems));
}
}
void CompareItems(OrderedMap<string, object> expected)
{
Check(expected.Keys.SequenceEqual(ContentData.Items.Keys), "items: same ids in the same order");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
ItemSpec item = ContentData.Items[pair.Key];
var problems = new List<string>();
Compare(problems, entry, "name", item.Name);
Compare(problems, entry, "cost", item.Cost);
Compare(problems, entry, "text", item.Text);
Compare(problems, entry, "hp_flat", item.HpFlat);
Compare(problems, entry, "damage", item.Damage);
Compare(problems, entry, "reduction", item.Reduction);
Compare(problems, entry, "attack_speed", item.AttackSpeed);
Compare(problems, entry, "regen", item.Regen);
Compare(problems, entry, "splash", item.Splash);
Compare(problems, entry, "min_level", item.MinLevel);
Check(problems.Count == 0, "item " + pair.Key + " matches " + string.Join(", ", problems));
}
}
void CompareChampions(OrderedMap<string, object> expected)
{
Check(expected.Keys.SequenceEqual(ContentData.Champions.Keys), "champions: same ids in the same order");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
ChampionSpec spec = ContentData.Champions[pair.Key];
var problems = new List<string>();
Compare(problems, entry, "name", spec.Name);
Compare(problems, entry, "role", spec.RoleText);
Compare(problems, entry, "model", spec.Model);
Compare(problems, entry, "cost", spec.Cost);
Compare(problems, entry, "base", spec.Base);
Compare(problems, entry, "text", spec.Text);
Compare(problems, entry, "priority", spec.Priority);
Compare(problems, entry, "siege_factor", spec.SiegeFactor);
Compare(problems, entry, "view_scale", spec.ViewScale);
Check(problems.Count == 0, "champion " + pair.Key + " matches " + string.Join(", ", problems));
}
}
void CompareTalents(OrderedMap<string, object> expected)
{
bool same = expected.Count == ContentData.Talents.Count;
foreach (KeyValuePair<string, object> pair in expected)
{
TalentOption[][] tiers;
if (!ContentData.Talents.TryGetValue(pair.Key, out tiers))
{
same = false;
continue;
}
List<object> expectedTiers = MiniJson.Arr(pair.Value);
same = same && expectedTiers.Count == tiers.Length;
for (int tier = 0; tier < Math.Min(expectedTiers.Count, tiers.Length); tier++)
{
List<object> options = MiniJson.Arr(expectedTiers[tier]);
same = same && options.Count == tiers[tier].Length;
for (int choice = 0; choice < Math.Min(options.Count, tiers[tier].Length); choice++)
{
var option = MiniJson.Obj(options[choice]);
same = same && MiniJson.Str(option["id"]) == tiers[tier][choice].Id && MiniJson.Str(option["name"]) == tiers[tier][choice].Name
&& MiniJson.Str(option["text"]) == tiers[tier][choice].Text;
}
}
}
Check(same, "talents: every tier and choice matches");
}
void CompareConstants(OrderedMap<string, object> data)
{
List<object> levels = MiniJson.Arr(data["level_xp"]);
Check(levels.Select(MiniJson.Int).SequenceEqual(ContentData.LevelXp), "level XP table");
Check(MiniJson.Arr(data["talent_levels"]).Select(MiniJson.Int).SequenceEqual(ContentData.TalentLevels), "talent levels");
var constants = MiniJson.Obj(data["content_constants"]);
var expected = new Dictionary<string, double>
{
{ "HP_PER_LEVEL", LuneContent.HpPerLevel }, { "DAMAGE_PER_LEVEL", LuneContent.DamagePerLevel }, { "XP_PARTICIPATION", LuneContent.XpParticipation },
{ "XP_PER_ROUND", LuneContent.XpPerRound }, { "XP_RADIUS", LuneContent.XpRadius }, { "ITEM_SLOTS", LuneContent.ItemSlots },
{ "ITEM_REFUND", LuneContent.ItemRefund }, { "CHAMPION_HP", LuneContent.ChampionHp }, { "CHAMPION_DAMAGE", LuneContent.ChampionDamage },
{ "CHAMPION_BOUNTY", LuneContent.ChampionBounty }, { "CHAMPION_BOUNTY_PER_ROUND", LuneContent.ChampionBountyPerRound },
{ "MAX_BOOKINGS", LuneContent.MaxBookings }, { "BOSS_GROTTO_HP", LuneContent.BossGrottoHp }, { "BOSS_GROTTO_DAMAGE", LuneContent.BossGrottoDamage },
{ "BOSS_DEPLOY_HP", LuneContent.BossDeployHp }, { "BOSS_DEPLOY_DAMAGE", LuneContent.BossDeployDamage }, { "BOSS_BOUNTY", LuneContent.BossBounty },
{ "BOSS_BOUNTY_PER_ROUND", LuneContent.BossBountyPerRound }, { "BOSS_LOCK_ROUNDS", LuneContent.BossLockRounds }, { "BOSS_XP", LuneContent.BossXp },
{ "BUILDING_XP", LuneContent.BuildingXp },
};
var wrong = expected.Where(p => MiniJson.Num(constants[p.Key]) != p.Value).Select(p => p.Key).ToList();
Check(wrong.Count == 0 && MiniJson.Str(constants["BOSS_MODEL"]) == LuneContent.BossModel, "content constants " + string.Join(", ", wrong));
Check(MiniJson.Obj(data["match_roles"]).Keys.SequenceEqual(ContentData.MatchRoles.Keys)
&& MiniJson.Obj(data["match_roles"]).All(p => ContentData.MatchRoles[p.Key] == MiniJson.Str(p.Value)), "troop roles in reference order");
Check(MiniJson.Obj(data["hero_roles"]).All(p => ContentData.HeroRoles[p.Key] == MiniJson.Str(p.Value)), "hero roles");
Check(MiniJson.Arr(data["row_order"]).Select(MiniJson.Str).SequenceEqual(ContentData.RowOrder), "formation row order");
Check(MiniJson.Arr(data["bot_names"]).Select(MiniJson.Str).SequenceEqual(ContentData.BotNames), "bot names");
Check(MiniJson.Arr(data["waves_melee"]).Select(MiniJson.Str).SequenceEqual(ContentData.WavesMelee), "melee monster types");
var bot = MiniJson.Obj(data["bot_constants"]);
Check(MiniJson.Num(bot["BOSS_BAR"]) == LuneBot.BossBar && MiniJson.Num(bot["BOSS_BAR_STEP"]) == LuneBot.BossBarStep, "bot boss thresholds");
}
void CompareProfiles(OrderedMap<string, object> expected)
{
Check(expected.Keys.SequenceEqual(ContentData.BotProfiles.Keys), "bot profiles in reference order (seed rotation)");
foreach (KeyValuePair<string, object> pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
BotProfile profile = ContentData.BotProfiles[pair.Key];
bool same = MiniJson.Num(entry["front"]) == profile.Front && MiniJson.Num(entry["melee"]) == profile.Melee && MiniJson.Num(entry["ranged"]) == profile.Ranged
&& MiniJson.Num(entry["splash"]) == profile.Splash && MiniJson.Num(entry["support"]) == profile.Support
&& MiniJson.Int(entry["upgrade_from"]) == profile.UpgradeFrom && MiniJson.Num(entry["support_lane"]) == profile.SupportLane
&& MiniJson.Str(entry["hero"]) == profile.Hero && MiniJson.Int(entry["hero_round"]) == profile.HeroRound
&& MiniJson.Arr(entry["items"]).Select(MiniJson.Str).SequenceEqual(profile.Items)
&& MiniJson.Arr(entry["talents"]).Select(MiniJson.Int).SequenceEqual(profile.TalentChoices)
&& MiniJson.Str(entry["champion"]) == profile.Champion
&& (entry.ContainsKey("boss_from") ? MiniJson.Int(entry["boss_from"]) == profile.BossFrom && MiniJson.Int(entry["boss_troops"]) == profile.BossTroops : !profile.HuntsBoss);
Check(same, "bot profile " + pair.Key);
}
}
void CompareBalance(OrderedMap<string, object> expected)
{
LuneBalance balance = LuneBalance.Default();
var actual = new Dictionary<string, object>
{
{ "sim_step", balance.SimStep }, { "combat_time_limit", balance.CombatTimeLimit }, { "march_speed_factor", balance.MarchSpeedFactor },
{ "aggro_radius", balance.AggroRadius }, { "leash_radius", balance.LeashRadius }, { "lane_half_width", balance.LaneHalfWidth },
{ "start_gold", balance.StartGold }, { "base_income", balance.BaseIncome }, { "income_per_round", balance.IncomePerRound },
{ "income_growth_cap", balance.IncomeGrowthCap }, { "supply_cap", balance.SupplyCap }, { "sell_refund", balance.SellRefund },
{ "lane_share", balance.LaneShare }, { "wave_base_count", balance.WaveBaseCount }, { "wave_count_every", balance.WaveCountEvery },
{ "wave_max_count", balance.WaveMaxCount }, { "wave_hp_growth", balance.WaveHpGrowth }, { "wave_damage_growth", balance.WaveDamageGrowth },
{ "wave_growth_quadratic", balance.WaveGrowthQuadratic }, { "monster_bounty_scale", balance.MonsterBountyScale },
{ "bounty_growth", balance.BountyGrowth }, { "tower_outer_hp", balance.TowerOuterHp }, { "tower_inner_hp", balance.TowerInnerHp },
{ "base_tower_hp", balance.BaseTowerHp }, { "core_hp", balance.CoreHp }, { "tower_damage", balance.TowerDamage },
{ "base_tower_damage", balance.BaseTowerDamage }, { "tower_range", balance.TowerRange }, { "tower_attack_interval", balance.TowerAttackInterval },
{ "building_radius", balance.BuildingRadius }, { "bounty_outer_tower", balance.BountyOuterTower }, { "bounty_inner_tower", balance.BountyInnerTower },
{ "bounty_base_tower", balance.BountyBaseTower }, { "inner_tower_fraction", balance.InnerTowerFraction },
{ "outer_tower_fraction", balance.OuterTowerFraction }, { "tower_side_offset", balance.TowerSideOffset },
};
var problems = new List<string>();
foreach (KeyValuePair<string, object> pair in expected)
{
if (pair.Key == "monster_pool")
{
if (!MiniJson.Arr(pair.Value).Select(MiniJson.Str).SequenceEqual(balance.MonsterPool))
problems.Add(pair.Key);
continue;
}
object value;
if (!actual.TryGetValue(pair.Key, out value))
{
problems.Add("missing " + pair.Key);
continue;
}
if (MiniJson.Num(pair.Value) != Convert.ToDouble(value))
problems.Add(pair.Key);
}
Check(problems.Count == 0, "balance defaults match lune_balance.gd " + string.Join(", ", problems));
}
void CompareMap(OrderedMap<string, object> lanes, List<object> buildings)
{
var map = new LuneMap();
double worst = 0.0;
foreach (string lane in LuneMap.Lanes)
{
var expected = MiniJson.Obj(lanes[lane]);
LanePath path = map.LanePaths[lane];
List<object> points = MiniJson.Arr(expected["points"]);
bool countOk = points.Count == path.Points.Length;
double deviation = Math.Abs(MiniJson.Num(expected["length"]) - path.Length);
for (int i = 0; countOk && i < points.Count; i++)
{
List<object> xy = MiniJson.Arr(points[i]);
deviation = Math.Max(deviation, Math.Max(Math.Abs(MiniJson.Num(xy[0]) - path.Points[i].X), Math.Abs(MiniJson.Num(xy[1]) - path.Points[i].Y)));
}
worst = Math.Max(worst, deviation);
Check(countOk && deviation <= GeometryTolerance, string.Format("{0} lane polyline and length match ({1} points, deviation {2:E2})", lane, path.Points.Length, deviation));
}
bool buildingsOk = buildings.Count == map.Buildings.Count;
double buildingWorst = 0.0;
for (int i = 0; buildingsOk && i < buildings.Count; i++)
{
var expected = MiniJson.Obj(buildings[i]);
BuildingDef actual = map.Buildings[i];
List<object> position = MiniJson.Arr(expected["position"]);
buildingsOk = MiniJson.Str(expected["id"]) == actual.Id && MiniJson.Str(expected["tier"]) == actual.Tier && MiniJson.Str(expected["lane"]) == actual.Lane
&& MiniJson.Num(expected["max_hp"]) == actual.MaxHp && MiniJson.Int(expected["bounty"]) == actual.Bounty;
buildingWorst = Math.Max(buildingWorst, Math.Abs(MiniJson.Num(expected["progress"]) - actual.Progress));
buildingWorst = Math.Max(buildingWorst, Math.Max(Math.Abs(MiniJson.Num(position[0]) - actual.Position.X), Math.Abs(MiniJson.Num(position[1]) - actual.Position.Y)));
}
Check(buildingsOk && buildingWorst <= GeometryTolerance, string.Format("18 buildings in reference order, positions match (deviation {0:E2})", buildingWorst));
Note(string.Format("map geometry: largest deviation lanes {0:E2} m, buildings {1:E2} m", worst, buildingWorst));
}
}
}

View file

@ -0,0 +1,390 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Port of tests/luneraria/test_lune_core.gd (91 checks in Godot): structure, symmetry,
/// building gates, victory and draw, endless waves, commands and ownership, rewards,
/// persistence, a full bot match and the exact mirror match.
/// </summary>
public sealed class CoreRulesSuite : CheckSuite
{
public override string Name { get { return "core"; } }
static readonly List<string> Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" };
static LuneMatch NewMatch(int seed = 7, int human = -1)
{
var state = new LuneMatch();
state.Setup(new MatchOptions { Seed = seed, HumanSlot = human, Bunds = new List<string>(Bunds) });
return state;
}
protected override void RunChecks()
{
TestStructure();
TestSymmetry();
TestGates();
TestVictoryAndDraw();
TestEndlessWaves();
TestCommandsAndOwnership();
TestRewards();
TestPersistenceAndRestart();
TestFullMatch();
TestMirrorFairness();
}
void TestStructure()
{
LuneMatch state = NewMatch();
Check(state.Players.Count == 6, "six player slots");
var teams = new Dictionary<int, int>();
var roles = new HashSet<string>();
foreach (Player player in state.Players)
{
int count;
teams.TryGetValue(player.Team, out count);
teams[player.Team] = count + 1;
roles.Add(player.Team + "-" + player.Lane);
}
Check(teams.Count == 2 && teams[0] == 3 && teams[1] == 3, "exactly two teams of three");
Check(roles.Count == 6, "six distinct owner roles (team x lane)");
Check(LuneMap.Lanes.SequenceEqual(new[] { "top", "mid", "bot" }) && state.Map.LanePaths.Count == 3, "three lanes");
int towers = 0;
int cores = 0;
var perTeamLane = new Dictionary<string, int>();
var baseTowers = new int[2];
foreach (BuildingDef definition in state.Map.Buildings)
{
if (definition.Tier == "core")
{
cores += 1;
}
else
{
towers += 1;
if (definition.Tier == "base")
{
baseTowers[definition.Team] += 1;
}
else
{
string key = definition.Team + "-" + definition.Lane;
int count;
perTeamLane.TryGetValue(key, out count);
perTeamLane[key] = count + 1;
}
}
}
Check(towers == 16 && cores == 2, "sixteen towers and two Ancients");
Check(perTeamLane.Count == 6 && perTeamLane.Values.All(v => v == 2), "two lane towers per team and lane");
Check(baseTowers[0] == 2 && baseTowers[1] == 2, "two base towers per team");
}
void TestSymmetry()
{
var map = new LuneMap();
foreach (string lane in LuneMap.Lanes)
{
LanePath path = map.LanePaths[lane];
double worst = 0.0;
for (int i = 0; i < 21; i++)
{
double s = path.Length * (double)i / 20.0;
worst = GdMath.Max(worst, LuneMap.Mirror(path.PointAt(s)).DistanceTo(path.PointAt(path.Length - s)));
}
Check(worst < 0.001, lane + " lane is its own mirror image (team fairness)");
}
Check(Math.Abs(map.LanePaths["top"].Length - map.LanePaths["bot"].Length) < 0.001, "top and bottom lanes have equal length");
foreach (BuildingDef definition in map.Buildings)
{
if (definition.Team == 0)
{
BuildingDef twin = map.BuildingDef(definition.Id.Replace("blau", "rot"));
Check(LuneMap.Mirror(definition.Position).DistanceTo(twin.Position) < 0.001 && GdMath.IsEqualApprox(definition.MaxHp, twin.MaxHp), "mirrored building " + definition.Id);
}
}
foreach (string lane in LuneMap.Lanes)
Check(map.LanePoint(1, lane, 7.0, 1.2).DistanceTo(LuneMap.Mirror(map.LanePoint(0, lane, 7.0, 1.2))) < 0.001, "mirrored spawn points on " + lane);
Check(LuneMap.Mirror(LuneMap.Grotto).DistanceTo(LuneMap.Grotto) < 0.001, "grotto equidistant for both teams");
LanePath mid = map.LanePaths["mid"];
double grottoGap = mid.PointAt(mid.Project(LuneMap.Grotto)).DistanceTo(LuneMap.Grotto);
Check(grottoGap > 8.0, string.Format("grotto lies beside, not on, the mid lane ({0:0.0} m)", grottoGap));
}
void TestGates()
{
var map = new LuneMap();
var alive = new Dictionary<string, bool>();
foreach (BuildingDef definition in map.Buildings)
alive[definition.Id] = true;
Func<string, bool> gate = id => LuneMap.IsAttackable(map.BuildingDef(id), alive);
Check(gate("rot_top_outer") && !gate("rot_top_inner"), "outer before inner tower");
Check(!gate("rot_base_1") && !gate("rot_ancient"), "base and Ancient protected at start");
alive["rot_top_outer"] = false;
Check(gate("rot_top_inner") && !gate("rot_base_1"), "inner opens after outer; base still closed");
alive["rot_mid_outer"] = false;
alive["rot_bot_outer"] = false;
Check(!gate("rot_base_1"), "outer towers of all lanes do not open the base");
alive["rot_top_inner"] = false;
Check(gate("rot_base_1") && gate("rot_base_2") && !gate("rot_ancient"), "one broken lane opens the base, not the Ancient");
Check(gate("rot_mid_inner") && gate("rot_bot_inner"), "towers of other lanes keep working and stay attackable in order");
alive["rot_base_1"] = false;
Check(!gate("rot_ancient"), "Ancient needs both base towers down");
alive["rot_base_2"] = false;
Check(gate("rot_ancient"), "Ancient vulnerable after both base towers");
// Gate applies to every damage source inside combat, including splash.
var combat = new LuneCombat(map, LuneBalance.Default(), 1);
LuneUnit inner = combat.AddBuilding(map.BuildingDef("rot_top_inner"), 1000.0);
combat.AddBuilding(map.BuildingDef("rot_top_outer"), 1000.0);
Dictionary<string, bool> gates = combat.AliveMap();
combat.QueueDamage(inner, 500.0, gates);
Check(!combat.HasPendingDamage(inner), "locked inner tower ignores queued damage (ranged/splash/DoT)");
}
static LuneCombat Duel(double coreHpA, double coreHpB)
{
var map = new LuneMap();
var combat = new LuneCombat(map, LuneBalance.Default(), 1);
foreach (BuildingDef definition in map.Buildings)
{
if (definition.Tier == "core")
combat.AddBuilding(definition, definition.Team == 0 ? coreHpA : coreHpB);
else
combat.AddBuilding(definition, 0.0);
}
return combat;
}
static LuneUnit Attacker(LuneCombat combat, int team, double damage)
{
UnitDef definition = DataCatalog.GetUnit("g05");
definition.Damage = damage;
LuneUnit unit = combat.Spawn("troop", team, "mid", definition, 1.0, 0.0);
unit.Position = combat.Map.CorePosition(1 - team) + new Vec2(2.5, 0.0).Rotated(team);
unit.ReachedBase = true;
return unit;
}
static void RunSteps(LuneCombat combat, int count)
{
for (int i = 0; i < count; i++)
{
combat.Step();
if (combat.Finished)
break;
}
}
void TestVictoryAndDraw()
{
LuneCombat combat = Duel(12000.0, 30.0);
Attacker(combat, 0, 200.0);
RunSteps(combat, 200);
Check(combat.Finished && combat.EndReason == "core" && combat.DestroyedCores.SequenceEqual(new[] { 1 }), "destroying an Ancient ends the combat with a core result");
LuneCombat draw = Duel(30.0, 30.0);
Attacker(draw, 1, 200.0);
Attacker(draw, 0, 200.0);
RunSteps(draw, 200);
Check(draw.DestroyedCores.Count == 2, "both Ancients in the same step are both recorded");
LuneMatch state = NewMatch();
state.BeginCombat();
state.Combat.DestroyedCores.Clear();
state.Combat.DestroyedCores.Add(0);
state.Combat.DestroyedCores.Add(1);
state.Combat.Finished = true;
state.Combat.EndReason = "core";
state.Settle();
Check(state.Phase == Phase.Over && state.Winner == LuneMatch.Draw, "simultaneous Ancient loss is a draw");
// Unit order must not decide: reversing the spawn order gives the same result.
LuneCombat reversed = Duel(30.0, 30.0);
Attacker(reversed, 0, 200.0);
Attacker(reversed, 1, 200.0);
RunSteps(reversed, 200);
Check(reversed.DestroyedCores.Count == 2 && reversed.Steps == draw.Steps, "processing order gives no advantage");
var timeout = new LuneMatch();
timeout.Setup(new MatchOptions { Seed = 3, HumanSlot = -1 });
timeout.BeginCombat();
timeout.Combat.Elapsed = timeout.Balance.CombatTimeLimit;
timeout.StepCombat();
Check(timeout.Combat.EndReason == "timeout" && timeout.Phase == Phase.Planning && timeout.Winner == LuneMatch.Ongoing, "combat time window ends the phase, never the match");
Check(timeout.CoreHp(0) == timeout.Balance.CoreHp && timeout.CoreHp(1) == timeout.Balance.CoreHp, "time window causes no hidden core damage");
}
void TestEndlessWaves()
{
LuneBalance balance = LuneBalance.Default();
double previous = 0.0;
foreach (int roundNumber in new[] { 1, 5, 8, 20, 21, 50, 200, 5000, 1000000 })
{
List<UnitDef> wave = LuneWaves.Build(balance, roundNumber);
double total = 0.0;
bool finite = true;
foreach (UnitDef monster in wave)
{
total += monster.MaxHp + monster.Damage * 10.0;
finite = finite && !double.IsInfinity(monster.MaxHp) && !double.IsNaN(monster.MaxHp) && !double.IsInfinity(monster.Damage) && monster.Bounty > 0;
}
Check(finite && wave.Count <= balance.WaveMaxCount && wave.Count >= 1, string.Format("wave {0} finite and capped ({1} monsters)", roundNumber, wave.Count));
Check(total > previous, string.Format("wave {0} stronger than the previous sample", roundNumber));
previous = total;
}
LuneMatch state = NewMatch(11);
state.RoundNumber = 21;
state.BeginCombat();
int monsters = state.Combat.Units.Count(u => u.Kind == "monster");
Check(monsters == 6 * LuneWaves.MonsterCount(balance, 21), "round 21 spawns ordinary waves; no final wave logic");
state.FinishCombat();
Check(state.Phase != Phase.Over || state.Winner != LuneMatch.Ongoing, "no match end without a core result");
Check(state.RoundNumber == 22 || state.Phase == Phase.Over, "round counter continues after 20");
}
void TestCommandsAndOwnership()
{
LuneMatch state = NewMatch(5, 1);
int gold = state.GoldOf(1);
Check(!state.Submit(1, Command.Buy("g01")).Ok, "buying another Bund's troop is rejected");
CommandResult bought = state.Submit(1, Command.Buy("w01"));
Check(bought.Ok && state.GoldOf(1) == gold - 50, "buy costs exactly the catalogue price");
string uid = state.Players[1].Draft.Roster[0].Uid;
Check(!state.Submit(0, Command.Upgrade(uid)).Ok, "foreign unit id is rejected");
Check(!state.Submit(1, Command.Upgrade("p1-9999")).Ok, "unknown unit id is rejected");
Check(state.Players[1].Roster.Count == 0, "draft purchases are not committed before the phase change");
Check(state.Submit(1, Command.Assign(uid, "top")).Ok && !state.Submit(1, Command.Assign(uid, "grotte")).Ok, "lane assignment validated");
state.Submit(1, Command.Sell(uid));
Check(state.GoldOf(1) == gold, "cancelling a purchase in the same planning refunds fully");
state.Submit(1, Command.Buy("w01"));
state.Players[1].Draft.Gold = -5;
state.BeginCombat();
Check(state.Players[1].Roster.Count == 0 && state.Players[1].Gold == gold, "invalid draft is rejected atomically at the phase change");
state.FinishCombat();
LuneMatch cap = NewMatch(6, 0);
cap.Players[0].Draft.Gold = 100000;
for (int i = 0; i < cap.Balance.SupplyCap + 3; i++)
cap.Submit(0, Command.Buy("g01"));
Check(cap.Players[0].Draft.Roster.Count == cap.Balance.SupplyCap, "supply cap enforced");
Check(!cap.Submit(0, Command.Buy("g01")).Ok && cap.Phase == Phase.Planning, "no purchases beyond the cap");
cap.BeginCombat();
Check(!cap.Submit(0, Command.Buy("g03")).Ok, "no commands during combat");
LuneMatch bots = NewMatch(9);
int[] before = bots.Players.Select(p => p.Gold).ToArray();
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(bots, slot);
bool spentOk = true;
for (int slot = 0; slot < 6; slot++)
{
int invested = bots.Players[slot].Draft.Roster.Sum(e => e.Invested);
spentOk = spentOk && invested + bots.Players[slot].Draft.Gold == before[slot];
}
Check(spentOk, "bots spend exactly their own gold, no extra resources");
}
void TestRewards()
{
LuneMatch state = NewMatch(4);
state.BeginCombat();
LuneCombat combat = state.Combat;
combat.Events.Clear();
combat.Events.Add(new CombatEvent { Serial = 1, Team = 1, Lane = "top", Zone = "lane", Kind = "monster", Bounty = 101 });
combat.Events.Add(new CombatEvent { Serial = 2, Team = 1, Lane = "top", Zone = "lane", Kind = "monster", Bounty = 101 });
combat.Events.Add(new CombatEvent { Serial = 3, Team = 0, Lane = "mid", Zone = "lane", Kind = "tower", Bounty = 151, BuildingId = "blau_mid_outer" });
combat.Events.Add(new CombatEvent { Serial = 4, Team = 0, Lane = "bot", Zone = "lane", Kind = "troop", Bounty = 0 });
combat.Finished = true;
combat.EndReason = "cleared";
int[] goldBefore = state.Players.Select(p => p.Gold).ToArray();
state.Settle();
int[] gains = state.LastSettlement.Gains;
Check(gains[0] + gains[1] + gains[2] == 202, "each monster death pays exactly once, total 2 x 101 to Blau");
Check(gains[0] >= 50 + 50 && gains[0] - gains[1] >= 90, "Top lane player of the killing team gets the lane share");
Check(gains[3] + gains[4] + gains[5] == 151, "tower bounty exactly once to Rot");
Check(Math.Abs(gains[3] - gains[4]) <= 1 && Math.Abs(gains[4] - gains[5]) <= 1, "tower bounty split evenly with integer remainder");
int income = state.LastSettlement.Income;
bool paid = true;
for (int slot = 0; slot < 6; slot++)
paid = paid && state.Players[slot].Gold == goldBefore[slot] + gains[slot] + income;
Check(paid, "settlement credits gains plus income, nothing else");
Check(gains.Sum() == 353, "persistent troop deaths pay nothing");
}
void TestPersistenceAndRestart()
{
LuneMatch state = NewMatch(8, 0);
state.Submit(0, Command.Buy("g03"));
state.SetReady(0);
string uid = state.Players[0].Draft.Roster[0].Uid;
state.BeginCombat();
state.Combat.Buildings["blau_mid_outer"].Hp -= 1234.0;
LuneUnit fallen = state.Combat.Units.First(u => u.PersistentUid == uid);
fallen.Hp = 0.0;
fallen.Alive = false;
state.FinishCombat();
Check(state.Phase == Phase.Planning && state.RoundNumber == 2, "round advances to planning");
Check(state.Players[0].Roster.Count == 1 && state.Players[0].Roster[0].Uid == uid, "fallen persistent troop returns to the roster");
Check(GdMath.IsEqualApprox(state.BuildingHp["blau_mid_outer"], state.Balance.TowerOuterHp - 1234.0) || state.BuildingHp["blau_mid_outer"] < state.Balance.TowerOuterHp - 1234.0, "building damage persists between rounds");
state.BeginCombat();
bool healed = false;
foreach (LuneUnit unit in state.Combat.Units)
{
if (unit.PersistentUid == uid)
healed = unit.Hp == unit.MaxHp && unit.Effects.Count == 0 && unit.Dots.Count == 0;
}
Check(healed, "returning troop starts the next combat healed and without effects");
state.FinishCombat();
LuneMatch fresh = NewMatch(8, 0);
Check(fresh.Players[0].Roster.Count == 0 && fresh.RoundNumber == 1 && fresh.BuildingHp["blau_mid_outer"] == fresh.Balance.TowerOuterHp, "new match starts without previous progress");
}
void TestFullMatch()
{
LuneMatch state = NewMatch(2);
int limit = 80;
while (state.Phase != Phase.Over && state.RoundNumber <= limit)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(state, slot);
state.BeginCombat();
state.FinishCombat();
}
Check(state.Phase == Phase.Over, "a six-bot match reaches a regular Ancient result (seed 2)");
Check(state.Winner == 0 || state.Winner == 1 || state.Winner == LuneMatch.Draw, "result is Blau, Rot or draw");
int coresDown = (state.CoreHp(0) <= 0.0 ? 1 : 0) + (state.CoreHp(1) <= 0.0 ? 1 : 0);
Check(coresDown >= 1, "the result follows an actually destroyed Ancient");
Note(string.Format("full match seed 2 -> {0} after {1} rounds", state.Winner == LuneMatch.Draw ? "draw" : (state.Winner == 0 ? "Blau" : "Rot"), state.History.Count));
}
void TestMirrorFairness()
{
// Identical Bund and deterministic bot profile on all six slots: the match must stay an
// exact mirror image. Any drift would reveal an order- or id-dependent team advantage.
foreach (string bund in new[] { "glutpakt", "wurzelbund" })
{
var state = new LuneMatch();
state.Setup(new MatchOptions { Seed = 3, HumanSlot = -1, Bunds = Enumerable.Repeat(bund, 6).ToList() });
bool mirrored = true;
for (int roundIndex = 0; roundIndex < 12; roundIndex++)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(state, slot, "verteidigung");
state.BeginCombat();
state.FinishCombat();
foreach (BuildingDef definition in state.Map.Buildings)
{
if (definition.Team == 0)
{
string twin = definition.Id.Replace("blau", "rot");
mirrored = mirrored && GdMath.IsEqualApprox(state.BuildingHp[definition.Id], state.BuildingHp[twin]);
}
}
for (int slot = 0; slot < 3; slot++)
mirrored = mirrored && state.Players[slot].Gold == state.Players[slot + 3].Gold;
if (state.Phase == Phase.Over)
break;
}
Check(mirrored, bund + " mirror match stays exactly symmetric for 12 rounds (buildings and gold)");
Check(state.Phase != Phase.Over || state.Winner == LuneMatch.Draw, bund + " mirror match never produces a one-sided winner");
}
}
}
}

View file

@ -0,0 +1,112 @@
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Determinism of the C# core: the same inputs give the same match, repeatedly, with no
/// dependence on hashing, object identity, wall-clock time or the order in which matches run.
/// Each .NET run also randomises string hashing per process, so passing golden replays in
/// several processes shows that no rule depends on dictionary iteration order.
/// </summary>
public sealed class DeterminismSuite : CheckSuite
{
public override string Name { get { return "determinism"; } }
protected override void RunChecks()
{
foreach (int seed in new[] { 5, 13, 28 })
{
string first = Fingerprint(Play(seed));
string second = Fingerprint(Play(seed));
Check(first == second, "seed " + seed + ": two runs of the same match are identical");
}
// Interleaving two matches must not change either (no shared static state).
LuneMatch a = New(7);
LuneMatch b = New(9);
for (int round = 0; round < 6; round++)
{
foreach (LuneMatch match in new[] { a, b })
{
if (match.Phase == Phase.Over)
continue;
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
}
bool running = true;
while (running)
{
running = false;
foreach (LuneMatch match in new[] { a, b })
{
if (match.Phase == Phase.Combat)
{
match.StepCombat();
running = true;
}
}
}
}
LuneMatch aAlone = New(7);
LuneMatch bAlone = New(9);
for (int round = 0; round < 6; round++)
{
foreach (LuneMatch match in new[] { aAlone, bAlone })
{
if (match.Phase == Phase.Over)
continue;
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
match.FinishCombat();
}
}
Check(Fingerprint(a) == Fingerprint(aAlone) && Fingerprint(b) == Fingerprint(bAlone), "interleaved matches equal matches run alone (no shared state)");
// Unit ids are sequential per combat and follow spawn order, never object identity.
LuneMatch ids = New(4);
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(ids, slot);
ids.BeginCombat();
Check(ids.Combat.Units.Select(u => u.Id).SequenceEqual(Enumerable.Range(1, ids.Combat.Units.Count)), "unit ids are 1..n in spawn order");
// The combat step is fixed; the simulation never reads a clock or a frame rate.
Check(ids.Balance.SimStep == 0.05, "fixed simulation step of 0.05 s (20 steps per combat second)");
}
static LuneMatch New(int seed)
{
var match = new LuneMatch();
match.Setup(new MatchOptions { Seed = seed, HumanSlot = -1 });
return match;
}
static LuneMatch Play(int seed)
{
LuneMatch match = New(seed);
while (match.Phase != Phase.Over && match.RoundNumber <= 60)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
match.FinishCombat();
}
return match;
}
static string Fingerprint(LuneMatch match)
{
var parts = new List<string> { match.RoundNumber + "/" + match.Winner + "/" + match.Phase };
foreach (Player player in match.Players)
{
parts.Add(player.Gold + ":" + player.Earned + ":" + player.Spent + ":" + string.Join(",", player.Roster.Select(e => e.Uid + e.TypeId + e.Lane)));
if (player.Hero != null)
parts.Add(player.Hero.HeroId + player.Hero.Xp + string.Join("+", player.Hero.Items) + string.Join("+", player.Hero.Talents));
}
foreach (KeyValuePair<string, double> pair in match.BuildingHp)
parts.Add(pair.Key + "=" + pair.Value.ToString("R", System.Globalization.CultureInfo.InvariantCulture));
foreach (HistoryEntry entry in match.History)
parts.Add(entry.Round + entry.EndReason + entry.CombatTime.ToString("R", System.Globalization.CultureInfo.InvariantCulture) + string.Join(",", entry.Gold));
return string.Join("|", parts);
}
}
}

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,226 @@
{
"about": "Golden scenarios for the rules port. The Godot reference (tools/godot-golden/golden_scenarios.gd) and the C# core (GoldenSuite) interpret the same operations; Godot writes golden.json, C# compares its own snapshots with it (docs/port/U1-RULES.md). Uid references: $last:<slot> last roster entry, $roster:<slot>:<index>, $hero:<slot>, $booking:<slot>:<index> (new bookings of the draft).",
"scenarios": [
{"id": "start_three_lanes", "title": "Normaler Drei-Lane-Start: sechs Bots, Aufmarsch, erste Gefechte, Abrechnung",
"ops": [
{"op": "setup", "seed": 4, "bunds": ["wurzelbund", "wurzelbund", "glutpakt", "glutpakt", "wurzelbund", "glutpakt"]},
{"op": "bots"},
{"op": "snapshot", "label": "planned"},
{"op": "begin"},
{"op": "snapshot", "label": "spawned", "units": true, "wave": true},
{"op": "steps", "n": 200},
{"op": "snapshot", "label": "t10", "units": true},
{"op": "steps", "n": 400},
{"op": "snapshot", "label": "t30", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "lane_outer_lost", "title": "Lane verliert äußeren Turm: Bastion wird angreifbar, Marschgrenze rückt vor",
"ops": [
{"op": "setup", "seed": 5},
{"op": "building", "id": "blau_top_outer", "hp": 0},
{"op": "round", "value": 6},
{"op": "gold", "slot": 3, "add": 900},
{"op": "bots"},
{"op": "begin"},
{"op": "steps", "n": 500},
{"op": "snapshot", "label": "t25", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "lane_both_lost", "title": "Lane verliert beide Türme: Basistürme angreifbar",
"ops": [
{"op": "setup", "seed": 6},
{"op": "building", "id": "blau_bot_outer", "hp": 0},
{"op": "building", "id": "blau_bot_inner", "hp": 0},
{"op": "round", "value": 8},
{"op": "gold", "slot": 5, "add": 1500},
{"op": "bots"},
{"op": "begin"},
{"op": "steps", "n": 700},
{"op": "snapshot", "label": "t35", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "base_tower_attack", "title": "Angriff auf Basistürme durch eine starke Rot-Armee",
"ops": [
{"op": "setup", "seed": 7},
{"op": "building", "id": "blau_mid_outer", "hp": 0},
{"op": "building", "id": "blau_mid_inner", "hp": 0},
{"op": "round", "value": 12},
{"op": "gold", "slot": 3, "add": 6000},
{"op": "gold", "slot": 4, "add": 6000},
{"op": "gold", "slot": 5, "add": 6000},
{"op": "bots"},
{"op": "begin"},
{"op": "steps", "n": 1200},
{"op": "snapshot", "label": "t60", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "ancient_destruction", "title": "Ancient-Zerstörung beendet die Partie",
"ops": [
{"op": "setup", "seed": 8},
{"op": "building", "id": "rot_top_outer", "hp": 0},
{"op": "building", "id": "rot_top_inner", "hp": 0},
{"op": "building", "id": "rot_base_1", "hp": 0},
{"op": "building", "id": "rot_base_2", "hp": 0},
{"op": "building", "id": "rot_ancient", "hp": 900},
{"op": "round", "value": 12},
{"op": "gold", "slot": 0, "add": 8000},
{"op": "gold", "slot": 1, "add": 8000},
{"op": "gold", "slot": 2, "add": 8000},
{"op": "bots"},
{"op": "begin"},
{"op": "finish"},
{"op": "snapshot", "label": "over", "events": true}
]},
{"id": "high_wave_21", "title": "Hohe Welle über 20: normale Welle, stärkere Monster",
"ops": [
{"op": "setup", "seed": 9},
{"op": "round", "value": 21},
{"op": "gold_all", "add": 4000},
{"op": "bots"},
{"op": "begin"},
{"op": "snapshot", "label": "spawned", "units": true, "wave": true},
{"op": "steps", "n": 400},
{"op": "snapshot", "label": "t20", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "high_wave_137", "title": "Hohe Welle über 100: Werte, Belohnungen und Kampf bleiben endlich",
"ops": [
{"op": "setup", "seed": 10},
{"op": "round", "value": 137},
{"op": "gold_all", "add": 40000},
{"op": "bots"},
{"op": "begin"},
{"op": "snapshot", "label": "spawned", "wave": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "hero_levels", "title": "Heldenentwicklung über Runden: Anwerben, Erfahrung, Stufen, Talente, Items (Bots)",
"ops": [
{"op": "setup", "seed": 12},
{"op": "play", "rounds": 9}
]},
{"id": "item_purchase", "title": "Itemkauf: Plätze, Einzigartigkeit, Stufe, Rückgabe, Normale Einheiten ohne Items",
"ops": [
{"op": "setup", "seed": 21, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]},
{"op": "gold", "slot": 0, "add": 5000},
{"op": "cmd", "slot": 0, "command": {"type": "hero_buy", "hero_id": "held_ringborke"}},
{"op": "cmd", "slot": 0, "command": {"type": "buy", "type_id": "g01"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "glutklinge", "uid": "$last:0"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "glutklinge", "uid": "$hero:0"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "glutklinge"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "kristallsplitter"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "rankenmantel"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "basaltschild"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "funkenfokus"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_sell", "item_id": "basaltschild"}},
{"op": "cmd", "slot": 0, "command": {"type": "item_buy", "item_id": "basaltschild"}},
{"op": "snapshot", "label": "planned"},
{"op": "begin"},
{"op": "snapshot", "label": "spawned", "units": true},
{"op": "finish"},
{"op": "cmd", "slot": 0, "command": {"type": "item_sell", "item_id": "basaltschild"}},
{"op": "cmd", "slot": 0, "command": {"type": "hero_cancel"}},
{"op": "snapshot", "label": "after_round"}
]},
{"id": "attack_champion", "title": "Angriffschampion: Buchung n, öffentliche Ankündigung, Einsatz n+1, Kopfgeld, Verbrauch",
"ops": [
{"op": "setup", "seed": 24, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]},
{"op": "gold", "slot": 0, "add": 1000},
{"op": "cmd", "slot": 0, "command": {"type": "champion_book", "champion_id": "ch_belagerung", "lane": "bot"}},
{"op": "cmd", "slot": 0, "command": {"type": "champion_book", "champion_id": "ch_druck", "lane": "top"}},
{"op": "cmd", "slot": 0, "command": {"type": "champion_book", "champion_id": "ch_schutz", "lane": "top"}},
{"op": "cmd", "slot": 0, "command": {"type": "champion_cancel", "uid": "$booking:0:1"}},
{"op": "begin"},
{"op": "finish"},
{"op": "snapshot", "label": "announced"},
{"op": "cmd", "slot": 0, "command": {"type": "champion_cancel", "uid": "c0-1"}},
{"op": "begin"},
{"op": "snapshot", "label": "deployed", "units": true},
{"op": "steps", "n": 600},
{"op": "snapshot", "label": "t30", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true},
{"op": "begin"},
{"op": "snapshot", "label": "consumed"},
{"op": "finish"}
]},
{"id": "boss_cycle", "title": "Bossversuch erfolgreich, Anspruch, Entsendung auf gewählter Lane, Boss getötet mit Kopfgeld, dreirundige Sperre, Wiederverfügbarkeit",
"ops": [
{"op": "setup", "seed": 26, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]},
{"op": "play", "rounds": 5, "bots": false},
{"op": "expedition", "slot": 1, "types": [1, 1, 1, 0, 2, 2, 2], "hero": "held_ringborke"},
{"op": "begin"},
{"op": "snapshot", "label": "expedition", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "won", "events": true},
{"op": "settle_boss"},
{"op": "cmd", "slot": 4, "command": {"type": "boss_lane", "lane": "top"}},
{"op": "cmd", "slot": 1, "command": {"type": "boss_lane", "lane": "top"}},
{"op": "cmd", "slot": 1, "command": {"type": "assign", "uid": "$roster:1:0", "lane": "grotto"}},
{"op": "cmd", "slot": 1, "command": {"type": "assign", "uid": "$roster:1:0", "lane": "mid"}},
{"op": "snapshot", "label": "announced"},
{"op": "begin"},
{"op": "snapshot", "label": "deployed", "units": true},
{"op": "steps", "n": 200},
{"op": "snapshot", "label": "marching", "units": true},
{"op": "unit_set", "select": {"kind": "boss", "zone": "lane"}, "hp": 0},
{"op": "steps", "n": 1},
{"op": "finish"},
{"op": "snapshot", "label": "boss_killed", "events": true},
{"op": "buildings_full"},
{"op": "snapshot", "label": "locked_r8"},
{"op": "play", "rounds": 1, "bots": false},
{"op": "buildings_full"},
{"op": "snapshot", "label": "locked_r9"},
{"op": "play", "rounds": 1, "bots": false},
{"op": "snapshot", "label": "open_r10"},
{"op": "begin"},
{"op": "snapshot", "label": "no_second_deployment", "units": true},
{"op": "finish"}
]},
{"id": "boss_failure", "title": "Bossversuch gescheitert: keine Belohnung, keine Sperre, Boss beim nächsten Versuch voll",
"ops": [
{"op": "setup", "seed": 27, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]},
{"op": "gold", "slot": 2, "add": 200},
{"op": "cmd", "slot": 2, "command": {"type": "buy", "type_id": "g01"}},
{"op": "cmd", "slot": 2, "command": {"type": "assign", "uid": "$last:2", "lane": "grotto"}},
{"op": "begin"},
{"op": "snapshot", "label": "attempt", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "failed", "events": true},
{"op": "cmd", "slot": 2, "command": {"type": "assign", "uid": "$roster:2:0", "lane": "grotto"}},
{"op": "begin"},
{"op": "snapshot", "label": "second_attempt", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "failed_again", "events": true}
]},
{"id": "grotto_contested", "title": "Umkämpfte Grotte: zwei Expeditionen, Boss ruht, erwacht unberührt, Sieger erhält Anspruch",
"ops": [
{"op": "setup", "seed": 25, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]},
{"op": "expedition", "slot": 1, "types": [1, 1, 0, 2]},
{"op": "cmd", "slot": 1, "command": {"type": "buy", "type_id": "w01"}},
{"op": "expedition", "slot": 4, "types": [1]},
{"op": "begin"},
{"op": "snapshot", "label": "dormant", "units": true},
{"op": "steps", "n": 300},
{"op": "snapshot", "label": "t15", "units": true},
{"op": "finish"},
{"op": "snapshot", "label": "settled", "events": true}
]},
{"id": "mirror_match", "title": "Spiegelpartie: gleicher Bund und gleiches Profil auf allen Plätzen bleibt exakt symmetrisch",
"ops": [
{"op": "setup", "seed": 3, "bunds": ["glutpakt", "glutpakt", "glutpakt", "glutpakt", "glutpakt", "glutpakt"]},
{"op": "play", "rounds": 8, "profile": "verteidigung"}
]},
{"id": "bot_match_1", "title": "Vollständige Bot-Partie Seed 1 bis zum Ancient-Fall", "ops": [{"op": "setup", "seed": 1}, {"op": "play", "rounds": 60}]},
{"id": "bot_match_2", "title": "Vollständige Bot-Partie Seed 2 bis zum Ancient-Fall", "ops": [{"op": "setup", "seed": 2}, {"op": "play", "rounds": 60}]},
{"id": "bot_match_3", "title": "Vollständige Bot-Partie Seed 3 bis zum Ancient-Fall", "ops": [{"op": "setup", "seed": 3}, {"op": "play", "rounds": 60}]},
{"id": "bot_match_11", "title": "Vollständige Bot-Partie Seed 11 bis zum Ancient-Fall", "ops": [{"op": "setup", "seed": 11}, {"op": "play", "rounds": 60}]},
{"id": "bot_match_28", "title": "Vollständige Bot-Partie Seed 28 (Bots mit Helden, Champions und Grotte)", "ops": [{"op": "setup", "seed": 28, "bunds": ["glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund"]}, {"op": "play", "rounds": 60}]}
]
}

View file

@ -0,0 +1,325 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Runs the operations of Golden/scenarios.json against the C# core and records snapshots in
/// exactly the shape tools/godot-golden/golden_scenarios.gd writes for the Godot reference.
/// </summary>
public sealed class GoldenReplay
{
public LuneMatch State;
public readonly List<object> Snapshots = new List<object>();
public readonly List<object> Results = new List<object>();
public void Apply(OrderedMap<string, object> op)
{
switch (MiniJson.Str(op["op"]))
{
case "setup":
{
State = new LuneMatch();
var options = new MatchOptions { Seed = MiniJson.Int(op["seed"]), HumanSlot = op.ContainsKey("human") ? MiniJson.Int(op["human"]) : -1 };
if (op.ContainsKey("bunds"))
options.Bunds = MiniJson.Arr(op["bunds"]).Select(MiniJson.Str).ToList();
if (op.ContainsKey("profiles"))
options.Profiles = MiniJson.Arr(op["profiles"]).Select(MiniJson.Str).ToList();
State.Setup(options);
break;
}
case "building":
State.BuildingHp[MiniJson.Str(op["id"])] = MiniJson.Num(op["hp"]);
break;
case "buildings_full":
foreach (BuildingDef definition in State.Map.Buildings)
State.BuildingHp[definition.Id] = definition.MaxHp;
break;
case "round":
State.RoundNumber = MiniJson.Int(op["value"]);
break;
case "gold":
State.Players[MiniJson.Int(op["slot"])].Draft.Gold += MiniJson.Int(op["add"]);
break;
case "gold_all":
foreach (Player player in State.Players)
player.Draft.Gold += MiniJson.Int(op["add"]);
break;
case "cmd":
{
int slot = MiniJson.Int(op["slot"]);
Command command = Resolve(MiniJson.Obj(op["command"]));
CommandResult result = State.Submit(slot, command);
Results.Add(new List<object> { "cmd", (long)slot, command.Type, result.Ok, result.Message });
break;
}
case "bots":
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(State, slot, op.ContainsKey("profile") ? MiniJson.Str(op["profile"]) : "");
break;
case "begin":
Results.Add(new List<object> { "begin", -1L, "", State.BeginCombat(), "" });
break;
case "steps":
for (int i = 0; i < MiniJson.Int(op["n"]); i++)
{
if (State.Phase != Phase.Combat)
break;
State.StepCombat();
}
break;
case "finish":
State.FinishCombat();
break;
case "unit_set":
{
var select = MiniJson.Obj(op["select"]);
foreach (LuneUnit unit in State.Combat.Units)
{
if (select.ContainsKey("kind") && unit.Kind != MiniJson.Str(select["kind"]))
continue;
if (select.ContainsKey("zone") && unit.Zone != MiniJson.Str(select["zone"]))
continue;
if (select.ContainsKey("team") && unit.Team != MiniJson.Int(select["team"]))
continue;
if (op.ContainsKey("hp"))
unit.Hp = MiniJson.Num(op["hp"]);
}
break;
}
case "settle_boss":
Results.Add(new List<object> { "settle_boss", -1L, "", true, State.SettleBoss() });
break;
case "expedition":
{
int slot = MiniJson.Int(op["slot"]);
State.Players[slot].Draft.Gold += 6000;
List<string> ids = State.BaseIds(State.Players[slot].Bund);
foreach (object typeIndex in MiniJson.Arr(op["types"]))
{
Record(slot, Command.Buy(ids[MiniJson.Int(typeIndex)]));
RosterEntry entry = State.Players[slot].Draft.Roster[State.Players[slot].Draft.Roster.Count - 1];
Record(slot, Command.Upgrade(entry.Uid));
Record(slot, Command.Assign(entry.Uid, "grotto"));
}
if (op.ContainsKey("hero"))
{
Record(slot, Command.HeroBuy(MiniJson.Str(op["hero"])));
Record(slot, Command.Assign(State.Players[slot].Draft.Hero.Uid, "grotto"));
}
break;
}
case "play":
{
bool bots = !op.ContainsKey("bots") || (bool)op["bots"];
string profile = op.ContainsKey("profile") ? MiniJson.Str(op["profile"]) : "";
for (int i = 0; i < MiniJson.Int(op["rounds"]); i++)
{
if (State.Phase == Phase.Over)
break;
if (bots)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(State, slot, profile);
}
int fought = State.RoundNumber;
State.BeginCombat();
State.FinishCombat();
var label = new OrderedMap<string, object>();
label["label"] = "round-" + fought;
Snapshot(label);
}
break;
}
case "snapshot":
Snapshot(op);
break;
default:
throw new InvalidOperationException("unknown op " + MiniJson.Str(op["op"]));
}
}
void Record(int slot, Command command)
{
CommandResult result = State.Submit(slot, command);
Results.Add(new List<object> { "cmd", (long)slot, command.Type, result.Ok, result.Message });
}
Command Resolve(OrderedMap<string, object> source)
{
var command = new Command { Type = MiniJson.Str(source["type"]) };
foreach (KeyValuePair<string, object> pair in source)
{
switch (pair.Key)
{
case "type_id": command.TypeId = MiniJson.Str(pair.Value); break;
case "lane": command.Lane = MiniJson.Str(pair.Value); break;
case "uid": command.Uid = ResolveUid(MiniJson.Str(pair.Value)); break;
case "hero_id": command.HeroId = MiniJson.Str(pair.Value); break;
case "item_id": command.ItemId = MiniJson.Str(pair.Value); break;
case "champion_id": command.ChampionId = MiniJson.Str(pair.Value); break;
case "tier": command.Tier = MiniJson.Int(pair.Value); break;
case "choice": command.Choice = MiniJson.Int(pair.Value); break;
}
}
return command;
}
string ResolveUid(string uid)
{
if (!uid.StartsWith("$", StringComparison.Ordinal))
return uid;
string[] parts = uid.Substring(1).Split(':');
Player owner = State.Players[int.Parse(parts[1])];
switch (parts[0])
{
case "last": return owner.Draft.Roster[owner.Draft.Roster.Count - 1].Uid;
case "roster": return owner.Draft.Roster[int.Parse(parts[2])].Uid;
case "hero": return owner.Draft.Hero.Uid;
case "booking": return owner.Draft.NewBookings[int.Parse(parts[2])].Uid;
}
return uid;
}
// ---------------------------------------------------------------- snapshot (same shape as the Godot tool)
static string Bool(bool value) { return value ? "true" : "false"; }
void Snapshot(OrderedMap<string, object> op)
{
var players = new List<object>();
foreach (Player player in State.Players)
{
var entry = new OrderedMap<string, object>();
entry["gold"] = (long)player.Gold;
entry["draft_gold"] = (long)player.Draft.Gold;
entry["ready"] = player.Ready;
entry["earned"] = (long)player.Earned;
entry["spent"] = (long)player.Spent;
entry["next_seq"] = (long)player.NextSeq;
entry["roster"] = Roster(player.Roster, false);
entry["hero"] = Hero(player.Hero);
entry["bookings"] = player.Bookings.Select(b => (object)(b.Uid + "|" + b.ChampionId + "|" + b.Lane + "|" + b.Cost + "|" + b.DeployRound)).ToList();
entry["new_bookings"] = player.Draft.NewBookings.Select(b => (object)(b.Uid + "|" + b.ChampionId + "|" + b.Lane + "|" + b.Cost)).ToList();
entry["draft_roster"] = Roster(player.Draft.Roster, true);
entry["draft_hero"] = Hero(player.Draft.Hero);
entry["boss_lane"] = player.Draft.BossLane;
entry["undo"] = (long)player.Undo.Count;
players.Add(entry);
}
var snap = new OrderedMap<string, object>();
snap["label"] = MiniJson.Str(op["label"]);
snap["phase"] = (long)(int)State.Phase;
snap["round"] = (long)State.RoundNumber;
snap["winner"] = (long)State.Winner;
snap["end_note"] = State.EndNote;
snap["players"] = players;
snap["buildings"] = State.Map.Buildings.Select(d => (object)State.BuildingHp[d.Id]).ToList();
var boss = new OrderedMap<string, object>();
boss["next"] = (long)State.Boss.NextAvailableRound;
boss["settled"] = (long)State.Boss.SettledRound;
BossClaim claim = State.Boss.Claim;
boss["claim"] = claim == null ? "" : claim.Team + "|" + claim.WonRound + "|" + claim.DeployRound + "|" + claim.ChooserSlot + "|" + claim.Lane;
snap["boss"] = boss;
snap["boss_log"] = State.BossLog.Select(e => (object)(e.Round + "|" + e.Event + "|" + e.Team + "|" + e.Lane + "|" + e.Chooser)).ToList();
snap["announcements"] = State.Announcements().Select(a => (object)(a.Kind + "|" + a.Team + "|" + a.Slot + "|" + a.Lane + "|" + a.ChampionId + "|" + a.Bounty)).ToList();
snap["settlement"] = SettlementSnapshot();
snap["combat"] = CombatSnapshot();
if (op.ContainsKey("units") && (bool)op["units"] && State.Combat != null)
snap["units"] = State.Combat.Units.Select(u => (object)UnitSnapshot(u)).ToList();
if (op.ContainsKey("events") && (bool)op["events"] && State.Combat != null)
{
snap["events"] = State.Combat.Events.Select(e => (object)new List<object> { (long)e.Serial, (long)e.Step, e.Time, (long)e.Team, e.Lane, e.Zone, e.Kind,
(long)e.UnitId, e.DefId, (long)e.Bounty, e.BuildingId, (long)e.Xp, e.XpHeroes.Select(h => (object)h).ToList() }).ToList();
}
if (op.ContainsKey("wave") && (bool)op["wave"])
snap["wave"] = LuneWaves.Build(State.Balance, State.RoundNumber).Select(m => (object)new List<object> { m.Id, m.MaxHp, m.Damage, (long)m.Bounty }).ToList();
Snapshots.Add(snap);
}
static List<object> Roster(List<RosterEntry> roster, bool withFresh)
{
return roster.Select(e => (object)(e.Uid + "|" + e.TypeId + "|" + e.Lane + "|" + e.Invested + (withFresh ? "|" + Bool(e.Fresh) : ""))).ToList();
}
static string Hero(HeroState hero)
{
if (hero == null)
return "";
return hero.Uid + "|" + hero.HeroId + "|" + hero.Xp + "|" + hero.Lane + "|" + string.Join("+", hero.Items) + "|" + string.Join("+", hero.Talents) + "|"
+ hero.Invested + "|" + Bool(hero.Fresh) + "|" + string.Join("+", hero.FreshItems);
}
object SettlementSnapshot()
{
Settlement s = State.LastSettlement;
if (s == null)
return null;
var result = new OrderedMap<string, object>();
result["round"] = (long)s.Round;
result["gains"] = s.Gains.Select(g => (object)(long)g).ToList();
result["income"] = (long)s.Income;
result["lost"] = s.BuildingsLost.Select(b => (object)b).ToList();
result["end_reason"] = s.EndReason;
result["combat_time"] = s.CombatTime;
result["boss"] = s.Boss;
var laneKills = new OrderedMap<string, object>();
foreach (KeyValuePair<string, int> pair in s.LaneKills)
laneKills[pair.Key] = (long)pair.Value;
result["lane_kills"] = laneKills;
var xp = new OrderedMap<string, object>();
foreach (KeyValuePair<string, int> pair in s.Xp)
xp[pair.Key] = (long)pair.Value;
result["xp"] = xp;
return result;
}
object CombatSnapshot()
{
LuneCombat c = State.Combat;
if (c == null)
return null;
var result = new OrderedMap<string, object>();
result["round"] = (long)c.RoundNumber;
result["steps"] = (long)c.Steps;
result["elapsed"] = c.Elapsed;
result["finished"] = c.Finished;
result["end_reason"] = c.EndReason;
result["destroyed"] = c.DestroyedCores.Select(t => (object)(long)t).ToList();
result["grotto"] = c.GrottoOutcome + "|" + c.GrottoClaimTeam + "|" + c.GrottoActiveTeam + "|" + Bool(c.GrottoResolved);
result["grotto_boss"] = c.GrottoBoss == null ? "" : Bool(c.GrottoBoss.Alive) + "|" + Bool(c.GrottoBoss.Dormant) + "|" + c.GrottoBoss.Id;
result["grotto_boss_hp"] = c.GrottoBoss != null ? c.GrottoBoss.Hp : 0.0;
result["events"] = (long)c.Events.Count;
result["mobile"] = new List<object> { (long)c.MobileCount(0), (long)c.MobileCount(1), (long)c.MobileCount(2) };
return result;
}
static OrderedMap<string, object> UnitSnapshot(LuneUnit unit)
{
var result = new OrderedMap<string, object>();
result["id"] = (long)unit.Id;
result["kind"] = unit.Kind;
result["team"] = (long)unit.Team;
result["lane"] = unit.Lane;
result["zone"] = unit.Zone;
result["slot"] = (long)unit.OwnerSlot;
result["uid"] = unit.PersistentUid;
result["def"] = unit.Def.Id;
result["alive"] = unit.Alive;
result["hp"] = unit.Hp;
result["max_hp"] = unit.MaxHp;
result["shield"] = unit.Shield;
result["x"] = (double)unit.Position.X;
result["y"] = (double)unit.Position.Y;
result["facing"] = unit.Facing;
result["progress"] = unit.Progress;
result["target"] = (long)(unit.Target != null ? unit.Target.Id : -1);
result["hits"] = (long)unit.HitCount;
result["reached"] = unit.ReachedBase;
result["dormant"] = unit.Dormant;
result["effects"] = unit.Effects.Select(p => (object)new List<object> { p.Key, p.Value.Kind, p.Value.Amount, p.Value.Remaining }).ToList();
result["dots"] = unit.Dots.Select(p => (object)new List<object> { p.Key, p.Value.Damage, p.Value.Remaining, p.Value.NextTick, (long)p.Value.AttackType }).ToList();
return result;
}
}
}

View file

@ -0,0 +1,156 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Golden replays: every scenario of Golden/scenarios.json runs on the C# core and each
/// snapshot is compared with the Godot reference (Golden/golden.json) field by field.
/// Integers, strings, flags, ids, ownership, purchases, targets and results must be equal;
/// numbers that pass through floating point use the tolerances below (docs/port/U1-RULES.md).
/// The largest deviation per category is noted in the report.
/// </summary>
public sealed partial class GoldenSuite
{
/// <summary>Tolerance categories: hit points (absolute, 0.01 HP or 1e-9 relative), positions and lane progress (1 mm), angles, times, effects.</summary>
static readonly Dictionary<string, double> Tolerances = new Dictionary<string, double>
{
{ "hp", 0.01 }, { "position", 0.001 }, { "angle", 0.0001 }, { "time", 0.000001 }, { "effect", 0.000001 }, { "other", 0.000001 },
};
readonly Dictionary<string, double> _worst = new Dictionary<string, double>();
readonly Dictionary<string, int> _counts = new Dictionary<string, int>();
int _compared;
partial void RunScenarios()
{
var spec = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(GoldenDirectory, "scenarios.json"))));
var golden = MiniJson.Obj(MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(GoldenDirectory, "golden.json"))))["scenarios"]);
foreach (object item in MiniJson.Arr(spec["scenarios"]))
{
var scenario = MiniJson.Obj(item);
string id = MiniJson.Str(scenario["id"]);
object reference;
if (!golden.TryGetValue(id, out reference))
{
Check(false, id + ": no golden reference (run tools/godot-golden/run_golden.sh)");
continue;
}
var replay = new GoldenReplay();
foreach (object op in MiniJson.Arr(scenario["ops"]))
replay.Apply(MiniJson.Obj(op));
var expected = MiniJson.Obj(reference);
List<object> expectedSnapshots = MiniJson.Arr(expected["snapshots"]);
Check(expectedSnapshots.Count == replay.Snapshots.Count, string.Format("{0}: {1} snapshots like the reference ({2})", id, replay.Snapshots.Count, expectedSnapshots.Count));
for (int i = 0; i < Math.Min(expectedSnapshots.Count, replay.Snapshots.Count); i++)
{
var problems = new List<string>();
string label = MiniJson.Str(MiniJson.Obj(expectedSnapshots[i])["label"]);
CompareValue(id + "/" + label, "", expectedSnapshots[i], replay.Snapshots[i], problems);
Check(problems.Count == 0, id + "/" + label + " matches the reference" + (problems.Count > 0 ? ": " + problems.Count + " differences, first " + string.Join("; ", problems.Take(6)) : ""));
}
var results = new List<string>();
CompareValue(id + "/results", "results", expected["results"], replay.Results, results);
Check(results.Count == 0, id + ": command results and messages match the reference" + (results.Count > 0 ? ": " + string.Join("; ", results.Take(6)) : ""));
}
Note(string.Format("golden values compared: {0}", _compared));
foreach (KeyValuePair<string, double> pair in _worst.OrderBy(p => p.Key))
Note(string.Format("largest deviation {0}: {1:E2} over {2} values (tolerance {3:E0})", pair.Key, pair.Value, _counts[pair.Key], Tolerances[pair.Key]));
}
static string Category(string key)
{
switch (key)
{
case "hp":
case "max_hp":
case "shield":
case "grotto_boss_hp":
case "buildings":
return "hp";
case "x":
case "y":
case "progress":
return "position";
case "facing":
return "angle";
case "elapsed":
case "combat_time":
case "events":
return "time";
case "effects":
case "dots":
return "effect";
}
return "other";
}
void CompareValue(string path, string key, object expected, object actual, List<string> problems)
{
if (expected == null || actual == null)
{
if (expected != actual)
problems.Add(path + ": " + Show(expected) + " != " + Show(actual));
return;
}
if (IsNumber(expected) && IsNumber(actual))
{
_compared++;
if (expected is long el && actual is long al)
{
if (el != al)
problems.Add(path + ": " + el + " != " + al);
return;
}
double e = MiniJson.Num(expected);
double a = MiniJson.Num(actual);
string category = Category(key);
double deviation = Math.Abs(e - a);
double tolerance = Math.Max(Tolerances[category], 1e-9 * Math.Abs(e));
double worst;
if (!_worst.TryGetValue(category, out worst) || deviation > worst)
_worst[category] = deviation;
int count;
_counts.TryGetValue(category, out count);
_counts[category] = count + 1;
if (deviation > tolerance || double.IsNaN(a) != double.IsNaN(e))
problems.Add(string.Format("{0}: {1:R} != {2:R}", path, e, a));
return;
}
if (expected is OrderedMap<string, object> em && actual is OrderedMap<string, object> am)
{
if (!em.Keys.SequenceEqual(am.Keys))
problems.Add(path + ": keys " + string.Join(",", em.Keys) + " != " + string.Join(",", am.Keys));
foreach (KeyValuePair<string, object> pair in em)
{
object other;
if (am.TryGetValue(pair.Key, out other))
CompareValue(path + "." + pair.Key, pair.Key, pair.Value, other, problems);
}
return;
}
if (expected is List<object> list && actual is List<object> otherList)
{
if (list.Count != otherList.Count)
{
problems.Add(path + ": " + list.Count + " entries != " + otherList.Count);
return;
}
for (int i = 0; i < list.Count; i++)
CompareValue(path + "[" + i + "]", key, list[i], otherList[i], problems);
return;
}
_compared++;
if (!Equals(Normalize(expected), Normalize(actual)))
problems.Add(path + ": " + Show(expected) + " != " + Show(actual));
}
static bool IsNumber(object value) { return value is long || value is double; }
static object Normalize(object value) { return value is bool b ? (object)b : MiniJson.Str(value); }
static string Show(object value) { return value == null ? "null" : "'" + MiniJson.Str(value) + "'"; }
}
}

View file

@ -0,0 +1,15 @@
namespace Luneraria.Core.Tests
{
/// <summary>Golden replays against the Godot reference (implemented with the scenario files).</summary>
public sealed partial class GoldenSuite : CheckSuite
{
public override string Name { get { return "golden"; } }
protected override void RunChecks()
{
RunScenarios();
}
partial void RunScenarios();
}
}

View file

@ -0,0 +1,24 @@
{
"name": "Luneraria.Core.Tests",
"rootNamespace": "Luneraria.Core.Tests",
"references": [
"Luneraria.Core",
"UnityEngine.TestRunner",
"UnityEditor.TestRunner"
],
"includePlatforms": [
"Editor"
],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": true,
"precompiledReferences": [
"nunit.framework.dll"
],
"autoReferenced": false,
"defineConstraints": [
"UNITY_INCLUDE_TESTS"
],
"versionDefines": [],
"noEngineReferences": false
}

View file

@ -0,0 +1,383 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Port of tests/luneraria/test_lune_m2.gd (73 checks in Godot): heroes (XP, talents, three
/// item slots), attack champions (book n, fight n + 1) and the boss grotto (contest, failure,
/// success, one deployment, lock, bounty). Gold granted below is a fixture on drafts only.
/// The Godot check "legacy phase, workers and sparks stay untouched" referred to Saumwacht
/// autoloads that do not exist in the port; it checks here that the core has no such types.
/// </summary>
public sealed class M2RulesSuite : CheckSuite
{
public override string Name { get { return "m2"; } }
static LuneMatch NewMatch(int seed = 21)
{
var state = new LuneMatch();
state.Setup(new MatchOptions { Seed = seed, HumanSlot = -1, Bunds = new List<string> { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" } });
return state;
}
static void Grant(LuneMatch state, int slot, int gold)
{
state.Players[slot].Draft.Gold += gold;
}
static void RunRound(LuneMatch state, Action<LuneMatch> planner = null)
{
if (planner != null)
planner(state);
state.BeginCombat();
state.FinishCombat();
}
protected override void RunChecks()
{
TestHeroesAndItems();
TestXpAndPersistence();
TestChampions();
TestGrottoWithdrawalAndContest();
TestBossSuccessLockAndDeployment();
TestBossFailureRestores();
TestBotsM2();
TestNoLegacyEconomy();
TestLaneLoss();
}
void TestHeroesAndItems()
{
LuneMatch state = NewMatch();
Grant(state, 0, 5000);
int gold = state.GoldOf(0);
Check(state.Submit(0, Command.HeroBuy("held_ringborke")).Ok && state.GoldOf(0) == gold - 260, "hero costs exactly 260");
Check(!state.Submit(0, Command.HeroBuy("held_funkenlanze")).Ok, "only one active hero per player");
state.Submit(0, Command.Buy("g01"));
string troopUid = state.Players[0].Draft.Roster[0].Uid;
Check(!state.Submit(0, Command.ItemBuy("glutklinge", troopUid)).Ok, "normal troops carry no items");
Check(typeof(RosterEntry).GetField("Items") == null, "troop entries have no inventory");
string heroUid = state.Players[0].Draft.Hero.Uid;
Check(state.Submit(0, Command.ItemBuy("glutklinge", heroUid)).Ok, "hero buys an item");
Check(!state.Submit(0, Command.ItemBuy("glutklinge")).Ok, "items are unique per hero");
Check(!state.Submit(0, Command.ItemBuy("kristallsplitter")).Ok, "item requirement (level 3) enforced");
Check(state.Submit(0, Command.ItemBuy("rankenmantel")).Ok && state.Submit(0, Command.ItemBuy("basaltschild")).Ok, "second and third slot");
Check(!state.Submit(0, Command.ItemBuy("funkenfokus")).Ok, "three item slots at most");
Check(!state.Submit(0, Command.Talent(0, 0)).Ok, "talent needs hero level 3");
UnitDef definition = LuneContent.HeroDefinition(state.Players[0].Draft.Hero);
Check(GdMath.IsEqualApprox(definition.MaxHp, 2200.0 + 350.0) && GdMath.IsEqualApprox(definition.DamageBonus, 0.18) && GdMath.IsEqualApprox(definition.ReductionBonus, 0.10), "item bonuses stack into the hero definition");
int goldBeforeSell = state.GoldOf(0);
state.Submit(0, Command.ItemSell("basaltschild"));
Check(state.GoldOf(0) == goldBeforeSell + 180, "item bought this planning refunds fully");
state.Submit(0, Command.ItemBuy("basaltschild"));
state.BeginCombat();
state.FinishCombat();
HeroState hero = state.Players[0].Hero;
Check(hero.Items.SequenceEqual(new[] { "glutklinge", "rankenmantel", "basaltschild" }), "items persist after the round");
goldBeforeSell = state.GoldOf(0);
state.Submit(0, Command.ItemSell("basaltschild"));
Check(state.GoldOf(0) == goldBeforeSell + 90, "committed item sells for 50 %");
Check(!state.Submit(0, Command.HeroCancel()).Ok, "committed hero cannot be returned for gold");
// Caps: two reduction sources plus a talent stay capped at 35 %.
var probe = new LuneUnit();
probe.Def = new UnitDef { ReductionBonus = 0.40, Ability = "" };
Check(GdMath.IsEqualApprox(probe.DamageReduction(), 0.35), "damage reduction capped at 35 %");
probe.Def = new UnitDef { AttackSpeedBonus = 0.9 };
Check(probe.AttackSpeedFactor() <= 1.5 + 0.0001, "attack tempo capped at +50 %");
}
void TestXpAndPersistence()
{
LuneMatch state = NewMatch(22);
Grant(state, 1, 3000);
state.Submit(1, Command.HeroBuy("held_quellgeflecht"));
for (int i = 0; i < 3; i++)
state.Submit(1, Command.Buy("w03"));
string heroUid = state.Players[1].Draft.Hero.Uid;
state.BeginCombat();
LuneUnit spawned = state.Combat.Units.FirstOrDefault(u => u.PersistentUid == heroUid);
Check(spawned != null && spawned.Kind == "hero" && spawned.Lane == "mid", "hero fights on its lane as its own kind");
state.FinishCombat();
int participation = LuneContent.XpParticipation + LuneContent.XpPerRound * 1;
int xp = state.Players[1].Hero.Xp;
Check(xp > participation, string.Format("supporting hero near fights earns more than participation XP ({0} > {1})", xp, participation));
// Kills are not needed: a hero far away from any death still earns the participation share.
LuneMatch lonely = NewMatch(23);
Grant(lonely, 0, 3000);
lonely.Submit(0, Command.HeroBuy("held_funkenlanze"));
lonely.BeginCombat();
foreach (LuneUnit unit in lonely.Combat.Units)
{
if (unit.Kind == "hero")
unit.Position = new Vec2(-34.0, -34.0);
}
lonely.FinishCombat();
Check(lonely.Players[0].Hero.Xp >= participation, "participation XP without any kill");
// Level, talent and persistence across rounds.
state.Players[1].Hero.Xp = 290;
state.Players[1].Draft = state.Snapshot(state.Players[1]);
Check(LuneContent.LevelFor(290) == 3 && state.Submit(1, Command.Talent(0, 1)).Ok, "talent tier 1 at level 3");
Check(!state.Submit(1, Command.Talent(0, 0)).Ok, "talent choice is final");
Check(!state.Submit(1, Command.Talent(1, 0)).Ok, "tier 2 needs level 6");
state.BeginCombat();
bool healthy = false;
bool talentLive = false;
foreach (LuneUnit unit in state.Combat.Units)
{
if (unit.PersistentUid == heroUid)
{
healthy = unit.Hp == unit.MaxHp && unit.Def.Level == 3;
talentLive = unit.HasTalent("weite_quelle");
unit.Hp = 0.0;
}
}
state.FinishCombat();
Check(healthy && talentLive, "hero starts healed with level and talent");
Check(state.Players[1].Hero.Talents.SequenceEqual(new[] { "weite_quelle" }) && state.Players[1].Hero.Xp >= 290, "hero keeps XP and talents after dying");
LuneMatch fresh = NewMatch(22);
Check(fresh.Players[1].Hero == null && fresh.Players[1].Roster.Count == 0, "new match starts without heroes or XP");
}
void TestChampions()
{
LuneMatch state = NewMatch(24);
Grant(state, 0, 1000);
int gold = state.GoldOf(0);
Check(state.Submit(0, Command.ChampionBook("ch_belagerung", "bot")).Ok && state.GoldOf(0) == gold - 180, "booking costs the champion price");
string bookingUid = state.Players[0].Draft.NewBookings[0].Uid;
state.Submit(0, Command.ChampionBook("ch_druck", "top"));
Check(!state.Submit(0, Command.ChampionBook("ch_schutz", "top")).Ok, "at most two bookings per round");
state.Submit(0, Command.ChampionCancel(state.Players[0].Draft.NewBookings[1].Uid));
Check(state.Players[0].Draft.NewBookings.Count == 1, "fresh booking can be cancelled");
state.BeginCombat();
int inRoundOne = state.Combat.Units.Count(u => u.Kind == "champion");
Check(inRoundOne == 0, "a booking never fights in the round it was bought");
state.FinishCombat();
Check(state.RoundNumber == 2 && state.Announcements().Any(a => a.Kind == "champion" && a.Lane == "bot" && a.Team == 0), "booking is announced publicly for round 2");
Check(!state.Submit(0, Command.ChampionCancel(bookingUid)).Ok, "committed booking cannot be moved or refunded");
state.BeginCombat();
List<LuneUnit> champions = state.Combat.Units.FindAll(u => u.Kind == "champion");
Check(champions.Count == 1 && champions[0].Team == 0 && champions[0].Lane == "bot" && champions[0].Def.ChampionId == "ch_belagerung", "round 2 fields the champion for Blau on Bottom");
// Kill it for a clean bounty check.
LuneUnit champion = champions[0];
champion.Hp = 0.0;
state.Combat.Step();
int bounty = LuneContent.ChampionBounty + LuneContent.ChampionBountyPerRound * 2;
List<CombatEvent> events = state.Combat.Events.FindAll(e => e.Kind == "champion");
Check(events.Count == 1 && events[0].Bounty == bounty, "champion death recorded once with its bounty");
state.FinishCombat();
int rotGain = 0;
for (int slot = 3; slot < 6; slot++)
rotGain += state.LastSettlement.Gains[slot];
Check(rotGain >= bounty, "defenders receive the champion bounty");
Check(state.Players[0].Bookings.Count == 0, "champion is consumed after its round");
state.BeginCombat();
Check(state.Combat.Units.All(u => u.Kind != "champion"), "no champion in round 3");
state.FinishCombat();
// Siege focus: a siege champion next to a tower and a monster attacks the tower.
var map = new LuneMap();
var combat = new LuneCombat(map, LuneBalance.Default(), 1);
LuneUnit tower = combat.AddBuilding(map.BuildingDef("rot_mid_outer"), 6000.0);
LuneUnit siege = combat.Spawn("champion", 0, "mid", LuneContent.ChampionDefinition("ch_belagerung", 1, LuneBalance.Default()), 1.0, 0.0);
siege.Position = tower.Position + new Vec2(-3.0, 0.0);
LuneUnit monster = combat.Spawn("monster", 1, "mid", DataCatalog.GetEnemy("e01"), 1.0, 0.0);
monster.Position = siege.Position + new Vec2(-1.5, 0.0);
// Targetless units search on their staggered step; four steps cover every offset.
for (int i = 0; i < LuneCombat.Retarget; i++)
combat.Step();
Check(siege.Target == tower, "siege champion prefers the attackable tower");
}
static void Expedition(LuneMatch state, int slot, int[] types, string hero = "")
{
Grant(state, slot, 6000);
List<string> ids = state.BaseIds(state.Players[slot].Bund);
foreach (int typeIndex in types)
{
state.Submit(slot, Command.Buy(ids[typeIndex]));
RosterEntry entry = state.Players[slot].Draft.Roster[state.Players[slot].Draft.Roster.Count - 1];
state.Submit(slot, Command.Upgrade(entry.Uid));
state.Submit(slot, Command.Assign(entry.Uid, "grotto"));
}
if (hero.Length > 0)
{
state.Submit(slot, Command.HeroBuy(hero));
state.Submit(slot, Command.Assign(state.Players[slot].Draft.Hero.Uid, "grotto"));
}
}
void TestGrottoWithdrawalAndContest()
{
LuneMatch state = NewMatch(25);
Expedition(state, 1, new[] { 1, 1, 0, 2 });
state.Submit(1, Command.Buy("w01"));
Expedition(state, 4, new[] { 1 });
state.BeginCombat();
List<LuneUnit> grottoUnits = state.Combat.Units.FindAll(u => u.Zone == "grotto" && u.Team != 2);
List<LuneUnit> midTroops = state.Combat.Units.FindAll(u => u.Kind == "troop" && u.Team == 0 && u.Lane == "mid");
Check(grottoUnits.Count == 5 && midTroops.Count == 1, "grotto troops leave the Mid lane (4 in grotto for Blau, 1 on Mid)");
Check(state.Combat.GrottoBoss != null && state.Combat.GrottoBoss.Dormant, "boss waits while two expeditions are present");
double bossHp = state.Combat.GrottoBoss.Hp;
int wokeStep = -1;
bool zonesStable = true;
while (state.Phase == Phase.Combat)
{
state.Combat.Step();
foreach (LuneUnit unit in grottoUnits)
zonesStable = zonesStable && unit.Zone == "grotto";
if (wokeStep < 0 && !state.Combat.GrottoBoss.Dormant)
{
wokeStep = state.Combat.Steps;
bool rotAlive = state.Combat.Units.Any(u => u.Alive && u.Zone == "grotto" && u.Team == 1);
Check(!rotAlive && GdMath.IsEqualApprox(state.Combat.GrottoBoss.Hp, bossHp), "boss wakes untouched once only one expedition survives");
}
if (state.Combat.Finished)
state.Settle();
}
Check(wokeStep > 0 && zonesStable, "expedition stays in the grotto for the whole combat phase");
}
void TestBossSuccessLockAndDeployment()
{
LuneMatch state = NewMatch(26);
for (int i = 0; i < 5; i++)
RunRound(state);
Check(state.RoundNumber == 6, "reached round 6");
Expedition(state, 1, new[] { 1, 1, 1, 0, 2, 2, 2 }, "held_ringborke");
state.BeginCombat();
state.FinishCombat();
Check(state.LastSettlement.Boss == "won", "strong expedition conquers the boss in round 6");
BossClaim claim = state.Boss.Claim;
Check(claim != null && claim.Team == 0 && claim.WonRound == 6 && claim.DeployRound == 7 && claim.ChooserSlot == 1 && claim.Lane == "mid", "claim for Blau: deploy in 7, chooser is the contributing Mid player");
Check(state.Boss.NextAvailableRound == 10, "grotto available again from round 10");
string repeated = state.SettleBoss();
Check(repeated == "repeat" && state.Boss.NextAvailableRound == 10 && state.Boss.Claim != null && state.Boss.Claim.DeployRound == 7, "repeating the settlement signal creates no second claim and keeps the first");
Check(state.Announcements().Any(a => a.Kind == "boss" && a.Team == 0 && a.Lane == "mid"), "deployment announced with lane in the next planning");
Check(!state.Submit(4, Command.BossLaneChoice("top")).Ok && !state.Submit(0, Command.BossLaneChoice("top")).Ok, "only the chooser may redirect the boss");
Check(state.Submit(1, Command.BossLaneChoice("top")).Ok, "chooser redirects the boss to Top");
string entryUid = state.Players[1].Roster[0].Uid;
Check(!state.Submit(1, Command.Assign(entryUid, "grotto")).Ok, "grotto locked in round 7");
state.Submit(1, Command.Assign(entryUid, "mid"));
state.BeginCombat();
List<LuneUnit> bosses = state.Combat.Units.FindAll(u => u.Kind == "boss");
Check(bosses.Count == 1 && bosses[0].Team == 0 && bosses[0].Lane == "top" && bosses[0].Zone == "lane", "round 7: exactly one boss marches for Blau on Top");
double startProgress = bosses[0].Progress;
for (int i = 0; i < 200; i++)
state.Combat.Step();
Check(bosses[0].Progress > startProgress + 5.0 || !bosses[0].Alive, "the boss advances towards Rot's Ancient");
bosses[0].Hp = 0.0;
state.Combat.Step();
int bounty = LuneContent.BossBounty + LuneContent.BossBountyPerRound * 7;
state.FinishCombat();
int[] gains = state.LastSettlement.Gains;
bool rotBossPart = gains[3] + gains[4] + gains[5] >= bounty && Math.Abs(gains[3] - gains[4]) <= 1 + 60 && bounty > 10 * 30;
Check(rotBossPart, string.Format("Rot receives the high boss bounty ({0}), split in the team", bounty));
Check(state.Boss.Claim == null, "claim consumed after the deployment round");
foreach (int lockedRound in new[] { 8, 9 })
{
Check(state.RoundNumber == lockedRound && !state.GrottoAvailable(), string.Format("grotto locked in round {0}", lockedRound));
// Fixture: keep all buildings standing so the strong test army cannot end the match early.
foreach (BuildingDef definition in state.Map.Buildings)
state.BuildingHp[definition.Id] = definition.MaxHp;
RunRound(state);
}
Check(state.RoundNumber == 10 && state.GrottoAvailable(), "grotto open again in round 10");
state.BeginCombat();
Check(state.Combat.Units.All(u => u.Kind != "boss"), "no second deployment");
state.FinishCombat();
}
void TestBossFailureRestores()
{
LuneMatch state = NewMatch(27);
Grant(state, 2, 200);
state.Submit(2, Command.Buy("g01"));
state.Submit(2, Command.Assign(state.Players[2].Draft.Roster[0].Uid, "grotto"));
state.BeginCombat();
double firstHp = state.Combat.GrottoBoss.MaxHp;
state.FinishCombat();
Check(state.LastSettlement.Boss == "failed" && state.Boss.Claim == null && state.GrottoAvailable(), "weak expedition fails without reward or lock");
state.Submit(2, Command.Assign(state.Players[2].Roster[0].Uid, "grotto"));
state.BeginCombat();
Check(state.Combat.GrottoBoss.Hp == state.Combat.GrottoBoss.MaxHp && state.Combat.GrottoBoss.MaxHp >= firstHp, "next attempt faces a fully restored boss");
state.FinishCombat();
bool noGold = state.Combat.Events.Where(e => e.Zone == "grotto").All(e => e.Bounty == 0);
Check(noGold, "grotto deaths pay no gold");
}
void TestBotsM2()
{
LuneMatch state = NewMatch(28);
int heroes = 0;
int bookings = 0;
int grotto = 0;
for (int roundIndex = 0; roundIndex < 12; roundIndex++)
{
if (state.Phase == Phase.Over)
break;
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(state, slot);
foreach (Player player in state.Players)
{
bookings += player.Draft.NewBookings.Count;
grotto += player.Draft.Roster.Count(e => e.Lane == "grotto");
}
state.BeginCombat();
state.FinishCombat();
}
heroes = state.Players.Count(p => p.Hero != null);
Check(heroes >= 3, string.Format("bots recruit heroes ({0})", heroes));
Check(bookings >= 1, string.Format("bots book attack champions ({0})", bookings));
Check(grotto >= 1, string.Format("a bot sends an expedition to the grotto ({0} troop assignments)", grotto));
}
void TestNoLegacyEconomy()
{
// Working assumption E of the reference: the Saumwacht worker/spark economy must not
// exist in this mode. The port carries no such types at all.
string[] legacy = { "GameState", "EconomyManager", "WaveManager" };
bool clean = typeof(LuneMatch).Assembly.GetTypes().All(t => Array.IndexOf(legacy, t.Name) < 0);
LuneMatch state = NewMatch(29);
for (int i = 0; i < 3; i++)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(state, slot);
state.BeginCombat();
state.FinishCombat();
}
Check(clean, "no legacy phase, worker or spark economy in the core");
bool hasSparkFields = typeof(Player).GetField("Sparks") != null || typeof(Player).GetField("Workers") != null;
Check(!hasSparkFields, "Luneraria players have gold only, no sparks or workers");
}
void TestLaneLoss()
{
// A player whose lane is broken is not eliminated: they keep planning, defending and helping.
LuneMatch state = NewMatch(30);
state.BuildingHp["blau_top_outer"] = 0.0;
state.BuildingHp["blau_top_inner"] = 0.0;
state.Players[0].Draft = state.Snapshot(state.Players[0]);
Check(state.Submit(0, Command.Buy("g03")).Ok, "lane-less player can still buy");
Check(state.Submit(0, Command.Assign(state.Players[0].Draft.Roster[0].Uid, "mid")).Ok, "and support another lane");
state.Submit(0, Command.Buy("g01"));
state.BeginCombat();
int top = 0;
int mid = 0;
foreach (LuneUnit unit in state.Combat.Units)
{
if (unit.OwnerSlot == 0)
{
top += unit.Lane == "top" ? 1 : 0;
mid += unit.Lane == "mid" ? 1 : 0;
}
}
Check(top == 1 && mid == 1, "troops fight on the broken lane and on the supported lane");
Check(LuneMap.IsAttackable(state.Map.BuildingDef("blau_base_1"), state.Combat.AliveMap()), "the broken lane opens Blau's base towers");
state.FinishCombat();
Check(state.Phase != Phase.Over || state.Winner != LuneMatch.Ongoing, "the match continues or ends only by an Ancient");
Check(state.Players[0].Roster.Count == 2 && state.LastSettlement.Income > 0, "lane-less player still owns troops and earns income");
}
}
}

View file

@ -0,0 +1,194 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Text;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Minimal JSON reader for the golden files, without package dependencies (the same code
/// runs in Unity and in the .NET harness). Objects keep their key order; numbers without
/// fraction or exponent become long, all others double.
/// </summary>
public static class MiniJson
{
public static object Parse(string text)
{
int at = 0;
object value = ReadValue(text, ref at);
SkipSpace(text, ref at);
if (at != text.Length)
throw new FormatException("trailing characters at " + at);
return value;
}
static void SkipSpace(string text, ref int at)
{
while (at < text.Length && char.IsWhiteSpace(text[at]))
at++;
}
static object ReadValue(string text, ref int at)
{
SkipSpace(text, ref at);
char c = text[at];
if (c == '{')
return ReadObject(text, ref at);
if (c == '[')
return ReadArray(text, ref at);
if (c == '"')
return ReadString(text, ref at);
if (text.Length - at >= 4 && string.CompareOrdinal(text, at, "true", 0, 4) == 0)
{
at += 4;
return true;
}
if (text.Length - at >= 5 && string.CompareOrdinal(text, at, "false", 0, 5) == 0)
{
at += 5;
return false;
}
if (text.Length - at >= 4 && string.CompareOrdinal(text, at, "null", 0, 4) == 0)
{
at += 4;
return null;
}
return ReadNumber(text, ref at);
}
static OrderedMap<string, object> ReadObject(string text, ref int at)
{
var result = new OrderedMap<string, object>();
at++;
SkipSpace(text, ref at);
if (text[at] == '}')
{
at++;
return result;
}
while (true)
{
SkipSpace(text, ref at);
string key = ReadString(text, ref at);
SkipSpace(text, ref at);
if (text[at] != ':')
throw new FormatException("expected ':' at " + at);
at++;
result[key] = ReadValue(text, ref at);
SkipSpace(text, ref at);
if (text[at] == ',')
{
at++;
continue;
}
if (text[at] == '}')
{
at++;
return result;
}
throw new FormatException("expected ',' or '}' at " + at);
}
}
static List<object> ReadArray(string text, ref int at)
{
var result = new List<object>();
at++;
SkipSpace(text, ref at);
if (text[at] == ']')
{
at++;
return result;
}
while (true)
{
result.Add(ReadValue(text, ref at));
SkipSpace(text, ref at);
if (text[at] == ',')
{
at++;
continue;
}
if (text[at] == ']')
{
at++;
return result;
}
throw new FormatException("expected ',' or ']' at " + at);
}
}
static string ReadString(string text, ref int at)
{
if (text[at] != '"')
throw new FormatException("expected string at " + at);
at++;
var builder = new StringBuilder();
while (true)
{
char c = text[at++];
if (c == '"')
return builder.ToString();
if (c != '\\')
{
builder.Append(c);
continue;
}
char escape = text[at++];
switch (escape)
{
case '"': builder.Append('"'); break;
case '\\': builder.Append('\\'); break;
case '/': builder.Append('/'); break;
case 'b': builder.Append('\b'); break;
case 'f': builder.Append('\f'); break;
case 'n': builder.Append('\n'); break;
case 'r': builder.Append('\r'); break;
case 't': builder.Append('\t'); break;
case 'u':
builder.Append((char)int.Parse(text.Substring(at, 4), NumberStyles.HexNumber, CultureInfo.InvariantCulture));
at += 4;
break;
default: throw new FormatException("bad escape at " + at);
}
}
}
static object ReadNumber(string text, ref int at)
{
int start = at;
bool fraction = false;
while (at < text.Length && "+-0123456789.eE".IndexOf(text[at]) >= 0)
{
if (text[at] == '.' || text[at] == 'e' || text[at] == 'E')
fraction = true;
at++;
}
string token = text.Substring(start, at - start);
if (!fraction)
{
long whole;
if (long.TryParse(token, NumberStyles.Integer, CultureInfo.InvariantCulture, out whole))
return whole;
}
return double.Parse(token, NumberStyles.Float, CultureInfo.InvariantCulture);
}
// ---------------------------------------------------------------- access helpers
public static OrderedMap<string, object> Obj(object value) { return (OrderedMap<string, object>)value; }
public static List<object> Arr(object value) { return (List<object>)value; }
public static double Num(object value) { return value is long l ? l : (double)value; }
public static int Int(object value) { return value is long l ? (int)l : (int)(double)value; }
public static string Str(object value) { return value == null ? "" : value.ToString(); }
public static object Get(OrderedMap<string, object> map, string key)
{
object value;
return map.TryGetValue(key, out value) ? value : null;
}
}
}

View file

@ -0,0 +1,31 @@
using System.IO;
using NUnit.Framework;
using UnityEngine;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Runs the engine-independent core suites in Unity's Test Runner (EditMode). The same
/// suites run outside Unity through tools/dotnet/Luneraria.Core.Tests.
/// </summary>
public sealed class CoreSuiteTests
{
static string GoldenDirectory { get { return Path.Combine(Application.dataPath, "Luneraria/Tests/Core/Golden"); } }
static void RunSuite(CheckSuite suite)
{
suite.GoldenDirectory = GoldenDirectory;
bool ok = suite.Run();
foreach (string note in suite.Notes)
Debug.Log(suite.Name + ": " + note);
Assert.IsTrue(ok, suite.Name + ": " + suite.Failures.Count + " of " + suite.Checks + " checks failed:\n" + string.Join("\n", suite.Failures));
Debug.Log(string.Format("LUNE_U1_PASS {0}: {1} checks", suite.Name, suite.Checks));
}
[Test] public void Content() { RunSuite(new ContentSuite()); }
[Test] public void CoreRules() { RunSuite(new CoreRulesSuite()); }
[Test] public void M2Rules() { RunSuite(new M2RulesSuite()); }
[Test] public void GoldenReplays() { RunSuite(new GoldenSuite()); }
[Test] public void Determinism() { RunSuite(new DeterminismSuite()); }
}
}

24
README.md Normal file
View file

@ -0,0 +1,24 @@
# Champions of Luneraria · Unity
Drei-Lane-Autobattler 3 gegen 3 (Mischung aus Dota 2 und Legion TD 2), portiert von Godot nach Unity.
- Engine: Unity 6.3 LTS (6000.3.20f1), URP.
- Zielplattformen: Fedora 44, Windows 11. macOS ist die Entwicklungsplattform.
- Referenz: `../saumwacht-godot`, Zweig `luneraria/g3` (`1b540f2`).
## Aufbau
- `Assets/Luneraria/Core/`: Regelkern ohne Unity-Abhängigkeit (`Luneraria.Core`).
- `Assets/Luneraria/Tests/Core/`: Prüfsuiten mit Golden-Referenzen aus Godot; laufen im Unity Test Runner und mit dem .NET SDK.
- `tools/`: .NET-Projekte, Godot-Referenzwerkzeuge, Portwerkzeuge.
- `docs/port/`: Stand, Entscheidungen und QA je Meilenstein ([PORT-STATUS.md](docs/port/PORT-STATUS.md)).
## Befehle
```bash
sh tools/run_core_tests.sh
```
```bash
dotnet run -c Release --project tools/dotnet/Luneraria.Core.Bench
```

19
docs/port/PORT-QA.md Normal file
View file

@ -0,0 +1,19 @@
# Port-QA
Je Meilenstein: Tests, Bild, Leistung, bekannte Unterschiede, Commit.
## U1 · C#-Simulation
| Punkt | Ergebnis |
| --- | --- |
| Tests | `content` 75/75, `core` 91/91, `m2` 73/73, `golden` 157/157, `determinism` 6/6 = **402/402**; dreimal in getrennten Prozessen; Protokoll `logs/u1/core-tests.json` |
| Golden-Parität | 19 Szenarien, 120 Snapshots, 51.221 Werte; größte Abweichung 0 in allen Kategorien (Toleranzen siehe U1-RULES.md) |
| Bild | `screenshots/u1/replay-start_three_lanes-t10.png`, `replay-boss_cycle-marching.png`, `replay-grotto_contested-t15.png`: Godot-Positionen (Punkte) und C#-Positionen (Ringe) decken sich (31/31, 35/35, 15/15) |
| Leistung (Mac M5) | Simulationsschritt bei 120 Einheiten: Godot 1,587 ms, C#/.NET 8 0,259 ms; Bot-Partien pro Runde: 648 ms zu 36 ms (`logs/u1/sim-bench-*.json`) |
| Bekannte Unterschiede | technische Repräsentation (Klassen statt Dictionaries, stabile Sortierung, 32-Bit-int, kein ungenutzter Zufallsgenerator); Referenzfehler R1 bewusst übernommen |
| Offen | Lauf im Unity-Editor (Lizenz fehlt), Mono-Leistung, Kerntests auf Windows 11 und Fedora 44 |
| Commit | Zweig `luneraria/u1` |
## U2 bis U5
Noch nicht begonnen.

39
docs/port/PORT-STATUS.md Normal file
View file

@ -0,0 +1,39 @@
# Port-Status · Champions of Luneraria in Unity
Stand: 30. September 2026.
Referenz ist der Godot-Stand `luneraria/g3` (`1b540f2`) in `../saumwacht-godot`; er wird nicht mehr verändert. Engine: Unity 6.3 LTS (6000.3.20f1), Renderer URP. HDRP ist ausgeschlossen, bis ein Vergleich mit einer echten Szene etwas anderes nahelegt.
Zielplattformen sind Fedora 44 und Windows 11; macOS ist die Entwicklungsplattform.
| Meilenstein | Zustand | Nachweis |
| --- | --- | --- |
| U1 · C#-Simulation | **fertig außerhalb von Unity**: 402/402 Prüfungen, Golden-Parität mit Godot; Unity-Editor-Lauf offen (Lizenz) | [U1-RULES.md](U1-RULES.md) |
| U2 · Unity-Spielwelt | nicht begonnen; Voraussetzung: U1 grün und Unity-Lizenz aktiv | [U2-WORLD.md](U2-WORLD.md) |
| U3 · G3-HUD portieren | nicht begonnen | [U3-UI.md](U3-UI.md) |
| U4 · Netzwerk | nicht begonnen; M4-Umfang ist erfasst | [U4-NETWORK.md](U4-NETWORK.md) |
| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | nicht begonnen | [U5-VISUAL.md](U5-VISUAL.md) |
QA je Meilenstein: [PORT-QA.md](PORT-QA.md).
## Blocker
- **Unity-Lizenz:** Unity startet im Batch-Modus, meldet aber „No valid Unity Editor license found“. Nötig ist eine Anmeldung im Unity Hub mit aktivierter Personal-Lizenz; das kann nur der Projektinhaber erledigen.
- Bis dahin gibt es keine `ProjectSettings/` und keine `.meta`-Dateien; Unity erzeugt sie beim ersten Öffnen.
## Entscheidungen
- **Regelkern ohne Unity** (`Luneraria.Core`, `noEngineReferences`). Unity wird Adapter und Präsentation.
- **Verhaltensgleichheit statt Bitgleichheit.** Verglichen wird mit dokumentierten Toleranzen; gemessen wurde auf dem Mac trotzdem Abweichung 0.
- **Tabellen** einmal aus Godot erzeugt (`tools/port/gen_content.py`); danach ist `ContentData.cs` die gepflegte Quelle.
- **Referenzfehler R1** im Bot-Gedächtnis ist bewusst übernommen (Parität) und für eine spätere Bot-Phase vorgemerkt.
- **Geplante Helden** (Lucy, Ghaliga, Siegfried, Feena) stehen im Datenmodell, sind aber inaktiv, bis eine Heldenphase ihre Regeln umsetzt.
- **Assets:** Blender-Quellen und Generatoren sind die Quelle. Ein zentraler, reproduzierbarer Blender-Export erzeugt FBX für Unity; GLB nur, wo keine bessere Quelle existiert (U2).
## Arbeitsweise
- Ein Zweig je Meilenstein: `luneraria/u1`, `luneraria/u2`, … Nach jedem Meilenstein kommen Tests, Bild, Leistung, bekannte Unterschiede und ein Commit.
- Werkzeuge:
- `tools/run_core_tests.sh` (Kern).
- `tools/godot-golden/run_godot.sh` (Godot-Referenz).
- `tools/dotnet/Luneraria.Core.Bench` (Leistung).

170
docs/port/U1-RULES.md Normal file
View file

@ -0,0 +1,170 @@
# U1 · Regelkern in C#
Stand: 30. September 2026 · Zweig `luneraria/u1` · Referenz: Godot-Stand `luneraria/g3` (`1b540f2`, Regeln unverändert seit G2).
**Ergebnis:** Der vollständige, engineunabhängige Regelkern läuft in C#. Er ist deterministisch und reproduzierbar und verhält sich in allen geprüften Fällen wie die Godot-Referenz: 402 von 402 Prüfungen, darunter 157 Golden-Vergleiche mit 51.221 Einzelwerten.
**Noch offen:** der Lauf im Unity-Editor. Die Unity-Lizenz fehlt noch; bis dahin baut und testet der .NET-8-SDK den Kern mit Unitys Sprach- und API-Stand (C# 9, .NET Standard 2.1).
## Aufbau
| Pfad | Inhalt |
| --- | --- |
| `Assets/Luneraria/Core/` | Assembly `Luneraria.Core`, `noEngineReferences: true` – keine Unity-Abhängigkeit |
| `Core/Model/` | `Vec2` (Bodenvektor), `GdMath` (Skalarfunktionen), `OrderedMap` (Map mit fester Reihenfolge) |
| `Core/Content/` | Tabellen (`ContentData.cs`, einmalig aus Godot erzeugt, seitdem gepflegte Quelle), Typen, `LuneBalance`, `LuneContent`, `LuneWaves`, `DataCatalog`, geplante Helden |
| `Core/Simulation/` | `LuneMap`, `LuneUnit`, `LuneCombat`, `LuneMatch` mit Partiemodell, `LuneBot` |
| `Assets/Luneraria/Tests/Core/` | Assembly `Luneraria.Core.Tests`: fünf Prüfsuiten, Golden-Dateien, Unity-Adapter (`Unity/CoreSuiteTests.cs`) |
| `tools/dotnet/` | .NET-Projekte: Kern als `netstandard2.1`, Testlauf, Leistungsmessung |
| `tools/godot-golden/` | Werkzeuge, die im Godot-Referenzprojekt laufen: Tabellenexport, Golden-Szenarien, Gleitkomma-Probe, Leistungsmessung |
| `tools/port/gen_content.py` | einmalige Erzeugung von `ContentData.cs` aus dem Godot-Export |
Unity dient später nur als Adapter und Präsentationsschicht (U2): Er treibt `LuneMatch` im festen Takt an und liest den Zustand.
## Portierter Umfang
| Bereich | Umsetzung | Quelle in Godot |
| --- | --- | --- |
| Matchzustand, Phasen Planung/Kampf/Ende, sechs Plätze, zwei Teams | `LuneMatch`, `MatchModel.cs` | `lune_match.gd` |
| Private Planung mit Entwurf, Rückgängig, atomare Übernahme bei Kampfbeginn | `Submit`, `Undo`, `BeginCombat`, `DraftValid` | `lune_match.gd` |
| Gold, Käufe, Aufwertungen, Rückgabe (voll in derselben Planung, sonst 75 %) | `Apply` | `lune_match.gd` |
| Formation: Reihen zu viert, Lane-Besitzer vorn, Rollenreihenfolge | `SpawnArmies`, `Sorted` | `lune_match.gd` |
| Drei Lanes, Geometrie, Bottom so lang wie Top | `LuneMap` | `lune_map.gd` |
| Zwei Laneturme je Lane, zwei Basistürme, Ancient, Sperren | `LuneMap.IsAttackable`, `LuneCombat` | `lune_map.gd`, `lune_combat.gd` |
| Einheiten, Effekte, Schilde, Schaden über Zeit, Schadensmatrix | `LuneUnit` | `lune_unit.gd` |
| Kampf: Ziele, Marsch, Trennung, Angriffe, Fähigkeiten, Tode, Ende | `LuneCombat` | `lune_combat.gd` |
| Unbegrenzte Wellen mit steigender Stärke | `LuneWaves` | `lune_waves.gd` |
| Helden: Stufen, Erfahrung, drei Itemplätze, Talente | `LuneContent.HeroDefinition`, `Apply` | `lune_content.gd`, `lune_match.gd` |
| Normale Einheiten ohne Items | `item_buy` lehnt Truppen-IDs ab; `RosterEntry` hat kein Inventar | `lune_match.gd` |
| Angriffschampions: Buchung n, Einsatz n+1, Kopfgeld, Verbrauch | `SpawnChampions`, `Settle` | `lune_match.gd` |
| Bossgrotte: Expeditionen, ruhender Boss, Anspruch, Entsendung, Kopfgeld, dreirundige Sperre | `SpawnArmies`, `SettleBoss`, `LuneCombat.UpdateGrotto` | `lune_match.gd`, `lune_combat.gd` |
| Bots über dieselbe Befehlsschnittstelle | `LuneBot` | `lune_bot.gd` |
| Siegbedingung: nur Ancient-Zerstörung; zwei im selben Schritt = Unentschieden | `Settle` | `lune_match.gd` |
Erhalten sind ausdrücklich: 3 gegen 3 auf drei Lanes, unbegrenzte Wellen, Partieende nur durch einen Ancient, zwei Laneturme je Lane und zwei Basistürme je Team, Heldenentwicklung, Angriffschampions, Bossgrotte mit Sperre. Die Architektur ist serverautoritativ: `LuneMatch` läuft nur beim Host; Clients senden `Command`-Absichten. Alte Saumwacht-Regeln (Arbeiter, Funken, Wellen der Kampagne) sind nicht portiert; ein Test prüft, dass der Kern keine solchen Typen enthält.
## Determinismus
- **Fester Takt:** `SimStep` 0,05 s. Die Simulation liest weder Uhr noch Bildrate; Unity treibt sie mit einem Akkumulator an.
- **Reihenfolgen:** Schleifen laufen nur über Listen in Spawnreihenfolge und über `OrderedMap` (Einfügereihenfolge, auch nach Entfernen stabil). `Dictionary` dient nur zum Nachschlagen; ein Codeaudit bestätigt, dass keine Regel darüber iteriert.
- **IDs:** Einheiten-IDs zählen je Kampf von 1 hoch; UIDs sind `p{Platz}-{Folge}`, `h…`, `c…`, `boss-{Runde}`. Keine Objektidentität, keine Hashwerte.
- **Sortierung:** Alle Vergleiche sind strenge Ordnungen auf eindeutigen Schlüsseln (Distanz, dann Einheiten-ID bzw. Rolle, dann Folge). Der Kern sortiert stabil per Einfügen.
- **Zufall:** kommt in den Regeln nicht vor (in Godot wurde der Zufallsgenerator nur initialisiert, nie benutzt).
- **Kein geteilter Zustand:** Zwei Partien im Wechsel liefern dasselbe wie einzeln.
- **Physik und Threads:** nicht beteiligt.
Nachweis: Die Suite `determinism` spielt dieselben Partien doppelt und verschachtelt. Alle Suiten liefen in drei getrennten Prozessen grün; .NET streut die String-Hashwerte je Prozess neu.
## Zahlen und Toleranzen
Positionen sind Vektoren mit 32-Bit-Komponenten, alle anderen Größen 64 Bit, wie in der Referenz. `Vec2` rechnet wie Godots Vector2: einfache Einzelgenauigkeit ohne fusionierte Multiply-Add-Operationen. Die Probe `tools/godot-golden/float_probe.gd` zeigt 4.000 von 4.000 identischen Stichproben.
Bitgleichheit ist **kein Ziel**. Verglichen wird fachlich:
| Kategorie | Toleranz | Größte gemessene Abweichung (Mac, .NET 8) |
| --- | --- | --- |
| Ganzzahlen, Texte, Flags: Besitz, Gold, Käufe, Aufwertungen, Ziele (Einheiten-ID), Zustände, Gewinner, Befehlsergebnisse und Meldungen | exakt | – |
| Lebenspunkte, Schilde, Gebäude-HP | 0,01 HP bzw. 10⁻⁹ relativ | 0 (5.679 Werte) |
| Positionen, Lanefortschritt | 1 mm | 0 (3.420 Werte) |
| Blickrichtung | 10⁻⁴ rad | 0 (1.140 Werte) |
| Kampfzeit, Ereigniszeiten | 10⁻⁶ s | 0 (780 Werte) |
| Effektstärken und -dauern | 10⁻⁶ | 0 (264 Werte) |
Auf anderen Laufzeiten (Unity-Mono, IL2CPP, Windows x64, Linux) sind winzige Rundungsunterschiede möglich; dafür sind die Toleranzen da. Weil der Host autoritativ rechnet, müssen Clients die Simulation nicht selbst nachrechnen.
## Bewusst andere technische Repräsentation
| Godot | C# | Warum gleichwertig |
| --- | --- | --- |
| Definitionen, Spieler, Entwürfe, Helden, Buchungen als `Dictionary` | Klassen (`UnitDef`, `Player`, `Draft`, `HeroState`, `Booking`, `BossClaim`) | Standardwerte entsprechen den `get(key, default)`-Fallbacks (z. B. `SiegeFactor = 1`) |
| leeres `{}` für „kein Held“ bzw. „kein Anspruch“ | `null` | – |
| Befehle als `Dictionary` | `Command` mit Fabrikmethoden; fehlende Felder = `null` (z. B. Kauf ohne Lane = eigene Lane) | Nachrichten und Ablehnungsgründe identisch (Golden-Vergleich) |
| `sort_custom` (Introsort, instabil) | stabile Einfügesortierung | bei strengen Ordnungen identisches Ergebnis |
| GDScript-`int` (64 Bit) | `int` (32 Bit) | Werte liegen weit darunter; Monsterkopfgeld wird zusätzlich begrenzt |
| Rückgängig über dieselbe `submit`-Funktion der Sitzung | `LuneMatch.Undo(slot)`; `Submit` kennt „undo“ nicht | wie in der Referenz, wo die Sitzung `undo()` direkt ruft |
| `rng` in `LuneMatch` | entfällt | wurde nie benutzt |
| Tabellen in `DataCatalog`/`LuneContent` | `ContentData.cs`, einmal erzeugt | Suite `content` vergleicht alles mit dem Godot-Export |
## Referenzfehler R1 (übernommen)
`lune_bot.gd`, `_plan_boss`: Ein Bot, dessen Gedächtnis nur eine Champion-Buchung enthält, liest die fehlenden Felder `bar` und `sent`. GDScript bricht die Funktion dann ab. Folge: Ein Bossjäger-Bot, der vorher einen Champion gebucht hat, schickt nie eine Expedition in die Grotte. Beim Erzeugen der Golden-Referenzen trat der Fehler in vier Szenarien auf (`Invalid access to property or key 'bar'`).
Der Port bildet das Verhalten bewusst nach (`LuneBot.PlanBoss`, Kommentar R1), damit die Bots fachlich gleich bleiben. **Empfehlung:** in einer späteren Balance- oder Bot-Phase korrigieren, mit neuer Golden-Referenz.
## Golden-Referenzen
`Assets/Luneraria/Tests/Core/Golden/scenarios.json` beschreibt 19 Szenarien als Abfolge von Operationen. Operationen sind: Aufbau, Fixture wie Gold, Runde oder Gebäude-HP, Befehl, Bots, Kampfbeginn, Schritte, Kampfende, Zustandsaufnahme. `tools/godot-golden/golden_scenarios.gd` spielt sie mit den Godot-Regeln und schreibt `golden.json`; die Suite `golden` spielt sie in C# und vergleicht jeden Snapshot Feld für Feld.
| Szenario | Deckt ab |
| --- | --- |
| `start_three_lanes` | normaler Drei-Lane-Start, Aufmarsch, Einheiten nach 10 und 30 s mit Zielen, Abrechnung |
| `lane_outer_lost` | Lane ohne äußeren Turm |
| `lane_both_lost` | Lane ohne beide Türme, Basis offen |
| `base_tower_attack` | Angriff auf Basistürme bis zum Fall des Ancients |
| `ancient_destruction` | Ancient-Zerstörung, Sieger, Partieende |
| `high_wave_21`, `high_wave_137` | Wellen über 20 und über 100: Zusammensetzung, Werte, Kampf, Belohnungen |
| `hero_levels` | Helden über neun Runden: Anwerben, Erfahrung, Stufen bis 6, Talente, Items |
| `item_purchase` | Itemkauf, Plätze, Einzigartigkeit, Stufenbedingung, Rückgabe, Truppen ohne Items |
| `attack_champion` | Buchung, Grenze, Rücknahme, Ankündigung, Einsatz, Kopfgeld, Verbrauch |
| `boss_cycle` | Bossversuch erfolgreich, Anspruch, Lane-Wahl, Entsendung, Boss getötet mit Kopfgeld, Sperre in Runde 8 und 9, wieder offen in Runde 10, keine zweite Entsendung |
| `boss_failure` | gescheiterter Bossversuch ohne Sperre, voller Boss beim nächsten Versuch |
| `grotto_contested` | umkämpfte Grotte, ruhender Boss, Sieger erhält Anspruch |
| `mirror_match` | exakte Symmetrie bei gleichen Bünden und Profilen |
| `bot_match_1/2/3/11/28` | fünf vollständige Bot-Partien bis zum Ancient-Fall |
Verglichen werden je Snapshot: Phase, Runde, Gewinner, Endnotiz; je Platz Gold, Entwurfsgold, Bereitschaft, Einnahmen, Ausgaben, Armee, Held, Buchungen und Rückgängig-Tiefe; Gebäude-HP; Bosszustand, Bosslog und Ankündigungen; die Abrechnung (Gewinne, Einkommen, verlorene Gebäude, Endgrund, Kills je Lane, Erfahrung); Kampfzustand und Grotte. Optional kommen hinzu: jede Einheit mit ID, Art, Team, Lane, Zone, Besitz, Leben, Schild, Position, Blickrichtung, Fortschritt, Ziel, Trefferzahl, Effekten und Schaden über Zeit; jedes Ereignis; die Welle. Dazu das Ergebnis und die Meldung jedes Befehls.
Beim ersten Vergleich zeigte sich ein Fehler im Exporter, nicht im Port: `Array(typed_array)` liefert in Godot dieselbe Referenz. Frühe Snapshots enthielten deshalb schon den späteren Ancient-Fall. Behoben mit `duplicate()`.
Bildnachweis: `docs/port/screenshots/u1/replay-*.png` zeigt die Karte mit den Godot-Positionen (Punkte) und den C#-Positionen (weiße Ringe). In allen drei Bildern stehen alle lebenden Einheiten an der Referenzposition (31/31, 35/35, 15/15).
## Tests
| Suite | Prüfungen | Inhalt |
| --- | ---: | --- |
| `content` | 75 | alle Tabellen und die berechnete Karte gegen den Godot-Export; Geometrieabweichung 0 m |
| `core` | 91 | Port von `test_lune_core.gd` (91 in Godot) |
| `m2` | 73 | Port von `test_lune_m2.gd` (73 in Godot); die Saumwacht-Autoload-Prüfung ist durch „keine Altökonomie-Typen im Kern“ ersetzt |
| `golden` | 157 | 19 Szenarien, 120 Snapshots, 51.221 Werte |
| `determinism` | 6 | Wiederholung, Verschachtelung, IDs, fester Takt |
| **gesamt** | **402** | alle bestanden, dreimal in getrennten Prozessen |
```bash
sh tools/run_core_tests.sh
```
Mit `REGENERATE_GOLDEN=1` werden vorher die Godot-Referenzen neu erzeugt. Das braucht `../saumwacht-godot`; das Referenz-Repository bleibt unverändert. Protokoll: `docs/port/logs/u1/core-tests.json`. Im Unity Test Runner laufen dieselben Suiten über `Luneraria.Core.Tests` (EditMode), sobald die Lizenz aktiv ist.
## Leistung
Gleiche Aufstellung wie die Godot-Benchmark-Probe: Runde 13, sechs Armeen am Versorgungslimit, 120 bewegliche Einheiten. Gemessen auf dem Mac (Apple M5).
| | Godot 4.7.2 (GDScript) | C# (.NET 8, Release) | Faktor |
| --- | ---: | ---: | ---: |
| Simulationsschritt, Median aus 5 × 200 | 1,587 ms | 0,259 ms | 6,1 × |
| Bot-Partien Seeds 1–3 (32 Runden) | 20,7 s | 1,17 s | 17,8 × |
Rohdaten: `docs/port/logs/u1/sim-bench-{godot,csharp}.json`.
- Unitys Editor läuft mit Mono; die Messung dort folgt mit U2.
- Der Faktor gegenüber Godot ist ein Anhaltspunkt, keine Zusage für den Player.
## Geplante Helden
Lucy, Ghaliga, Siegfried und Feena stehen als `PlannedHeroes` im Datenmodell:
- Volk, Rolle, Formationsreihe, Waffe, Aussehen, Ziel.
- Fähigkeit und Ultimate mit Auslöser (automatisch bzw. Command-Menü) und Wirkungsart.
- Balanceparameter wie Radius, Dauer, Stärke, Ziele, Abklingzeit und Betäubung; sie sind noch leer (`null` = nicht festgelegt).
Sie sind nicht aktiv und nicht käuflich; die Partie bietet weiter nur die drei Referenzhelden, damit die Parität gilt.
Für die Umsetzung sind neue Regeln nötig:
- Betäubung (Excalibur).
- Bindung/Aggro (Versteinerung).
- Ausnahme vom Tempodeckel (Turbopfeile).
- Kampfbefehle während der Kampfphase (Command-Menü; heute gibt es nur Planungsbefehle).
## Offene Punkte
- Unity-Lizenz aktivieren, dann Kern und Tests im Editor (Mono) laufen lassen und die Mono-Leistung messen.
- Kerntests auf Windows 11 und Fedora 44 ausführen (Zielplattformen). Der Kern ist plattformneutral, der Lauf dort steht aber aus.
- R1 später korrigieren (siehe oben).

51
docs/port/U2-WORLD.md Normal file
View file

@ -0,0 +1,51 @@
# U2 · Unity-Spielwelt
Stand: 30. September 2026 · **nicht begonnen**.
Voraussetzungen: U1 grün (erfüllt außerhalb von Unity) und aktive Unity-Lizenz (fehlt).
## Ziel
Die bestehende Partie läuft sichtbar in Unity (URP). Das ist noch keine grafische Neugestaltung.
Zuerst kommen:
- die G1-Drei-Lane-Karte,
- die G2-Türme (Grenzwacht, Bastion, Kernwacht),
- die G2-Ancients,
- die Bossgrotte und die Schlundkrone,
- repräsentative vorhandene Einheiten.
## Vorgaben
- **Adapter:** Unity treibt `LuneMatch` im festen Takt (0,05 s) über einen Akkumulator, unabhängig von der Bildrate. Ansichten lesen nur; sie schreiben nie in die Regeln.
- **Gemeinsames Materialsystem** (URP, Shader Graph oder HLSL): Stein, Metall, Holz, Vegetation, Kristall, Runen, Wasser. Die G2-Shader aus Godot (stone, metal, crystal, rune, cloth, vein, water_glow, hide) dienen als fachliche Vorlage. Automatische Materialkonvertierungen werden nicht ungeprüft übernommen.
- **Assets:** Quelle sind die Blender-Dateien und Generatoren im Referenz-Repository. Ein zentraler, reproduzierbarer Blender-Export erzeugt FBX für Unity; GLB nur, wo keine bearbeitbare Quelle existiert.
## Bestand an Quellen (Godot-Referenz)
| Gruppe | GLB | Blender-Quelle |
| --- | ---: | --- |
| Truppen, Monster, Sendungen (`assets/models`) | 59 | `assets-source/phase4/blend` (59 `.blend`) |
| Figuren mit Skelett (`assets/characters`) | 6 | `assets-source/phase10`–`phase19` (je ein `.blend` plus Generator) |
| G2-Bauwerke, Grotte, Schlundkrone (`assets/luneraria/g2`) | 6 | `assets-source/luneraria/g2` (`.blend` plus Generatoren) |
| Völker (`assets/luneraria/peoples`) | 7 | `assets-source/luneraria/peoples` |
| Umgebung, Vergleich, übrige | 11 | teilweise; wird im Exportinventar geklärt |
## Prüfpunkte der Pipeline
- Maßstab (1 Einheit = 1 m) und Achsen (Godot Y-oben/−Z-vorn gegenüber Unity Y-oben/+Z-vorn).
- Root Bone und Bone-Namen.
- Clip-Namen: idle, walk, attack_a, attack_b, hit, ability, death, roar, victory.
- Animation Events.
- Materialslots.
- Normalen und Tangenten.
- Blend Shapes, sofern vorhanden.
## Abnahme
- Die Partie läuft sichtbar.
- Die Kamera entspricht der Godot-Kamera.
- Screenshots neben den Godot-G2-Bildern mit vergleichbarer Kamera.
- Leistung gemessen, einschließlich Mono-Simulationszeit.
- Bekannte Unterschiede dokumentiert.
- Commit auf `luneraria/u2`.

21
docs/port/U3-UI.md Normal file
View file

@ -0,0 +1,21 @@
# U3 · G3-HUD portieren
Stand: 30. September 2026 · **nicht begonnen**.
Funktionale Referenz ist das G3-HUD aus Godot (`../saumwacht-godot/docs/luneraria/G3-HUD.md`, Commit `1b540f2`). Es wird übertragen, nicht neu erfunden.
## Zu übertragen
- Ancient-Anzeige, Welle und Phase, Gold.
- Drei Lanes mit Bedrohung und Türmen, Teammitglieder.
- Einheitenkarten, Armee.
- Held mit Stufe, Erfahrung, drei Itemslots und Talenten.
- Angriffschampions, nächste Welle, Bossstatus.
- Minimap, Bereit-Zustand, Tooltips, Ereignisse.
- Datenzugang über ein Sichtmodell mit denselben Privatsphäre-Regeln wie `LuneHudModel`.
## Offene Entscheidung
UI Toolkit oder uGUI: vor Beginn eine kurze technische Bewertung, dann **ein** System als Hauptlösung.
Die geplanten Helden (`PlannedHeroes`) mit Command-Menü für Ultimates sind im Layout vorzusehen.

27
docs/port/U4-NETWORK.md Normal file
View file

@ -0,0 +1,27 @@
# U4 · Netzwerk
Stand: 30. September 2026 · **nicht begonnen**. Das Netzwerk folgt erst nach funktionierendem lokalen Spiel.
Es wird keine neue Architektur erfunden. Übertragen wird der M4-Umfang (`lune_session.gd`, Protokoll 4), serverautoritativ:
- Clients senden Absichten.
- Der Host berechnet Gold, Käufe, Kampf, Schaden, Besitz, Boss und Sieg.
## Erfasster M4-Umfang
| Funktion | Verhalten in der Referenz |
| --- | --- |
| Rollen | offline (lokaler Host mit Bot-Plätzen), Host (ENet-Server, bis 5 Gäste), Gast |
| Sechs Plätze | ein Peer genau ein Platz; freie Plätze spielen Bots; Lobbyeinträge mit Name, Bereit, Bot |
| Beitritt | Hallo mit Protokollversion und Name; falsche Version wird abgelehnt; Namen gekürzt (24 Zeichen); volle Partie abgelehnt |
| Private Planung | ein Gast sendet Befehle für seinen Platz; fremde Plätze werden abgelehnt (Spoof-Schutz); der Zustand enthält nur den eigenen Entwurf |
| Bereit | nach Bereit ist die Planung verbindlich; alle bereit → Kampf; Bots planen einmal je Runde |
| Kampfsynchronisation | Kampfbeginn mit Einheitendatensätzen und Gebäude-HP; alle 0,05 s neue Einheiten und ein Frame (13 Werte je Einheit, ungeordnet-zuverlässig); Gast interpoliert, verspätete Frames werden verworfen |
| Abrechnung | Host sendet den öffentlichen Zustand nach dem Kampf |
| Disconnect | Platz geht an einen Bot, der in der Planung sofort plant; niemals Kernverlust |
| Wiedereinstieg | gleicher Name, solange der Platz bot-gehalten ist → Platz zurück, volle Synchronisation |
| Übernahme | ein neuer Mensch verwirft den unbestätigten Bot-Entwurf und muss selbst bereit melden |
| Revanche | Host startet neu, Plätze bleiben |
| Hostverlust | Gast kehrt mit Meldung ins Menü zurück |
| Tests | `test_lune_net` 43 Prüfungen, `net_rounds.py` Loopback mit zwei Prozessen 12 Prüfungen |
Mindestumfang des Ports: Ready, private Planung, Bot-Übernahme, Wiedereinstieg, Snapshots, Revanche, Disconnect, sechs Plätze.

22
docs/port/U5-VISUAL.md Normal file
View file

@ -0,0 +1,22 @@
# U5 · Visuelle Neuausrichtung
Stand: 30. September 2026 · **nicht begonnen**. U5 ist die eigentliche Begründung für den Unity-Wechsel.
## Vertical Slice
Eine repräsentative Mid-Lane:
- beide Teamrichtungen, zwei Türme, mindestens ein Ancient sichtbar,
- Fluss/Wasser, Vegetation, Felsen,
- mehrere normale Einheiten, ein Held, ein Angriffschampion,
- die Bossgrotte in sichtbarer Nähe, Kampf-VFX.
Nur diese Zone wird auf Zielqualität gebracht:
- Terrain, Wege, PBR-Materialien, Vegetation,
- Beleuchtung, Schatten, Himmel, Nebel, Wasser,
- Decals, Partikel, VFX, Color Grading.
## Nachweis
- Vorher/Nachher: Godot G3 gegenüber Unity U5 mit möglichst vergleichbarer Kamera.
- Leistung gemessen.
- Erst danach wird über das Qualitätsniveau der ganzen Karte entschieden.

View file

@ -0,0 +1,11 @@
{
"suites": [
{"suite": "content", "checks": 75, "failures": 0, "seconds": 0.02, "failed": [], "notes": ["map geometry: largest deviation lanes 0.00E+000 m, buildings 0.00E+000 m"]},
{"suite": "core", "checks": 91, "failures": 0, "seconds": 1.62, "failed": [], "notes": ["full match seed 2 -> Rot after 9 rounds"]},
{"suite": "m2", "checks": 73, "failures": 0, "seconds": 0.38, "failed": [], "notes": []},
{"suite": "golden", "checks": 157, "failures": 0, "seconds": 1.49, "failed": [], "notes": ["golden values compared: 51221", "largest deviation angle: 0.00E+000 over 1140 values (tolerance 1E-004)", "largest deviation effect: 0.00E+000 over 264 values (tolerance 1E-006)", "largest deviation hp: 0.00E+000 over 5679 values (tolerance 1E-002)", "largest deviation other: 0.00E+000 over 34 values (tolerance 1E-006)", "largest deviation position: 0.00E+000 over 3420 values (tolerance 1E-003)", "largest deviation time: 0.00E+000 over 780 values (tolerance 1E-006)"]},
{"suite": "determinism", "checks": 6, "failures": 0, "seconds": 1.80, "failed": [], "notes": []}
],
"runtime": ".NET 8.0.27",
"os": "Darwin 27.0.0 Darwin Kernel Version 27.0.0: Tue Aug 11 21:02:59 PDT 2026; root:xnu-13432.1.9~1/RELEASE_ARM64_T8142 Arm64"
}

View file

@ -0,0 +1,10 @@
{
"runtime": ".NET 8.0.27",
"cpu": "Arm64",
"mobile_units_at_start": 120,
"sim_step_ms_median": 0.2590,
"step_blocks_ms": [0.2376, 0.2429, 0.2590, 0.2607, 0.2692],
"bot_matches_seeds_1_2_3_ms": 1165.8,
"bot_match_rounds": 32,
"ms_per_round": 36.43
}

View file

@ -0,0 +1,15 @@
{
"bot_match_rounds": 32,
"bot_matches_seeds_1_2_3_ms": 20736.888,
"engine": "4.7.2-stable (official)",
"mobile_units_at_start": 120,
"ms_per_round": 648.02775,
"sim_step_ms_median": 1.586515,
"step_blocks_ms": [
1.580295,
1.58385,
1.586515,
1.590605,
1.595055
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1,7 @@
<Project>
<!-- Build output of the .NET harness stays in Temp/ (ignored by git and by Unity). -->
<PropertyGroup>
<BaseIntermediateOutputPath>$(MSBuildThisFileDirectory)../../Temp/dotnet/obj/$(MSBuildProjectName)/</BaseIntermediateOutputPath>
<BaseOutputPath>$(MSBuildThisFileDirectory)../../Temp/dotnet/bin/$(MSBuildProjectName)/</BaseOutputPath>
</PropertyGroup>
</Project>

View file

@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using Luneraria.Core;
/// <summary>
/// Measures the C# rules: one combat step at representative maximum occupation (round 13,
/// six armies at the supply cap, the same fixture as the Godot benchmark probe) and complete
/// bot matches. Usage: dotnet run -c Release --project tools/dotnet/Luneraria.Core.Bench [out.json]
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
var probe = new LuneMatch();
probe.Setup(new MatchOptions { Seed = 5, HumanSlot = -1 });
probe.RoundNumber = 13;
for (int slot = 0; slot < 6; slot++)
{
probe.Players[slot].Draft.Gold = 100000;
LuneBot.Plan(probe, slot, "ausgewogen");
}
probe.BeginCombat();
int units = probe.Combat.MobileCount();
for (int i = 0; i < 50; i++)
probe.StepCombat();
var samples = new List<double>();
for (int block = 0; block < 5; block++)
{
var fresh = new LuneMatch();
fresh.Setup(new MatchOptions { Seed = 5, HumanSlot = -1 });
fresh.RoundNumber = 13;
for (int slot = 0; slot < 6; slot++)
{
fresh.Players[slot].Draft.Gold = 100000;
LuneBot.Plan(fresh, slot, "ausgewogen");
}
fresh.BeginCombat();
var clock = Stopwatch.StartNew();
for (int i = 0; i < 200; i++)
fresh.StepCombat();
clock.Stop();
samples.Add(clock.Elapsed.TotalMilliseconds / 200.0);
}
samples.Sort();
double stepMs = samples[samples.Count / 2];
var matchClock = Stopwatch.StartNew();
int rounds = 0;
foreach (int seed in new[] { 1, 2, 3 })
{
var match = new LuneMatch();
match.Setup(new MatchOptions { Seed = seed, HumanSlot = -1 });
while (match.Phase != Phase.Over && match.RoundNumber <= 60)
{
for (int slot = 0; slot < 6; slot++)
LuneBot.Plan(match, slot);
match.BeginCombat();
match.FinishCombat();
rounds++;
}
}
matchClock.Stop();
string json = string.Format(System.Globalization.CultureInfo.InvariantCulture,
"{{\n \"runtime\": \"{0}\",\n \"cpu\": \"{1}\",\n \"mobile_units_at_start\": {2},\n \"sim_step_ms_median\": {3:0.0000},\n \"step_blocks_ms\": [{4}],\n \"bot_matches_seeds_1_2_3_ms\": {5:0.0},\n \"bot_match_rounds\": {6},\n \"ms_per_round\": {7:0.00}\n}}\n",
System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription, System.Runtime.InteropServices.RuntimeInformation.OSArchitecture,
units, stepMs, string.Join(", ", samples.ConvertAll(s => s.ToString("0.0000", System.Globalization.CultureInfo.InvariantCulture))),
matchClock.Elapsed.TotalMilliseconds, rounds, matchClock.Elapsed.TotalMilliseconds / rounds);
Console.Write(json);
if (args.Length > 0)
File.WriteAllText(args[0], json);
return 0;
}
}

View file

@ -0,0 +1,72 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Console runner: dotnet run --project tools/dotnet/Luneraria.Core.Tests -- [suite ...]
/// Writes docs/port/logs/u1/core-tests.json and exits non-zero on any failure.
/// </summary>
public static class Program
{
public static int Main(string[] args)
{
string root = FindRoot();
string golden = Path.Combine(root, "Assets/Luneraria/Tests/Core/Golden");
if (args.Length == 4 && args[0] == "--image")
return ReplayImage.Write(golden, args[1], args[2], Path.GetFullPath(args[3]));
var wanted = new HashSet<string>(args);
var report = new StringBuilder("{\n \"suites\": [\n");
int failures = 0;
bool first = true;
foreach (CheckSuite suite in CheckSuite.All())
{
if (wanted.Count > 0 && !wanted.Contains(suite.Name))
continue;
suite.GoldenDirectory = golden;
var clock = Stopwatch.StartNew();
bool ok = suite.Run();
clock.Stop();
failures += suite.Failures.Count;
Console.WriteLine("LUNE_U1_{0} {1}: {2} checks; {3} failures ({4:0.0} s)", ok ? "PASS" : "FAIL", suite.Name, suite.Checks, suite.Failures.Count, clock.Elapsed.TotalSeconds);
foreach (string failure in suite.Failures)
Console.WriteLine(" FAIL " + failure);
foreach (string note in suite.Notes)
Console.WriteLine(" note " + note);
report.Append(first ? "" : ",\n");
first = false;
report.AppendFormat(" {{\"suite\": \"{0}\", \"checks\": {1}, \"failures\": {2}, \"seconds\": {3:0.00}, \"failed\": [{4}], \"notes\": [{5}]}}",
suite.Name, suite.Checks, suite.Failures.Count, clock.Elapsed.TotalSeconds, Join(suite.Failures), Join(suite.Notes));
}
report.Append("\n ],\n \"runtime\": \"" + System.Runtime.InteropServices.RuntimeInformation.FrameworkDescription + "\",\n");
report.Append(" \"os\": \"" + System.Runtime.InteropServices.RuntimeInformation.OSDescription + " " + System.Runtime.InteropServices.RuntimeInformation.ProcessArchitecture + "\"\n}\n");
if (wanted.Count == 0)
{
string logs = Path.Combine(root, "docs/port/logs/u1");
Directory.CreateDirectory(logs);
File.WriteAllText(Path.Combine(logs, "core-tests.json"), report.ToString());
}
Console.WriteLine(failures == 0 ? "LUNE_U1_TESTS_OK" : "LUNE_U1_TESTS_FAILED");
return failures == 0 ? 0 : 1;
}
static string Join(List<string> values)
{
var parts = new List<string>();
foreach (string value in values)
parts.Add("\"" + value.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n") + "\"");
return string.Join(", ", parts);
}
static string FindRoot()
{
string dir = AppContext.BaseDirectory;
while (dir != null && !Directory.Exists(Path.Combine(dir, "Assets/Luneraria")))
dir = Path.GetDirectoryName(dir);
return dir ?? Directory.GetCurrentDirectory();
}
}
}

View file

@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Compression;
using System.Linq;
namespace Luneraria.Core.Tests
{
/// <summary>
/// Visual evidence for U1: the map with lanes and buildings, the Godot reference positions of
/// every unit of a golden snapshot as filled dots (Blau blue, Rot red, grotto violet) and the
/// C# positions of the same snapshot as white rings. Identical positions show as ringed dots.
/// Usage: Luneraria.Core.Tests.dll --image &lt;scenario&gt; &lt;label&gt; &lt;out.png&gt;
/// </summary>
public static class ReplayImage
{
const int Size = 1100;
public static int Write(string golden, string scenarioId, string label, string outPath)
{
var spec = MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(golden, "scenarios.json"))));
var scenario = MiniJson.Arr(spec["scenarios"]).Select(MiniJson.Obj).First(s => MiniJson.Str(s["id"]) == scenarioId);
var replay = new GoldenReplay();
foreach (object op in MiniJson.Arr(scenario["ops"]))
replay.Apply(MiniJson.Obj(op));
var reference = MiniJson.Obj(MiniJson.Obj(MiniJson.Obj(MiniJson.Parse(File.ReadAllText(Path.Combine(golden, "golden.json"))))["scenarios"])[scenarioId]);
var expected = MiniJson.Arr(reference["snapshots"]).Select(MiniJson.Obj).First(s => MiniJson.Str(s["label"]) == label);
var actual = replay.Snapshots.Select(o => (OrderedMap<string, object>)o).First(s => MiniJson.Str(s["label"]) == label);
var pixels = new byte[Size * Size * 3];
Fill(pixels, 12, 16, 20);
LuneMap map = replay.State.Map;
foreach (LanePath path in map.LanePaths.Values)
{
for (int i = 1; i < path.Points.Length; i++)
Line(pixels, path.Points[i - 1], path.Points[i], 110, 100, 80);
}
foreach (BuildingDef building in map.Buildings)
Square(pixels, building.Position, building.Tier == "core" ? 9 : 6, building.Team == 0 ? (byte)70 : (byte)200, 90, building.Team == 0 ? (byte)200 : (byte)70);
int same = 0;
int total = 0;
List<object> expectedUnits = MiniJson.Arr(expected["units"]);
List<object> actualUnits = MiniJson.Arr(actual["units"]);
for (int i = 0; i < expectedUnits.Count; i++)
{
var unit = MiniJson.Obj(expectedUnits[i]);
if (!(bool)unit["alive"] || MiniJson.Str(unit["kind"]) == "tower" || MiniJson.Str(unit["kind"]) == "core")
continue;
int team = MiniJson.Int(unit["team"]);
var at = new Vec2(MiniJson.Num(unit["x"]), MiniJson.Num(unit["y"]));
Disc(pixels, at, 4, team == 0 ? (byte)90 : team == 1 ? (byte)255 : (byte)180, team == 0 ? (byte)170 : team == 1 ? (byte)110 : (byte)120, team == 0 ? (byte)255 : team == 1 ? (byte)90 : (byte)255);
var mine = MiniJson.Obj(actualUnits[i]);
var mineAt = new Vec2(MiniJson.Num(mine["x"]), MiniJson.Num(mine["y"]));
Ring(pixels, mineAt, 6, 255, 255, 255);
total++;
if (mineAt.DistanceTo(at) <= 0.001f)
same++;
}
WritePng(outPath, pixels);
Console.WriteLine("REPLAY_IMAGE {0}/{1}: {2} of {3} living units at the reference position -> {4}", scenarioId, label, same, total, outPath);
return same == total ? 0 : 1;
}
static int Px(float world) { return (int)Math.Round((world + 40f) / 80f * (Size - 1)); }
static void Set(byte[] pixels, int x, int y, byte r, byte g, byte b)
{
if (x < 0 || y < 0 || x >= Size || y >= Size)
return;
int at = (y * Size + x) * 3;
pixels[at] = r;
pixels[at + 1] = g;
pixels[at + 2] = b;
}
static void Fill(byte[] pixels, byte r, byte g, byte b)
{
for (int i = 0; i < pixels.Length; i += 3)
{
pixels[i] = r;
pixels[i + 1] = g;
pixels[i + 2] = b;
}
}
static void Line(byte[] pixels, Vec2 a, Vec2 b, byte r, byte g, byte bl)
{
int steps = Math.Max(1, (int)(a.DistanceTo(b) / 80f * Size * 2f));
for (int i = 0; i <= steps; i++)
{
Vec2 p = a.Lerp(b, (double)i / steps);
for (int dx = -2; dx <= 2; dx++)
for (int dy = -2; dy <= 2; dy++)
Set(pixels, Px(p.X) + dx, Px(p.Y) + dy, r, g, bl);
}
}
static void Square(byte[] pixels, Vec2 at, int half, byte r, byte g, byte b)
{
for (int dx = -half; dx <= half; dx++)
for (int dy = -half; dy <= half; dy++)
Set(pixels, Px(at.X) + dx, Px(at.Y) + dy, r, g, b);
}
static void Disc(byte[] pixels, Vec2 at, int radius, byte r, byte g, byte b)
{
for (int dx = -radius; dx <= radius; dx++)
for (int dy = -radius; dy <= radius; dy++)
if (dx * dx + dy * dy <= radius * radius)
Set(pixels, Px(at.X) + dx, Px(at.Y) + dy, r, g, b);
}
static void Ring(byte[] pixels, Vec2 at, int radius, byte r, byte g, byte b)
{
for (int i = 0; i < 48; i++)
{
double angle = Math.PI * 2.0 * i / 48.0;
Set(pixels, Px(at.X) + (int)Math.Round(Math.Cos(angle) * radius), Px(at.Y) + (int)Math.Round(Math.Sin(angle) * radius), r, g, b);
}
}
// ---------------------------------------------------------------- minimal PNG writer (RGB, no dependencies)
static void WritePng(string path, byte[] rgb)
{
Directory.CreateDirectory(Path.GetDirectoryName(path));
var raw = new MemoryStream();
for (int y = 0; y < Size; y++)
{
raw.WriteByte(0);
raw.Write(rgb, y * Size * 3, Size * 3);
}
byte[] data = raw.ToArray();
var zlib = new MemoryStream();
zlib.WriteByte(0x78);
zlib.WriteByte(0x9C);
using (var deflate = new DeflateStream(zlib, CompressionLevel.Optimal, true))
deflate.Write(data, 0, data.Length);
uint adler = Adler32(data);
zlib.WriteByte((byte)(adler >> 24));
zlib.WriteByte((byte)(adler >> 16));
zlib.WriteByte((byte)(adler >> 8));
zlib.WriteByte((byte)adler);
using (FileStream file = File.Create(path))
{
file.Write(new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }, 0, 8);
var header = new List<byte>();
header.AddRange(BigEndian(Size));
header.AddRange(BigEndian(Size));
header.AddRange(new byte[] { 8, 2, 0, 0, 0 });
Chunk(file, "IHDR", header.ToArray());
Chunk(file, "IDAT", zlib.ToArray());
Chunk(file, "IEND", new byte[0]);
}
}
static byte[] BigEndian(int value) { return new[] { (byte)(value >> 24), (byte)(value >> 16), (byte)(value >> 8), (byte)value }; }
static void Chunk(Stream stream, string type, byte[] data)
{
byte[] kind = System.Text.Encoding.ASCII.GetBytes(type);
stream.Write(BigEndian(data.Length), 0, 4);
stream.Write(kind, 0, 4);
stream.Write(data, 0, data.Length);
byte[] both = kind.Concat(data).ToArray();
stream.Write(BigEndian((int)Crc32(both)), 0, 4);
}
static uint Crc32(byte[] data)
{
uint crc = 0xFFFFFFFF;
foreach (byte value in data)
{
crc ^= value;
for (int k = 0; k < 8; k++)
crc = (crc & 1) != 0 ? 0xEDB88320 ^ (crc >> 1) : crc >> 1;
}
return ~crc;
}
static uint Adler32(byte[] data)
{
uint a = 1;
uint b = 0;
foreach (byte value in data)
{
a = (a + value) % 65521;
b = (b + a) % 65521;
}
return (b << 16) | a;
}
}
}

View file

@ -0,0 +1,63 @@
extends Node
## Exports every rule table of the Godot reference as JSON (insertion order kept, doubles at
## full precision): DataCatalog units, enemies and sends, LuneContent, LuneBot profiles, the
## default LuneBalance and the lane geometry. tools/port/gen_content.py turns it into
## Assets/Luneraria/Core/Content/ContentData.cs once; the C# file is maintained from then on.
## Run: sh tools/godot-golden/run_godot.sh content_export --out=/tmp/lune_content.json
func _ready() -> void:
var out: String = "/tmp/lune_content.json"
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--out="):
out = arg.substr(6)
var balance := LuneBalance.default_balance()
var balance_values: Dictionary = {}
for property: Dictionary in balance.get_property_list():
if property.usage & PROPERTY_USAGE_SCRIPT_VARIABLE:
var value: Variant = balance.get(property.name)
balance_values[property.name] = Array(value) if value is PackedStringArray else value
var map := LuneMap.new(balance)
var lanes: Dictionary = {}
for lane: String in LuneMap.LANES:
var path: LuneMap.LanePath = map.lanes[lane]
var points: Array = []
for p: Vector2 in path.points:
points.append([p.x, p.y])
lanes[lane] = {"length": path.length, "points": points, "cumulative": Array(path.cumulative)}
var buildings: Array = []
for definition: Dictionary in map.buildings:
var copy: Dictionary = definition.duplicate()
copy.position = [definition.position.x, definition.position.y]
buildings.append(copy)
var data: Dictionary = {
"engine": Engine.get_version_info().string,
"units": _plain(DataCatalog.UNITS), "enemies": _plain(DataCatalog.ENEMIES), "sends": _plain(DataCatalog.SENDS),
"faction_names": DataCatalog.FACTION_NAMES, "armor_names": DataCatalog.ARMOR_NAMES, "damage_names": DataCatalog.DAMAGE_NAMES,
"heroes": LuneContent.HEROES, "level_xp": LuneContent.LEVEL_XP, "talent_levels": LuneContent.TALENT_LEVELS,
"talents": LuneContent.TALENTS, "items": LuneContent.ITEMS, "champions": LuneContent.CHAMPIONS,
"content_constants": {"HP_PER_LEVEL": LuneContent.HP_PER_LEVEL, "DAMAGE_PER_LEVEL": LuneContent.DAMAGE_PER_LEVEL,
"XP_PARTICIPATION": LuneContent.XP_PARTICIPATION, "XP_PER_ROUND": LuneContent.XP_PER_ROUND, "XP_RADIUS": LuneContent.XP_RADIUS,
"ITEM_SLOTS": LuneContent.ITEM_SLOTS, "ITEM_REFUND": LuneContent.ITEM_REFUND, "CHAMPION_HP": LuneContent.CHAMPION_HP,
"CHAMPION_DAMAGE": LuneContent.CHAMPION_DAMAGE, "CHAMPION_BOUNTY": LuneContent.CHAMPION_BOUNTY,
"CHAMPION_BOUNTY_PER_ROUND": LuneContent.CHAMPION_BOUNTY_PER_ROUND, "MAX_BOOKINGS": LuneContent.MAX_BOOKINGS,
"BOSS_MODEL": LuneContent.BOSS_MODEL, "BOSS_GROTTO_HP": LuneContent.BOSS_GROTTO_HP, "BOSS_GROTTO_DAMAGE": LuneContent.BOSS_GROTTO_DAMAGE,
"BOSS_DEPLOY_HP": LuneContent.BOSS_DEPLOY_HP, "BOSS_DEPLOY_DAMAGE": LuneContent.BOSS_DEPLOY_DAMAGE, "BOSS_BOUNTY": LuneContent.BOSS_BOUNTY,
"BOSS_BOUNTY_PER_ROUND": LuneContent.BOSS_BOUNTY_PER_ROUND, "BOSS_LOCK_ROUNDS": LuneContent.BOSS_LOCK_ROUNDS,
"BOSS_XP": LuneContent.BOSS_XP, "BUILDING_XP": LuneContent.BUILDING_XP},
"match_roles": LuneMatch.ROLES, "hero_roles": LuneMatch.HERO_ROLES, "row_order": LuneMatch.ROW_ORDER, "bot_names": LuneMatch.BOT_NAMES,
"bot_profiles": LuneBot.PROFILES, "bot_constants": {"BOSS_BAR": LuneBot.BOSS_BAR, "BOSS_BAR_STEP": LuneBot.BOSS_BAR_STEP},
"waves_melee": LuneWaves.MELEE, "balance": balance_values, "lanes": lanes, "buildings": buildings}
var file := FileAccess.open(out, FileAccess.WRITE)
file.store_string(JSON.stringify(data, "\t", false, true))
file.close()
print("CONTENT_EXPORT ", out)
get_tree().quit()
## Colours are presentation data; everything else is copied as is.
func _plain(table: Dictionary) -> Dictionary:
var result: Dictionary = {}
for key: String in table:
var entry: Dictionary = (table[key] as Dictionary).duplicate(true)
entry.erase("color")
result[key] = entry
return result

View file

@ -0,0 +1,29 @@
extends SceneTree
## Float calibration: how does this Godot binary compute Vector2 math (float32, FMA or not)?
## Writes inputs and results as raw float32 bits so the C# port can match them exactly.
## Run: godot --headless --path ../saumwacht-godot --script <this file> -- <out.json>
func _init() -> void:
var out: String = OS.get_cmdline_user_args()[0] if not OS.get_cmdline_user_args().is_empty() else "/tmp/float_probe.json"
var state: int = 12345
var rows: Array = []
for i: int in range(4000):
var values: Array[float] = []
for k: int in range(5):
state = (state * 1103515245 + 12345) % 2147483648
values.append((float(state) / 2147483648.0 - 0.5) * 80.0)
var a := Vector2(values[0], values[1])
var b := Vector2(values[2], values[3])
var w: float = absf(values[4]) / 40.0
var s: float = values[4]
var f := PackedFloat32Array([a.x, a.y, b.x, b.y, w, s,
a.length(), a.length_squared(), a.distance_to(b), a.distance_squared_to(b), a.dot(b),
a.normalized().x, a.normalized().y, a.lerp(b, w).x, a.lerp(b, w).y,
a.limit_length(0.9).x, a.limit_length(0.9).y, (a * s).x, (a / s).y, (a - b).normalized().x])
var d := PackedFloat64Array([atan2(a.x, a.y), clampf(s, -3.0, 3.0), float(a.x) * s + float(b.y)])
rows.append([f.to_byte_array().hex_encode(), d.to_byte_array().hex_encode()])
var file := FileAccess.open(out, FileAccess.WRITE)
file.store_string(JSON.stringify({"engine": Engine.get_version_info().string, "rows": rows}))
file.close()
print("FLOAT_PROBE ", rows.size(), " -> ", out)
quit()

View file

@ -0,0 +1,210 @@
extends Node
## Golden references for the C# port: interprets Assets/Luneraria/Tests/Core/Golden/scenarios.json
## with the Godot rules (luneraria/g3, 1b540f2) and writes the domain state after every
## "snapshot" operation plus the result of every command (Golden/golden.json). The C# core
## (GoldenSuite) runs the same operations and compares its own snapshots field by field.
## Run: sh tools/godot-golden/run_godot.sh golden_scenarios --in=<scenarios.json> --out=<golden.json> [--only=id,...]
var state: LuneMatch
var snapshots: Array = []
var results: Array = []
func _ready() -> void:
var source: String = ""
var out: String = "/tmp/lune_golden.json"
var only: PackedStringArray = []
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--in="):
source = arg.substr(5)
elif arg.begins_with("--out="):
out = arg.substr(6)
elif arg.begins_with("--only="):
only = arg.substr(7).split(",")
var spec: Dictionary = JSON.parse_string(FileAccess.get_file_as_string(source))
var scenarios: Dictionary = {}
for scenario: Dictionary in spec.scenarios:
if not only.is_empty() and not only.has(scenario.id):
continue
var started: int = Time.get_ticks_msec()
snapshots = []
results = []
for operation: Dictionary in scenario.ops:
_apply(operation)
scenarios[scenario.id] = {"title": scenario.title, "snapshots": snapshots, "results": results}
print("GOLDEN %s: %d snapshots, %d results, %d ms" % [scenario.id, snapshots.size(), results.size(), Time.get_ticks_msec() - started])
var file := FileAccess.open(out, FileAccess.WRITE)
file.store_string(JSON.stringify({"engine": Engine.get_version_info().string, "reference": "luneraria/g3 1b540f2", "scenarios": scenarios}, "", false, true))
file.close()
print("GOLDEN_DONE ", out)
get_tree().quit()
# ---------------------------------------------------------------- operations
func _apply(op: Dictionary) -> void:
match str(op.op):
"setup":
state = LuneMatch.new()
var options: Dictionary = {"seed": int(op.seed), "human_slot": int(op.get("human", -1))}
if op.has("bunds"):
options.bunds = op.bunds
if op.has("profiles"):
options.profiles = op.profiles
state.setup(options)
"building":
state.building_hp[str(op.id)] = float(op.hp)
"buildings_full":
for definition: Dictionary in state.map.buildings:
state.building_hp[definition.id] = float(definition.max_hp)
"round":
state.round_number = int(op.value)
"gold":
state.players[int(op.slot)].draft.gold = int(state.players[int(op.slot)].draft.gold) + int(op.add)
"gold_all":
for player: Dictionary in state.players:
player.draft.gold = int(player.draft.gold) + int(op.add)
"cmd":
var command: Dictionary = _resolve(int(op.slot), op.command)
var result: Dictionary = state.submit(int(op.slot), command)
results.append(["cmd", int(op.slot), str(command.type), bool(result.ok), str(result.message)])
"bots":
for slot: int in range(6):
LuneBot.plan(state, slot, str(op.get("profile", "")))
"begin":
results.append(["begin", -1, "", state.begin_combat(), ""])
"steps":
for i: int in range(int(op.n)):
if state.phase != LuneMatch.Phase.COMBAT:
break
state.step_combat()
"finish":
state.finish_combat()
"unit_set":
var select: Dictionary = op.select
for unit: LuneUnit in state.combat.units:
if select.has("kind") and unit.kind != str(select.kind):
continue
if select.has("zone") and unit.zone != str(select.zone):
continue
if select.has("team") and unit.team != int(select.team):
continue
if op.has("hp"):
unit.hp = float(op.hp)
"settle_boss":
results.append(["settle_boss", -1, "", true, state._settle_boss()])
"expedition":
var slot: int = int(op.slot)
state.players[slot].draft.gold = int(state.players[slot].draft.gold) + 6000
var ids: Array[String] = state.base_ids(state.players[slot].bund)
for type_index: Variant in op.types:
_record(slot, {"type": "buy", "type_id": ids[int(type_index)]})
var entry: Dictionary = state.players[slot].draft.roster[-1]
_record(slot, {"type": "upgrade", "uid": entry.uid})
_record(slot, {"type": "assign", "uid": entry.uid, "lane": "grotto"})
if op.has("hero"):
_record(slot, {"type": "hero_buy", "hero_id": str(op.hero)})
_record(slot, {"type": "assign", "uid": state.players[slot].draft.hero.uid, "lane": "grotto"})
"play":
for i: int in range(int(op.rounds)):
if state.phase == LuneMatch.Phase.OVER:
break
if bool(op.get("bots", true)):
for slot: int in range(6):
LuneBot.plan(state, slot, str(op.get("profile", "")))
var fought: int = state.round_number
state.begin_combat()
state.finish_combat()
_snapshot({"label": "round-%d" % fought})
"snapshot":
_snapshot(op)
_:
push_error("unknown op " + str(op.op))
func _record(slot: int, command: Dictionary) -> void:
var result: Dictionary = state.submit(slot, command)
results.append(["cmd", slot, str(command.type), bool(result.ok), str(result.message)])
## Uid references: $last:<slot>, $roster:<slot>:<index>, $hero:<slot>, $booking:<slot>:<index>.
func _resolve(slot: int, command: Dictionary) -> Dictionary:
var copy: Dictionary = command.duplicate(true)
if copy.has("uid") and str(copy.uid).begins_with("$"):
var parts: PackedStringArray = str(copy.uid).substr(1).split(":")
var owner: Dictionary = state.players[int(parts[1])]
match parts[0]:
"last": copy.uid = str(owner.draft.roster[-1].uid)
"roster": copy.uid = str(owner.draft.roster[int(parts[2])].uid)
"hero": copy.uid = str(owner.draft.hero.uid)
"booking": copy.uid = str(owner.draft.new_bookings[int(parts[2])].uid)
if copy.has("tier"):
copy.tier = int(copy.tier)
if copy.has("choice"):
copy.choice = int(copy.choice)
return copy
# ---------------------------------------------------------------- snapshot
func _snapshot(op: Dictionary) -> void:
var players: Array = []
for player: Dictionary in state.players:
players.append({"gold": int(player.gold), "draft_gold": int(player.draft.gold), "ready": bool(player.ready), "earned": int(player.earned),
"spent": int(player.spent), "next_seq": int(player.next_seq), "roster": _roster(player.roster, false), "hero": _hero(player.hero),
"bookings": player.bookings.map(func(b: Dictionary) -> String: return "%s|%s|%s|%d|%d" % [b.uid, b.champion_id, b.lane, int(b.cost), int(b.get("deploy_round", 0))]),
"new_bookings": (player.draft.new_bookings as Array).map(func(b: Dictionary) -> String: return "%s|%s|%s|%d" % [b.uid, b.champion_id, b.lane, int(b.cost)]),
"draft_roster": _roster(player.draft.roster, true), "draft_hero": _hero(player.draft.hero), "boss_lane": str(player.draft.boss_lane),
"undo": (player.undo as Array).size()})
var claim: Dictionary = state.boss.get("claim", {})
var snap: Dictionary = {"label": str(op.label), "phase": state.phase, "round": state.round_number, "winner": state.winner, "end_note": state.end_note,
"players": players, "buildings": state.map.buildings.map(func(d: Dictionary) -> float: return float(state.building_hp[d.id])),
"boss": {"next": int(state.boss.next_available_round), "settled": int(state.boss.get("settled_round", 0)),
"claim": "" if claim.is_empty() else "%d|%d|%d|%d|%s" % [int(claim.team), int(claim.won_round), int(claim.deploy_round), int(claim.chooser_slot), claim.lane]},
"boss_log": state.boss_log.map(func(e: Dictionary) -> String: return "%d|%s|%d|%s|%d" % [int(e.round), e.event, int(e.get("team", -1)), str(e.get("lane", "")), int(e.get("chooser", -1))]),
"announcements": state.announcements().map(func(a: Dictionary) -> String: return "%s|%d|%d|%s|%s|%d" % [a.kind, int(a.team), int(a.slot), a.lane, str(a.get("champion_id", "")), int(a.get("bounty", 0))]),
"settlement": _settlement(), "combat": _combat()}
if bool(op.get("units", false)) and state.combat != null:
snap.units = state.combat.units.map(_unit)
if bool(op.get("events", false)) and state.combat != null:
snap.events = state.combat.events.map(func(e: Dictionary) -> Array: return [int(e.serial), int(e.step), float(e.time), int(e.team), str(e.lane), str(e.zone), str(e.kind),
int(e.unit_id), str(e.def_id), int(e.bounty), str(e.get("building_id", "")), int(e.xp), e.xp_heroes])
if bool(op.get("wave", false)):
snap.wave = LuneWaves.build(state.balance, state.round_number).map(func(m: Dictionary) -> Array: return [str(m.id), float(m.max_hp), float(m.damage), int(m.bounty)])
snapshots.append(snap)
func _roster(roster: Array, with_fresh: bool) -> Array:
return roster.map(func(e: Dictionary) -> String: return "%s|%s|%s|%d%s" % [e.uid, e.type_id, e.lane, int(e.invested), ("|%s" % str(bool(e.get("fresh", false))).to_lower()) if with_fresh else ""])
func _hero(hero: Dictionary) -> String:
if hero.is_empty():
return ""
return "%s|%s|%d|%s|%s|%s|%d|%s|%s" % [hero.uid, hero.hero_id, int(hero.xp), hero.lane, "+".join(hero.items), "+".join(hero.talents), int(hero.invested),
str(bool(hero.get("fresh", false))).to_lower(), "+".join(hero.get("fresh_items", []))]
func _settlement() -> Variant:
var s: Dictionary = state.last_settlement
if s.is_empty():
return null
return {"round": int(s.round), "gains": (s.gains as Array).map(func(g: int) -> int: return g), "income": int(s.income), "lost": s.buildings_lost,
"end_reason": str(s.end_reason), "combat_time": float(s.combat_time), "boss": str(s.boss), "lane_kills": s.lane_kills, "xp": s.xp}
func _combat() -> Variant:
var c: LuneCombat = state.combat
if c == null:
return null
var boss: String = ""
if c.grotto_boss != null:
boss = "%s|%s|%s" % [str(c.grotto_boss.alive).to_lower(), str(c.grotto_boss.dormant).to_lower(), str(c.grotto_boss.id)]
return {"round": c.round_number, "steps": c.steps, "elapsed": c.elapsed, "finished": c.finished, "end_reason": c.end_reason,
"destroyed": c.destroyed_cores.duplicate(), "grotto": "%s|%d|%d|%s" % [c.grotto_outcome, c.grotto_claim_team, c.grotto_active_team, str(c.grotto_resolved).to_lower()],
"grotto_boss": boss, "grotto_boss_hp": c.grotto_boss.hp if c.grotto_boss != null else 0.0, "events": c.events.size(),
"mobile": [c.mobile_count(0), c.mobile_count(1), c.mobile_count(2)]}
func _unit(unit: LuneUnit) -> Dictionary:
var effects: Array = []
for family: String in unit.effects:
var effect: Dictionary = unit.effects[family]
effects.append([family, str(effect.kind), float(effect.amount), float(effect.remaining)])
var dots: Array = []
for family: String in unit.dots:
var dot: Dictionary = unit.dots[family]
dots.append([family, float(dot.damage), float(dot.remaining), float(dot.next_tick), int(dot.attack_type)])
return {"id": unit.id, "kind": unit.kind, "team": unit.team, "lane": unit.lane, "zone": unit.zone, "slot": unit.owner_slot, "uid": unit.persistent_uid,
"def": str(unit.def.get("id", "")), "alive": unit.alive, "hp": unit.hp, "max_hp": unit.max_hp, "shield": unit.shield,
"x": unit.position.x, "y": unit.position.y, "facing": unit.facing, "progress": unit.progress,
"target": unit.target.id if unit.target != null else -1, "hits": unit.hit_count, "reached": unit.reached_base, "dormant": unit.dormant,
"effects": effects, "dots": dots}

27
tools/godot-golden/run_godot.sh Executable file
View file

@ -0,0 +1,27 @@
#!/bin/sh
# Runs a tool of tools/godot-golden as a scene inside the Godot reference project, so the
# project's autoloads (DataCatalog, ...) exist; `godot --script` would not load them.
# The tool is copied to <godot project>/.port_tmp/ for the run and removed afterwards; the
# reference repository stays unchanged.
# Usage: sh tools/godot-golden/run_godot.sh <tool name without .gd> [tool arguments ...]
set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
GODOT_PROJECT="${GODOT_PROJECT:-$HERE/../../../saumwacht-godot}"
GODOT="${GODOT:-godot}"
NAME="$1"
shift
TMP="$GODOT_PROJECT/.port_tmp"
mkdir -p "$TMP"
cp "$HERE/$NAME.gd" "$TMP/$NAME.gd"
cat > "$TMP/$NAME.tscn" <<SCENE
[gd_scene format=3]
[ext_resource type="Script" path="res://.port_tmp/$NAME.gd" id="1"]
[node name="PortTool" type="Node"]
script = ExtResource("1")
SCENE
"$GODOT" --headless --path "$GODOT_PROJECT" "res://.port_tmp/$NAME.tscn" -- "$@"
CODE=$?
rm -rf "$TMP"
exit $CODE

View file

@ -0,0 +1,45 @@
extends Node
## Godot counterpart of tools/dotnet/Luneraria.Core.Bench: the same fixture, measured in GDScript.
## Run: sh tools/godot-golden/run_godot.sh sim_bench --out=<file.json>
func _ready() -> void:
var out: String = ""
for arg: String in OS.get_cmdline_user_args():
if arg.begins_with("--out="):
out = arg.substr(6)
var samples: Array[float] = []
var units: int = 0
for block: int in range(5):
var fresh := LuneMatch.new()
fresh.setup({"seed": 5, "human_slot": -1})
fresh.round_number = 13
for slot: int in range(6):
fresh.players[slot].draft.gold = 100000
LuneBot.plan(fresh, slot, "ausgewogen")
fresh.begin_combat()
units = fresh.combat.mobile_count()
var started: int = Time.get_ticks_usec()
for i: int in range(200):
fresh.step_combat()
samples.append((Time.get_ticks_usec() - started) / 1000.0 / 200.0)
samples.sort()
var match_started: int = Time.get_ticks_usec()
var rounds: int = 0
for seed: int in [1, 2, 3]:
var state := LuneMatch.new()
state.setup({"seed": seed, "human_slot": -1})
while state.phase != LuneMatch.Phase.OVER and state.round_number <= 60:
for slot: int in range(6):
LuneBot.plan(state, slot)
state.begin_combat()
state.finish_combat()
rounds += 1
var match_ms: float = (Time.get_ticks_usec() - match_started) / 1000.0
var report := {"engine": Engine.get_version_info().string, "mobile_units_at_start": units, "sim_step_ms_median": samples[samples.size() / 2],
"step_blocks_ms": samples, "bot_matches_seeds_1_2_3_ms": match_ms, "bot_match_rounds": rounds, "ms_per_round": match_ms / rounds}
print("SIM_BENCH ", JSON.stringify(report))
if not out.is_empty():
var file := FileAccess.open(out, FileAccess.WRITE)
file.store_string(JSON.stringify(report, "\t"))
file.close()
get_tree().quit()

158
tools/port/gen_content.py Normal file
View file

@ -0,0 +1,158 @@
#!/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()

13
tools/run_core_tests.sh Executable file
View file

@ -0,0 +1,13 @@
#!/bin/sh
# U1 core tests outside Unity: builds the rules (netstandard2.1, C# 9 = Unity's level) and runs
# every suite with the .NET SDK. Writes docs/port/logs/u1/core-tests.json.
# Optional: REGENERATE_GOLDEN=1 re-creates the Godot references first (needs ../saumwacht-godot).
set -eu
cd "$(dirname "$0")/.."
if [ "${REGENERATE_GOLDEN:-0}" = "1" ]; then
sh tools/godot-golden/run_godot.sh content_export --out="$PWD/Assets/Luneraria/Tests/Core/Golden/content.json"
sh tools/godot-golden/run_godot.sh golden_scenarios --in="$PWD/Assets/Luneraria/Tests/Core/Golden/scenarios.json" --out="$PWD/Assets/Luneraria/Tests/Core/Golden/golden.json"
fi
dotnet build tools/dotnet/Luneraria.Core/Luneraria.Core.csproj -c Release -nologo -v q
dotnet build tools/dotnet/Luneraria.Core.Tests/Luneraria.Core.Tests.csproj -c Release -nologo -v q
dotnet Temp/dotnet/bin/Luneraria.Core.Tests/Release/net8.0/Luneraria.Core.Tests.dll "$@"