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

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

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

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

194 lines
9.1 KiB
C#

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;
}
}
}