diff --git a/Assets/Luneraria/Core/Content/LuneBalance.cs b/Assets/Luneraria/Core/Content/LuneBalance.cs
index 7cb73d6..b627340 100644
--- a/Assets/Luneraria/Core/Content/LuneBalance.cs
+++ b/Assets/Luneraria/Core/Content/LuneBalance.cs
@@ -60,8 +60,26 @@ namespace Luneraria.Core
public double OuterTowerFraction = 0.35;
public double TowerSideOffset = 3.4;
+ // S1: extended rules (MatchOptions.Extended; the reference rules ignore all of these).
+ /// Legion TD 2: a booked attack champion raises the booker's income by cost / this, for good.
+ public int ChampionIncomeDivisor = 12;
+ /// Dota 2 barracks: waves of a team on a lane whose enemy inner tower fell get stronger.
+ public double BarracksBoost = 0.25;
+ public int BarracksExtraMonsters = 1;
+ /// Dota 2 buyback, adapted: a fallen hero starts the next combat exhausted, unless bought back.
+ public double ExhaustedHp = 0.6;
+ public int BuybackBase = 60;
+ public int BuybackPerLevel = 20;
+ /// League of Legends dragon souls: each grotto victory blesses the team, up to the soul.
+ public double BlessingStep = 0.04;
+ public int BlessingMax = 4;
+ /// Moon soul (all stacks): heroes regenerate this share of their health per second.
+ public double SoulRegen = 0.01;
+
public static LuneBalance Default() { return new LuneBalance(); }
+ public int BuybackCost(int level) { return BuybackBase + BuybackPerLevel * level; }
+
public int RoundIncome(int roundNumber)
{
return BaseIncome + GdMath.Min(IncomeGrowthCap, IncomePerRound * GdMath.Max(0, roundNumber - 1));
diff --git a/Assets/Luneraria/Core/Net/LuneSession.cs b/Assets/Luneraria/Core/Net/LuneSession.cs
index 4096ce1..e21440d 100644
--- a/Assets/Luneraria/Core/Net/LuneSession.cs
+++ b/Assets/Luneraria/Core/Net/LuneSession.cs
@@ -571,6 +571,7 @@ namespace Luneraria.Core
player.Gold = row.Gold;
player.Ready = row.Ready;
player.Earned = row.Earned;
+ player.IncomeBonus = row.IncomeBonus;
player.Spent = row.Spent;
player.NextSeq = row.NextSeq;
player.Roster = new List();
@@ -621,6 +622,8 @@ namespace Luneraria.Core
MatchState.Boss.NextAvailableRound = source.NextAvailableRound;
MatchState.Boss.SettledRound = source.SettledRound;
MatchState.Boss.Claim = source.Claim != null ? source.Claim.Clone() : null;
+ if (source.Blessing != null && source.Blessing.Length == 2)
+ MatchState.Boss.Blessing = (int[])source.Blessing.Clone();
}
public void ApplyLobby(List entries)
@@ -857,7 +860,8 @@ namespace Luneraria.Core
{
NextAvailableRound = MatchState.Boss.NextAvailableRound,
SettledRound = MatchState.Boss.SettledRound,
- Claim = MatchState.Boss.Claim != null ? MatchState.Boss.Claim.Clone() : null
+ Claim = MatchState.Boss.Claim != null ? MatchState.Boss.Claim.Clone() : null,
+ Blessing = (int[])MatchState.Boss.Blessing.Clone()
};
if (MatchState.Combat != null)
state.DestroyedCores.AddRange(MatchState.Combat.DestroyedCores);
@@ -872,6 +876,7 @@ namespace Luneraria.Core
Gold = player.Gold,
Ready = player.Ready,
Earned = player.Earned,
+ IncomeBonus = player.IncomeBonus,
Spent = player.Spent,
NextSeq = player.NextSeq,
Hero = player.Hero != null ? player.Hero.Clone() : null,
@@ -930,7 +935,7 @@ namespace Luneraria.Core
MatchOptions PublicOptions()
{
- var options = new MatchOptions { Seed = MatchState != null ? MatchState.SeedValue : 1, HumanSlot = LocalSlot };
+ var options = new MatchOptions { Seed = MatchState != null ? MatchState.SeedValue : 1, HumanSlot = LocalSlot, Extended = MatchState != null && MatchState.Extended };
if (MatchState != null)
{
foreach (Player player in MatchState.Players)
diff --git a/Assets/Luneraria/Core/Net/NetModel.cs b/Assets/Luneraria/Core/Net/NetModel.cs
index 30a72c7..bf89df9 100644
--- a/Assets/Luneraria/Core/Net/NetModel.cs
+++ b/Assets/Luneraria/Core/Net/NetModel.cs
@@ -50,6 +50,8 @@ namespace Luneraria.Core
public Draft Draft;
/// Undo stack size of the receiving seat (0 for others).
public int Undo;
+ /// S1: permanent champion income (public, like the champion bookings).
+ public int IncomeBonus;
}
/// Public state of the match for one seat (rpc_state).
diff --git a/Assets/Luneraria/Core/Simulation/LuneBot.cs b/Assets/Luneraria/Core/Simulation/LuneBot.cs
index 2caa4f1..1435ae2 100644
--- a/Assets/Luneraria/Core/Simulation/LuneBot.cs
+++ b/Assets/Luneraria/Core/Simulation/LuneBot.cs
@@ -18,7 +18,10 @@ namespace Luneraria.Core
// 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 names = ContentData.BotProfiles.Keys;
+ // S1: with extended rules the rotation also holds the profiles of later heroes (Sylvara).
+ var names = new List(ContentData.BotProfiles.Keys);
+ if (match.Extended)
+ names.AddRange(ContentData.ExtraBotProfiles.Keys);
return names[(slot * 7 + match.SeedValue) % names.Count];
}
@@ -76,6 +79,9 @@ namespace Luneraria.Core
match.Submit(slot, Command.HeroBuy(profile.Hero));
return;
}
+ // S1: buy an exhausted hero back while the army keeps a reserve.
+ if (hero.Exhausted && match.Extended && player.Draft.Gold >= match.BuybackCost(hero) + 150)
+ match.Submit(slot, Command.Buyback());
int level = LuneContent.LevelFor(hero.Xp);
for (int tier = 0; tier < LuneContent.TalentLevels.Length; tier++)
{
diff --git a/Assets/Luneraria/Core/Simulation/LuneMatch.cs b/Assets/Luneraria/Core/Simulation/LuneMatch.cs
index b0ed0a5..d0f790d 100644
--- a/Assets/Luneraria/Core/Simulation/LuneMatch.cs
+++ b/Assets/Luneraria/Core/Simulation/LuneMatch.cs
@@ -21,6 +21,8 @@ namespace Luneraria.Core
public int RoundNumber = 1;
public Phase Phase = Phase.Planning;
public int Winner = Ongoing;
+ /// S1: extended rules (MatchOptions.Extended); false keeps the reference rules exactly.
+ public bool Extended;
public string EndNote = "";
public readonly List Players = new List();
/// Building HP between combats, in map order.
@@ -46,6 +48,8 @@ namespace Luneraria.Core
{
options = options ?? new MatchOptions();
SeedValue = options.Seed;
+ Extended = options.Extended;
+ Boss.Blessing = new int[2];
RoundNumber = 1;
Phase = Phase.Planning;
Winner = Ongoing;
@@ -316,6 +320,19 @@ namespace Luneraria.Core
hero.Skin = skin;
return Result(true, skin.Length == 0 ? "Standard-Aussehen gewählt." : "Skin gewählt.");
}
+ case "buyback":
+ {
+ if (!Extended)
+ return Result(false, "Rückkauf gibt es nur mit erweiterten Regeln.");
+ if (hero == null || !hero.Exhausted)
+ return Result(false, "Dein Held ist nicht erschöpft.");
+ int cost = BuybackCost(hero);
+ if (draft.Gold < cost)
+ return Result(false, string.Format("Es fehlen {0} Gold.", cost - draft.Gold));
+ draft.Gold -= cost;
+ hero.Exhausted = false;
+ return Result(true, string.Format("Held für {0} Gold zurückgekauft.", cost));
+ }
case "champion_book":
{
string championId = command.ChampionId ?? "";
@@ -433,6 +450,9 @@ namespace Luneraria.Core
Booking committed = booking.Clone();
committed.DeployRound = RoundNumber + 1;
player.Bookings.Add(committed);
+ // S1 (Legion TD 2): sending raises the own income for good.
+ if (Extended)
+ player.IncomeBonus += ChampionIncome(booking.Cost);
}
BossClaim claim = Boss.Claim;
if (claim != null && claim.ChooserSlot == player.Slot && draft.BossLane.Length > 0)
@@ -499,16 +519,74 @@ namespace Luneraria.Core
{
foreach (string lane in LuneMap.Lanes)
{
- for (int index = 0; index < wave.Count; index++)
+ List lineup = Extended && BarracksBroken(team, lane) ? Reinforced(wave) : wave;
+ for (int index = 0; index < lineup.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);
+ Combat.Spawn("monster", team, lane, lineup[index].Clone(), 11.0 - (double)row * 1.4, lateral);
}
}
}
}
+ // ---------------------------------------------------------------- S1 extended rules
+ /// Legion TD 2: income a booked champion adds for good.
+ public int ChampionIncome(int cost) { return cost / GdMath.Max(1, Balance.ChampionIncomeDivisor); }
+
+ public int BuybackCost(HeroState hero) { return Balance.BuybackCost(LuneContent.LevelFor(hero.Xp)); }
+
+ /// Dota 2 barracks: the enemy inner tower of this lane has fallen, so this team's waves there are reinforced.
+ public bool BarracksBroken(int team, string lane)
+ {
+ foreach (BuildingDef definition in Map.Buildings)
+ {
+ if (definition.Team == 1 - team && definition.Lane == lane && definition.Tier == "inner")
+ return BuildingHp[definition.Id] <= 0.0;
+ }
+ return false;
+ }
+
+ /// The wave stronger by BarracksBoost and longer by extra copies of its first monster.
+ List Reinforced(List wave)
+ {
+ var result = new List();
+ foreach (UnitDef monster in wave)
+ {
+ UnitDef strong = monster.Clone();
+ strong.MaxHp *= 1.0 + Balance.BarracksBoost;
+ strong.Damage *= 1.0 + Balance.BarracksBoost;
+ result.Add(strong);
+ }
+ for (int extra = 0; extra < Balance.BarracksExtraMonsters && result.Count > 0; extra++)
+ result.Insert(0, result[0].Clone());
+ return result;
+ }
+
+ /// League of Legends dragon souls: Mondsegen stacks raise health and damage; the soul adds hero regeneration.
+ UnitDef Blessed(UnitDef definition, int team, string kind)
+ {
+ int stacks = GdMath.Min(Boss.Blessing[team], Balance.BlessingMax);
+ if (stacks <= 0)
+ return definition;
+ double factor = 1.0 + Balance.BlessingStep * stacks;
+ definition.MaxHp *= factor;
+ definition.Damage *= factor;
+ if (stacks >= Balance.BlessingMax && kind == "hero")
+ definition.Regen += Balance.SoulRegen;
+ return definition;
+ }
+
+ /// S1: an exhausted hero starts its combat with ExhaustedHp of its health.
+ void Exhaust(LuneUnit unit)
+ {
+ if (!Extended || unit.Kind != "hero" || unit.OwnerSlot < 0)
+ return;
+ HeroState hero = Players[unit.OwnerSlot].Hero;
+ if (hero != null && hero.Exhausted && hero.Uid == unit.PersistentUid)
+ unit.Hp = unit.MaxHp * Balance.ExhaustedHp;
+ }
+
void SpawnChampions()
{
for (int team = 0; team < 2; team++)
@@ -624,7 +702,9 @@ namespace Luneraria.Core
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);
+ LuneUnit spawned = Combat.Spawn(entry.Kind, team, lane, Extended ? Blessed(entry.Definition, team, entry.Kind) : entry.Definition,
+ GdMath.Max(0.4, 7.2 - (double)row * 1.5), lateral, entry.Slot, entry.Uid);
+ Exhaust(spawned);
}
}
}
@@ -642,7 +722,8 @@ namespace Luneraria.Core
{
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);
+ LuneUnit spawned = Combat.SpawnAt(entry.Kind, team, Grotto, Extended ? Blessed(entry.Definition, team, entry.Kind) : entry.Definition, at, entry.Slot, entry.Uid);
+ Exhaust(spawned);
expedition = true;
}
}
@@ -727,10 +808,19 @@ namespace Luneraria.Core
if (player.Hero != null && xpGain.TryGetValue(player.Hero.Uid, out gained))
player.Hero.Xp += gained;
}
+ if (Extended)
+ {
+ // S1 (Dota 2 buyback, adapted): a hero that fell is exhausted for the next combat.
+ foreach (LuneUnit unit in Combat.Units)
+ {
+ if (unit.Kind == "hero" && unit.OwnerSlot >= 0 && Players[unit.OwnerSlot].Hero != null && Players[unit.OwnerSlot].Hero.Uid == unit.PersistentUid)
+ Players[unit.OwnerSlot].Hero.Exhausted = !unit.Alive;
+ }
+ }
int income = Balance.RoundIncome(RoundNumber);
foreach (Player player in Players)
{
- int total = gains[player.Slot] + (Combat.DestroyedCores.Count > 0 ? 0 : income);
+ int total = gains[player.Slot] + (Combat.DestroyedCores.Count > 0 ? 0 : income + (Extended ? player.IncomeBonus : 0));
player.Gold += total;
player.Earned += total;
// Deployed bookings are consumed; they never return as property.
@@ -801,6 +891,9 @@ namespace Luneraria.Core
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 });
+ // S1 (League of Legends dragon souls): each conquest blesses the team for good.
+ if (Extended)
+ Boss.Blessing[team] = GdMath.Min(Balance.BlessingMax, Boss.Blessing[team] + 1);
result = "won";
}
else if (Combat.GrottoOutcome == "failed")
diff --git a/Assets/Luneraria/Core/Simulation/MatchModel.cs b/Assets/Luneraria/Core/Simulation/MatchModel.cs
index 76115c9..666dffa 100644
--- a/Assets/Luneraria/Core/Simulation/MatchModel.cs
+++ b/Assets/Luneraria/Core/Simulation/MatchModel.cs
@@ -38,6 +38,8 @@ namespace Luneraria.Core
public string Ultimate = "";
/// H1: cosmetic skin ("" = standard), chosen freely in planning, visible to everyone.
public string Skin = "";
+ /// S1 (extended rules): fell in the last combat; starts the next one weakened unless bought back.
+ public bool Exhausted;
public HeroState Clone()
{
@@ -104,6 +106,8 @@ namespace Luneraria.Core
public int NextSeq;
public Draft Draft;
public List Undo = new List();
+ /// S1 (extended rules): permanent income from booked attack champions (Legion TD 2).
+ public int IncomeBonus;
}
public sealed class BossClaim
@@ -124,6 +128,8 @@ namespace Luneraria.Core
public BossClaim Claim;
/// Round whose settlement was already applied (idempotence), 0 = none.
public int SettledRound;
+ /// S1 (extended rules): Mondsegen stacks per team from grotto victories (League of Legends dragon souls).
+ public int[] Blessing = new int[2];
}
public sealed class BossLogEntry
@@ -183,6 +189,12 @@ namespace Luneraria.Core
public List Bunds = new List();
/// Optional fixed bot profile per slot (balance runs).
public List Profiles = new List();
+ ///
+ /// S1: extended rules after Legion TD 2, Dota 2 and League of Legends (champion income,
+ /// barracks, exhaustion and buyback, Mondsegen) and bots that may pick the heroes after
+ /// the reference. False = the reference rules of the port (golden replays, fingerprints).
+ ///
+ public bool Extended;
}
/// Bot-private notes per slot, derived only from its own actions and public outcomes.
@@ -240,5 +252,7 @@ namespace Luneraria.Core
public static Command UltimateRule(string rule) { return new Command { Type = "ultimate_rule", TypeId = rule }; }
/// H1: cosmetic skin of the hero ("" = standard); the skin id travels in TypeId.
public static Command HeroSkin(string skin) { return new Command { Type = "hero_skin", TypeId = skin }; }
+ /// S1: buys an exhausted hero back to full strength for the next combat.
+ public static Command Buyback() { return new Command { Type = "buyback" }; }
}
}
diff --git a/Assets/Luneraria/Net/LuneWire.cs b/Assets/Luneraria/Net/LuneWire.cs
index d9a3ea5..e5734fe 100644
--- a/Assets/Luneraria/Net/LuneWire.cs
+++ b/Assets/Luneraria/Net/LuneWire.cs
@@ -187,6 +187,7 @@ namespace Luneraria.Net
Int(text, options.HumanSlot);
Str(text, options.Bunds != null ? string.Join("\x1d", options.Bunds) : "");
Str(text, options.Profiles != null ? string.Join("\x1d", options.Profiles) : "");
+ Int(text, options.Extended ? 1 : 0);
}
static void WriteLobby(StringBuilder text, LobbyEntry entry)
@@ -223,6 +224,8 @@ namespace Luneraria.Net
Int(text, boss.Claim.ChooserSlot);
Str(text, boss.Claim.Lane ?? "");
}
+ Int(text, boss.Blessing != null && boss.Blessing.Length > 0 ? boss.Blessing[0] : 0);
+ Int(text, boss.Blessing != null && boss.Blessing.Length > 1 ? boss.Blessing[1] : 0);
var cores = state.DestroyedCores ?? new List();
Int(text, cores.Count);
foreach (int core in cores)
@@ -262,6 +265,7 @@ namespace Luneraria.Net
Int(text, row.Spent);
Int(text, row.NextSeq);
Int(text, row.Undo);
+ Int(text, row.IncomeBonus);
WriteRosterList(text, row.Roster);
Int(text, row.Hero != null ? 1 : 0);
if (row.Hero != null)
@@ -326,6 +330,7 @@ namespace Luneraria.Net
Str(text, hero.FreshItems != null ? string.Join("\x1d", hero.FreshItems) : "");
Str(text, hero.Ultimate ?? "");
Str(text, hero.Skin ?? "");
+ Int(text, hero.Exhausted ? 1 : 0);
}
static void WriteUnit(StringBuilder text, NetUnitRecord unit)
@@ -599,6 +604,7 @@ namespace Luneraria.Net
string profiles = Text();
if (!string.IsNullOrEmpty(profiles))
options.Profiles.AddRange(profiles.Split('\x1d'));
+ options.Extended = Integer() == 1;
return options;
}
@@ -650,6 +656,7 @@ namespace Luneraria.Net
Lane = Text() ?? ""
};
}
+ state.Boss.Blessing = new[] { Integer(), Integer() };
int coreCount = Integer();
for (int i = 0; i < coreCount; i++)
state.DestroyedCores.Add(Integer());
@@ -689,7 +696,8 @@ namespace Luneraria.Net
Earned = Integer(),
Spent = Integer(),
NextSeq = Integer(),
- Undo = Integer()
+ Undo = Integer(),
+ IncomeBonus = Integer()
};
row.Roster = ReadRosterList();
if (Flag())
@@ -762,6 +770,7 @@ namespace Luneraria.Net
AddSplit(hero.FreshItems, Text());
hero.Ultimate = Text() ?? "";
hero.Skin = Text() ?? "";
+ hero.Exhausted = Integer() == 1;
return hero;
}
diff --git a/Assets/Luneraria/Tests/Core/CheckSuite.cs b/Assets/Luneraria/Tests/Core/CheckSuite.cs
index 7a7856a..f425cd0 100644
--- a/Assets/Luneraria/Tests/Core/CheckSuite.cs
+++ b/Assets/Luneraria/Tests/Core/CheckSuite.cs
@@ -50,7 +50,7 @@ namespace Luneraria.Core.Tests
public static IList All()
{
- return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite(), new H1HeroSuite() };
+ return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite(), new H1HeroSuite(), new S1RulesSuite() };
}
}
}
diff --git a/Assets/Luneraria/Tests/Core/S1RulesSuite.cs b/Assets/Luneraria/Tests/Core/S1RulesSuite.cs
new file mode 100644
index 0000000..b5d226b
--- /dev/null
+++ b/Assets/Luneraria/Tests/Core/S1RulesSuite.cs
@@ -0,0 +1,184 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Luneraria.Core.Tests
+{
+ ///
+ /// S1: the extended rules after Legion TD 2, Dota 2 and League of Legends (MatchOptions.Extended).
+ /// Champion income, barracks, exhaustion and buyback, Mondsegen, the bot rotation with later
+ /// heroes, and that none of it acts under the reference rules.
+ ///
+ public sealed class S1RulesSuite : CheckSuite
+ {
+ public override string Name { get { return "s1"; } }
+
+ protected override void RunChecks()
+ {
+ TestReferenceUntouched();
+ TestChampionIncome();
+ TestBarracks();
+ TestExhaustionAndBuyback();
+ TestBlessing();
+ TestBots();
+ }
+
+ static LuneMatch NewMatch(bool extended, int seed = 41)
+ {
+ var state = new LuneMatch();
+ state.Setup(new MatchOptions { Seed = seed, HumanSlot = -1, Extended = extended,
+ Bunds = new List { "glutpakt", "wurzelbund", "glutpakt", "wurzelbund", "glutpakt", "wurzelbund" } });
+ return state;
+ }
+
+ void TestReferenceUntouched()
+ {
+ LuneMatch state = NewMatch(false);
+ Check(!state.Extended, "default options keep the reference rules");
+ state.Players[0].Draft.Gold += 1000;
+ state.Submit(0, Command.ChampionBook("ch_schutz", "top"));
+ state.Submit(0, Command.HeroBuy("held_ringborke"));
+ state.BeginCombat();
+ Check(state.Players[0].IncomeBonus == 0, "reference: a champion gives no income");
+ foreach (LuneUnit unit in state.Combat.Units.Where(u => u.Kind == "hero"))
+ unit.Hp = 0.0;
+ state.FinishCombat();
+ Check(!state.Players[0].Hero.Exhausted, "reference: a fallen hero is not exhausted");
+ Check(!state.Submit(0, Command.Buyback()).Ok, "reference: no buyback");
+ var setup = new LuneMatch();
+ setup.Setup(new MatchOptions { Seed = 3 });
+ Check(!setup.Extended, "MatchOptions.Extended defaults to false");
+ }
+
+ void TestChampionIncome()
+ {
+ LuneMatch state = NewMatch(true);
+ state.Players[0].Draft.Gold += 1000;
+ Check(state.Submit(0, Command.ChampionBook("ch_schutz", "top")).Ok, "book a champion");
+ Check(state.Players[0].IncomeBonus == 0, "income rises only when the booking is committed");
+ state.Submit(0, Command.ChampionCancel(state.Players[0].Draft.NewBookings[0].Uid));
+ state.Submit(0, Command.ChampionBook("ch_belagerung", "mid"));
+ state.BeginCombat();
+ Check(state.Players[0].IncomeBonus == 180 / 12, "Keilbrecher adds 15 income for good (" + state.Players[0].IncomeBonus + ")");
+ int before = state.Players[0].Gold;
+ int beforeOther = state.Players[1].Gold;
+ state.FinishCombat();
+ int gain = state.Players[0].Gold - before;
+ int gainOther = state.Players[1].Gold - beforeOther;
+ Check(gain - state.LastSettlement.Gains[0] == state.LastSettlement.Income + 15, "settlement pays round income plus the champion income");
+ Check(gainOther - state.LastSettlement.Gains[1] == state.LastSettlement.Income, "other seats get the plain income");
+ }
+
+ void TestBarracks()
+ {
+ LuneMatch state = NewMatch(true);
+ Check(!state.BarracksBroken(0, "mid"), "barracks intact at the start");
+ state.BuildingHp["rot_mid_inner"] = 0.0;
+ Check(state.BarracksBroken(0, "mid") && !state.BarracksBroken(1, "mid") && !state.BarracksBroken(0, "top"), "Blau's mid waves are reinforced once Rot's inner mid tower fell");
+ state.BeginCombat();
+ List blueMid = state.Combat.Units.Where(u => u.Kind == "monster" && u.Team == 0 && u.Lane == "mid").ToList();
+ List blueTop = state.Combat.Units.Where(u => u.Kind == "monster" && u.Team == 0 && u.Lane == "top").ToList();
+ List redMid = state.Combat.Units.Where(u => u.Kind == "monster" && u.Team == 1 && u.Lane == "mid").ToList();
+ Check(blueMid.Count == blueTop.Count + 1 && redMid.Count == blueTop.Count, "one extra monster on the broken lane only");
+ Check(GdMath.IsEqualApprox(blueMid.Last().MaxHp, blueTop.Last().MaxHp * 1.25), "reinforced monsters have 125 % health");
+ LuneMatch reference = NewMatch(false);
+ reference.BuildingHp["rot_mid_inner"] = 0.0;
+ reference.BeginCombat();
+ Check(reference.Combat.Units.Count(u => u.Kind == "monster" && u.Team == 0 && u.Lane == "mid") == blueTop.Count, "reference: no barracks effect");
+ }
+
+ void TestExhaustionAndBuyback()
+ {
+ LuneMatch state = NewMatch(true);
+ state.Players[1].Draft.Gold += 2000;
+ state.Submit(1, Command.HeroBuy("held_sylvara"));
+ Check(!state.Submit(1, Command.Buyback()).Ok, "no buyback for a fresh hero");
+ state.BeginCombat();
+ LuneUnit hero = state.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == 1);
+ hero.Hp = 0.0;
+ state.FinishCombat();
+ HeroState committed = state.Players[1].Hero;
+ Check(committed.Exhausted, "a hero that fell is exhausted");
+ state.BeginCombat();
+ hero = state.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == 1);
+ Check(GdMath.IsEqualApprox(hero.Hp, hero.MaxHp * 0.6), "an exhausted hero starts with 60 % health");
+ state.FinishCombat();
+ Check(!state.Players[1].Hero.Exhausted || !state.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == 1).Alive, "surviving clears the exhaustion");
+ state.Players[1].Hero.Exhausted = true;
+ state.Players[1].Draft = state.Snapshot(state.Players[1]);
+ int level = LuneContent.LevelFor(state.Players[1].Hero.Xp);
+ int cost = 60 + 20 * level;
+ int gold = state.GoldOf(1);
+ Check(state.Submit(1, Command.Buyback()).Ok && state.GoldOf(1) == gold - cost, "buyback costs 60 + 20 × level (" + cost + ")");
+ Check(state.Undo(1).Ok && state.GoldOf(1) == gold && state.Players[1].Draft.Hero.Exhausted, "buyback can be undone");
+ state.Submit(1, Command.Buyback());
+ state.BeginCombat();
+ hero = state.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == 1);
+ Check(hero.Hp == hero.MaxHp, "a bought back hero starts at full health");
+ state.FinishCombat();
+ }
+
+ void TestBlessing()
+ {
+ LuneMatch state = NewMatch(true);
+ state.Players[0].Draft.Gold += 1000;
+ state.Submit(0, Command.Buy("g01", "top"));
+ state.Submit(0, Command.HeroBuy("held_funkenlanze"));
+ state.BeginCombat();
+ state.Combat.GrottoOutcome = "success";
+ state.Combat.GrottoClaimTeam = 0;
+ state.SettleBoss();
+ Check(state.Boss.Blessing[0] == 1 && state.Boss.Blessing[1] == 0, "a grotto victory blesses the winning team");
+ state.FinishCombat();
+ state.Boss.Blessing[0] = 2;
+ state.BeginCombat();
+ LuneUnit troop = state.Combat.Units.First(u => u.Kind == "troop" && u.OwnerSlot == 0);
+ Check(GdMath.IsEqualApprox(troop.MaxHp, DataCatalog.GetUnit("g01").MaxHp * 1.08), "two stacks: troops have 108 % health");
+ LuneUnit enemy = state.Combat.Units.FirstOrDefault(u => u.Kind == "troop" && u.Team == 1);
+ Check(enemy == null || GdMath.IsEqualApprox(enemy.MaxHp, DataCatalog.GetUnit(enemy.Def.Id).MaxHp), "the other team is not blessed");
+ state.FinishCombat();
+ state.Boss.Blessing[0] = 4;
+ state.BeginCombat();
+ LuneUnit hero = state.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == 0);
+ Check(GdMath.IsEqualApprox(hero.Def.Regen, 0.01), "Mondseele: heroes regenerate 1 % per second");
+ state.FinishCombat();
+ state.Combat.GrottoOutcome = "success";
+ state.Combat.GrottoClaimTeam = 0;
+ state.Boss.SettledRound = 0;
+ state.Boss.Claim = null;
+ state.Boss.NextAvailableRound = 1;
+ state.SettleBoss();
+ Check(state.Boss.Blessing[0] == 4, "Mondsegen stops at four stacks");
+ }
+
+ void TestBots()
+ {
+ bool sylvaraPicked = false;
+ for (int seed = 1; seed <= 20 && !sylvaraPicked; seed++)
+ {
+ LuneMatch probe = NewMatch(true, seed);
+ for (int slot = 0; slot < 6; slot++)
+ sylvaraPicked |= LuneBot.ProfileFor(probe, slot) == "assassine";
+ }
+ Check(sylvaraPicked, "extended rules: the bot rotation includes Sylvara's profile");
+ string first = Play(17, out LuneMatch played);
+ Check(first == Play(17, out LuneMatch _), "extended bot matches are deterministic");
+ Check(played.Phase == Phase.Over, "an extended bot match ends by an Ancient (" + first.Split('|')[0] + ")");
+ Check(played.Players.Any(p => p.IncomeBonus > 0), "bots earn champion income");
+ Note("extended seed 17: " + first.Split('|')[0] + ", income bonus " + string.Join(",", played.Players.Select(p => p.IncomeBonus)) + ", blessing " + played.Boss.Blessing[0] + "/" + played.Boss.Blessing[1]);
+ }
+
+ static string Play(int seed, out LuneMatch match)
+ {
+ match = NewMatch(true, seed);
+ while (match.Phase != Phase.Over && match.RoundNumber <= 80)
+ {
+ for (int slot = 0; slot < 6; slot++)
+ LuneBot.Plan(match, slot);
+ match.BeginCombat();
+ match.FinishCombat();
+ }
+ return match.RoundNumber + "/" + match.Winner + "|" + string.Join(",", match.Players.Select(p => p.Gold + ":" + p.Earned + ":" + p.IncomeBonus))
+ + "|" + string.Join(",", match.BuildingHp.Values.Select(v => v.ToString("R", System.Globalization.CultureInfo.InvariantCulture)));
+ }
+ }
+}
diff --git a/Assets/Luneraria/Tests/Core/S1RulesSuite.cs.meta b/Assets/Luneraria/Tests/Core/S1RulesSuite.cs.meta
new file mode 100644
index 0000000..29e8adb
--- /dev/null
+++ b/Assets/Luneraria/Tests/Core/S1RulesSuite.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 9e62ba3250254f8ba4fee06c56dfec7d
diff --git a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs
index 7a1de7c..a818111 100644
--- a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs
+++ b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs
@@ -52,5 +52,6 @@ namespace Luneraria.Core.Tests
[Test] public void NetProtocol() { RunSuite(new NetSuite()); }
[Test] public void NetRounds() { RunSuite(new NetRoundsSuite()); }
[Test] public void H1Heroes() { RunSuite(new H1HeroSuite()); }
+ [Test] public void S1Rules() { RunSuite(new S1RulesSuite()); }
}
}
diff --git a/Assets/Luneraria/Tests/UI/PlayMode/HudSession.cs b/Assets/Luneraria/Tests/UI/PlayMode/HudSession.cs
index b54c8a5..73ef642 100644
--- a/Assets/Luneraria/Tests/UI/PlayMode/HudSession.cs
+++ b/Assets/Luneraria/Tests/UI/PlayMode/HudSession.cs
@@ -40,7 +40,7 @@ namespace Luneraria.UI.Tests
public static string Root { get { return Path.GetDirectoryName(Application.dataPath); } }
/// Loads the scene and starts a match with the human seat, HUD into a width×height texture.
- public IEnumerator Open(int width = 1920, int height = 1080, int seed = 4, float uiScale = 1f, bool reducedMotion = false, int startRound = 1, int extraGold = 0, string[] bunds = null)
+ public IEnumerator Open(int width = 1920, int height = 1080, int seed = 4, float uiScale = 1f, bool reducedMotion = false, int startRound = 1, int extraGold = 0, string[] bunds = null, bool extended = false)
{
LunePreferences.ResetSession(uiScale, reducedMotion);
yield return HudCaptureTests.LoadScene();
@@ -62,6 +62,7 @@ namespace Luneraria.UI.Tests
Driver.StartRound = startRound;
Driver.ExtraGold = extraGold;
Driver.Bunds = bunds;
+ Driver.Extended = extended;
Driver.Speed = 1f;
Driver.Restart();
yield return Frames(3);
diff --git a/Assets/Luneraria/Tests/UI/PlayMode/S1RulesViewsTests.cs b/Assets/Luneraria/Tests/UI/PlayMode/S1RulesViewsTests.cs
new file mode 100644
index 0000000..d3f4499
--- /dev/null
+++ b/Assets/Luneraria/Tests/UI/PlayMode/S1RulesViewsTests.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections;
+using System.Linq;
+using Luneraria.Core;
+using Luneraria.View;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.TestTools;
+
+namespace Luneraria.UI.Tests
+{
+ ///
+ /// S1: the extended rules in the real scene. S1Hud checks the champion income in the gold display and the champion tooltip, the barracks mark of a
+ /// lane, exhaustion and the buyback button of the hero window, and the Mondsegen in the team
+ /// header. Fixtures set public match state only (a fallen tower, a fallen hero, blessing
+ /// stacks), as the G3 verify does. S1Bilder (explicit) captures the evidence at 1920×1080;
+ /// LUNE_EVIDENCE names the folder below docs/port/screenshots (default s1/spiel).
+ ///
+ public sealed class S1RulesViewsTests
+ {
+ static void Send(HudSession s, Command command)
+ {
+ CommandResult result = s.Match.Submit(HudSession.Human, command);
+ Assert.IsTrue(result.Ok, command.Type + ": " + result.Message);
+ s.Game.RebuildPreview();
+ s.Sync();
+ }
+
+ static IEnumerator WaitReady(HudSession s)
+ {
+ float waited = 0f;
+ while (s.Hud.ReadyBlocked() && waited < 5f)
+ {
+ waited += Time.unscaledDeltaTime;
+ yield return null;
+ }
+ }
+
+ /// One round with a booked champion and a hero that falls; returns in the next planning.
+ static IEnumerator Prepare(HudSession s)
+ {
+ Assert.IsTrue(s.Match.Extended, "the driver started an extended match");
+ yield return s.Starter();
+ s.Leave();
+ Send(s, Command.HeroBuy("held_sylvara"));
+ Send(s, Command.ChampionBook("ch_belagerung", "mid"));
+ s.Game.Key(KeyCode.Space);
+ yield return HudSession.Frames(2);
+ Assert.AreEqual(LuneMatchRunner.Stage.Combat, s.Runner.Current, "combat starts");
+ LuneUnit hero = s.Match.Combat.Units.First(u => u.Kind == "hero" && u.OwnerSlot == HudSession.Human);
+ hero.Hp = 0.0;
+ yield return s.FinishCombat();
+ yield return WaitReady(s);
+ // Public fixtures: Rot's inner mid tower fell earlier, Blau won the grotto twice.
+ s.Match.BuildingHp["rot_mid_inner"] = 0.0;
+ s.Match.BuildingHp["rot_mid_outer"] = 0.0;
+ s.Match.Boss.Blessing[0] = 2;
+ s.Sync();
+ }
+
+ [UnityTest, Timeout(600000)]
+ public IEnumerator S1Hud()
+ {
+ var s = new HudSession();
+ yield return s.Open(1920, 1080, seed: 4, startRound: 6, extraGold: 1500, extended: true);
+ yield return Prepare(s);
+ Assert.AreEqual(15, s.Me.IncomeBonus, "the Keilbrecher adds 15 income");
+ string texts = s.Texts();
+ StringAssert.Contains("davon 15 Söldner", texts, "the gold display shows the champion income");
+ StringAssert.Contains("SEGEN 2/4", texts, "the team header shows the Mondsegen");
+ Assert.IsTrue(HudSession.Visible(s.Hud.Lanes["mid"].Barracks), "mid shows the barracks mark");
+ Assert.IsFalse(HudSession.Visible(s.Hud.Lanes["top"].Barracks), "top shows none");
+ Assert.IsTrue(s.Me.Hero.Exhausted, "the fallen hero is exhausted");
+ s.Hud.OpenHero();
+ yield return HudSession.Frames(2);
+ s.Sync();
+ Assert.IsNotNull(s.Hud.HeroDetail.BuybackButton, "the hero window offers the buyback");
+ int gold = s.Me.Draft.Gold;
+ int cost = s.Match.BuybackCost(s.Me.Draft.Hero);
+ s.Click(s.Hud.HeroDetail.BuybackButton);
+ yield return HudSession.Frames(2);
+ s.Sync();
+ Assert.AreEqual(gold - cost, s.Me.Draft.Gold, "the buyback costs " + cost);
+ Assert.IsFalse(s.Me.Draft.Hero.Exhausted, "the hero is bought back");
+ Assert.IsNull(s.Hud.HeroDetail.BuybackButton, "no buyback button afterwards");
+ s.Hud.CloseModal();
+ s.Close();
+ }
+
+ [UnityTest, Timeout(600000), Explicit("Bildwerkzeug: nur gezielt per -testFilter")]
+ public IEnumerator S1Bilder()
+ {
+ string evidence = Environment.GetEnvironmentVariable("LUNE_EVIDENCE");
+ string folder = "docs/port/screenshots/" + (string.IsNullOrEmpty(evidence) ? "s1/spiel" : evidence) + "/";
+ var s = new HudSession();
+ yield return s.Open(1920, 1080, seed: 4, startRound: 6, extraGold: 1500, extended: true);
+ yield return Prepare(s);
+ yield return new WaitForSecondsRealtime(.5f);
+ yield return s.Shot(folder + "01-planung-einkommen-kaserne-mondsegen.png");
+ s.Hover(s.Hud.Lanes["mid"]);
+ yield return new WaitForSecondsRealtime(.3f);
+ yield return s.Shot(folder + "02-kaserne-tooltip.png");
+ s.Leave();
+ s.Hud.OpenHero();
+ yield return new WaitForSecondsRealtime(.6f);
+ yield return s.Shot(folder + "03-rueckkauf.png");
+ s.Hud.CloseModal();
+ s.Game.Key(KeyCode.Space);
+ yield return HudSession.Frames(2);
+ yield return s.Fight(12.0);
+ s.Driver.Paused = true;
+ LuneUnit monster = s.Match.Combat.Units.Where(u => u.Kind == "monster" && u.Team == 0 && u.Lane == "mid" && u.Alive).OrderByDescending(u => u.Progress).FirstOrDefault();
+ if (monster != null)
+ s.Rig.LookAt(monster.Position, 20f, true);
+ yield return new WaitForSecondsRealtime(.5f);
+ yield return s.Shot(folder + "04-verstaerkte-welle-mid.png");
+ s.Close();
+ }
+ }
+}
diff --git a/Assets/Luneraria/Tests/UI/PlayMode/S1RulesViewsTests.cs.meta b/Assets/Luneraria/Tests/UI/PlayMode/S1RulesViewsTests.cs.meta
new file mode 100644
index 0000000..bcb949d
--- /dev/null
+++ b/Assets/Luneraria/Tests/UI/PlayMode/S1RulesViewsTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 1591de50998e4ea096591b6bcb4464c2
diff --git a/Assets/Luneraria/UI/Game/LuneMainMenu.cs b/Assets/Luneraria/UI/Game/LuneMainMenu.cs
index a01c493..d5e2d98 100644
--- a/Assets/Luneraria/UI/Game/LuneMainMenu.cs
+++ b/Assets/Luneraria/UI/Game/LuneMainMenu.cs
@@ -30,6 +30,8 @@ namespace Luneraria.UI
LuneButton _laneTop;
LuneButton _laneMid;
LuneButton _laneBot;
+ /// S1: the rules toggle (extended or reference rules).
+ public LuneButton RulesToggle;
public LuneMainMenu()
{
@@ -78,6 +80,18 @@ namespace Luneraria.UI
_laneBot.Clicked += () => SetLane("bot");
SetLane("mid");
+ LuneUi.Label("REGELN", "SectionLabel", column);
+ RulesToggle = new LuneButton("", LuneButtons.HudToggle, column);
+ RulesToggle.ToggleMode = true;
+ RulesToggle.Clicked += () => SetRules(!LunePreferences.ExtendedRules);
+ LuneTooltip.Attach(RulesToggle, () => new LuneTip
+ {
+ Title = "Erweiterte Regeln (S1)", Glyph = "star", GlyphColor = LuneUiTheme.Hero,
+ Text = "Söldner-Einkommen wie in Legion TD 2: Angriffschampions erhöhen dauerhaft dein Einkommen.\nKasernen wie in Dota 2: Fällt ein innerer Turm, kommen die gegnerischen Wellen dort verstärkt.\nRückkauf: Gefallene Helden starten erschöpft, außer du kaufst sie zurück.\nMondsegen wie die Drachenseelen in League of Legends: Jeder Bosssieg stärkt dein Team dauerhaft.\nBots wählen auch Sylvara.",
+ Note = "Aus: die Referenzregeln des Ports.",
+ });
+ SetRules(LunePreferences.ExtendedRules);
+
LuneUi.Label("NETZWERK · DIREKTVERBINDUNG", "SectionLabel", column);
_name = Field(column, "Anzeigename", "Spieler");
VisualElement netRow = LuneUi.Box(false, 10f, column);
@@ -116,6 +130,13 @@ namespace Luneraria.UI
_bundWurzel.On = bund == "wurzelbund";
}
+ void SetRules(bool extended)
+ {
+ LunePreferences.ExtendedRules = extended;
+ RulesToggle.On = extended;
+ RulesToggle.Text = extended ? "ERWEITERT · Legion TD 2, Dota 2, LoL" : "REFERENZ · Regeln des Ports";
+ }
+
void SetLane(string lane)
{
Lane = lane;
@@ -130,7 +151,7 @@ namespace Luneraria.UI
int seat = Array.IndexOf(LuneMap.Lanes, Lane);
if (seat < 0)
seat = 1;
- var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = seat };
+ var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = seat, Extended = LunePreferences.ExtendedRules };
for (int slot = 0; slot < 6; slot++)
options.Bunds.Add(slot == seat ? Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]);
return options;
diff --git a/Assets/Luneraria/UI/Game/LuneNetPlay.cs b/Assets/Luneraria/UI/Game/LuneNetPlay.cs
index 9b457cf..cf628ea 100644
--- a/Assets/Luneraria/UI/Game/LuneNetPlay.cs
+++ b/Assets/Luneraria/UI/Game/LuneNetPlay.cs
@@ -143,7 +143,7 @@ namespace Luneraria.UI
ResetSession();
return;
}
- var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = 0 };
+ var options = new MatchOptions { Seed = Environment.TickCount & 0xffff, HumanSlot = 0, Extended = LunePreferences.ExtendedRules };
for (int slot = 0; slot < 6; slot++)
options.Bunds.Add(slot == 0 ? Menu.Bund : DataCatalog.Factions[(slot + options.Seed) % DataCatalog.Factions.Length]);
if (!Session.HostNetwork(options, displayName))
@@ -213,6 +213,7 @@ namespace Luneraria.UI
Driver.HumanSlot = Game.HumanSlot;
Driver.Bunds = _lastOptions != null && _lastOptions.Bunds != null ? _lastOptions.Bunds.ToArray() : null;
Driver.Seed = _lastOptions != null ? _lastOptions.Seed : Driver.Seed;
+ Driver.Extended = _lastOptions != null && _lastOptions.Extended;
Driver.Restart();
}
}
diff --git a/Assets/Luneraria/UI/Hud/LuneArmyPanels.cs b/Assets/Luneraria/UI/Hud/LuneArmyPanels.cs
index a2265e8..1fb3b6e 100644
--- a/Assets/Luneraria/UI/Hud/LuneArmyPanels.cs
+++ b/Assets/Luneraria/UI/Hud/LuneArmyPanels.cs
@@ -135,7 +135,8 @@ namespace Luneraria.UI
{
Title = spec.Name + " · Angriffschampion", Glyph = "champion", GlyphColor = LuneUiTheme.Champion,
Subtitle = spec.RoleText + " · " + spec.Cost + " Gold · Einsatz in Welle " + round + " – " + LuneUi.Upper(LuneMap.LaneNames[TargetLane]),
- Text = spec.Text,
+ // S1 (Legion TD 2): sending is also an investment in income.
+ Text = spec.Text + (_model != null && _model.State.Extended ? "\nSöldner-Einkommen: +" + _model.State.ChampionIncome(spec.Cost) + " Gold in jeder folgenden Runde, dauerhaft." : ""),
Note = "Einmaliger Einsatz: kämpft eine Welle lang und verschwindet danach. Die Ankündigung ist für alle sichtbar; sein Tod bringt dem Gegner " + definition.Bounty + " Gold.",
Warn = Cards[championId].Reason,
};
diff --git a/Assets/Luneraria/UI/Hud/LuneComponents.cs b/Assets/Luneraria/UI/Hud/LuneComponents.cs
index 63662c7..50f3ba2 100644
--- a/Assets/Luneraria/UI/Hud/LuneComponents.cs
+++ b/Assets/Luneraria/UI/Hud/LuneComponents.cs
@@ -66,7 +66,7 @@ namespace Luneraria.UI
Detail = LuneUi.Label("", "HudMuted", column);
}
- public void SetValues(int gold, int income, bool showIncome = true)
+ public void SetValues(int gold, int income, bool showIncome = true, int bonus = 0)
{
if (gold != _shown)
{
@@ -75,7 +75,8 @@ namespace Luneraria.UI
_shown = gold;
Amount.text = LuneUiTheme.Number(gold);
}
- string line = showIncome ? "+" + LuneUiTheme.Number(income) + " nächste Runde" : "";
+ // S1: the champion income is part of the sum; the short form fits the gold column.
+ string line = !showIncome ? "" : bonus > 0 ? "+" + LuneUiTheme.Number(income) + " · davon " + bonus + " Söldner" : "+" + LuneUiTheme.Number(income) + " nächste Runde";
if (Detail.text != line)
Detail.text = line;
LuneUi.Show(Detail, showIncome);
@@ -146,15 +147,18 @@ namespace Luneraria.UI
}
}
+ /// S1: Mondsegen stacks of the own team (-1 under the reference rules).
+ public int Blessing = -1;
+
public void Apply(List members, int team, bool planning, bool networked)
{
- string signature = string.Join(",", members) + "|" + team + "|" + planning + "|" + networked;
+ string signature = string.Join(",", members) + "|" + team + "|" + planning + "|" + networked + "|" + Blessing;
if (signature == _signature)
return;
_signature = signature;
_emblem.Glyph = "emblem_" + team;
_emblem.Color = LuneUiTheme.TeamColor(team);
- _header.text = "DEIN TEAM · " + LuneUi.Upper(LuneUiTheme.TeamName(team));
+ _header.text = "DEIN TEAM · " + LuneUi.Upper(LuneUiTheme.TeamName(team)) + (Blessing > 0 ? " · SEGEN " + Blessing + "/4" : "");
for (int index = 0; index < Mathf.Min(3, members.Count); index++)
{
LuneHudModel.MemberInfo member = members[index];
diff --git a/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs b/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
index 74f9d91..987738c 100644
--- a/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
+++ b/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
@@ -142,7 +142,7 @@ namespace Luneraria.UI
_hpBar.Fill = LuneHpBar.HealthColor(_hpBar.Fraction, LuneUiTheme.Good);
_portrait.Crossed = !live.Alive;
}
- string signature = (hasHero ? hero.HeroId + ":" + hero.Xp + ":" + hero.Lane + ":" + string.Join(",", hero.Items) + ":" + string.Join(",", hero.Talents) + ":" + hero.Ultimate + ":" + hero.Skin : "-") + "|"
+ string signature = (hasHero ? hero.HeroId + ":" + hero.Xp + ":" + hero.Lane + ":" + string.Join(",", hero.Items) + ":" + string.Join(",", hero.Talents) + ":" + hero.Ultimate + ":" + hero.Skin + ":" + hero.Exhausted : "-") + "|"
+ (live != null) + "|" + (live != null ? live.UltimateSerial : 0) + "|" + model.OwnGold + "|" + model.Planning;
if (signature == _signature)
return;
@@ -188,7 +188,7 @@ namespace Luneraria.UI
_xpText.text = top ? "EP " + hero.Xp + " · max" : "EP " + hero.Xp + " / " + high;
LuneUi.Show(_hpBar, live != null);
if (live == null)
- _hp.text = LuneUiTheme.Number(_definition.MaxHp) + " Leben";
+ _hp.text = LuneUiTheme.Number(_definition.MaxHp) + " Leben" + (hero.Exhausted && model.State.Extended ? " · ERSCHÖPFT" : "");
for (int index = 0; index < _slots.Length; index++)
{
string itemId = index < hero.Items.Count ? hero.Items[index] : "";
@@ -269,6 +269,8 @@ namespace Luneraria.UI
public readonly List TalentCards = new List();
public readonly Dictionary RuleCards = new Dictionary();
public readonly Dictionary SkinCards = new Dictionary();
+ /// S1: buys an exhausted hero back (null when not offered).
+ public LuneButton BuybackButton;
public LuneTabBar Tabs;
LuneHudModel _model;
string _signature = "";
@@ -297,7 +299,7 @@ namespace Luneraria.UI
{
_model = model;
HeroState hero = model.OwnHero;
- string signature = (hero != null ? hero.HeroId + ":" + hero.Xp + ":" + hero.Lane + ":" + hero.Fresh + ":" + string.Join(",", hero.Items) + ":" + string.Join(",", hero.Talents) + ":" + string.Join(",", hero.FreshItems) + ":" + hero.Ultimate + ":" + hero.Skin : "-")
+ string signature = (hero != null ? hero.HeroId + ":" + hero.Xp + ":" + hero.Lane + ":" + hero.Fresh + ":" + string.Join(",", hero.Items) + ":" + string.Join(",", hero.Talents) + ":" + string.Join(",", hero.FreshItems) + ":" + hero.Ultimate + ":" + hero.Skin + ":" + hero.Exhausted : "-")
+ "|" + model.OwnGold + "|" + model.Planning + "|" + model.Locked + "|" + Tab + "|" + model.State.GrottoAvailable() + "|" + model.State.RoundNumber;
if (signature == _signature)
return;
@@ -309,6 +311,7 @@ namespace Luneraria.UI
TalentCards.Clear();
RuleCards.Clear();
SkinCards.Clear();
+ BuybackButton = null;
Tabs = null;
if (hero == null)
Recruit(model);
@@ -428,6 +431,16 @@ namespace Luneraria.UI
}
if (hero.Lane == LuneMatch.Grotto)
LuneUi.SetColor(LuneUi.Wrap(LuneUi.Label("Der Held fehlt seiner Lane in der nächsten Kampfphase.", "HudText", left)), LuneUiTheme.Warn);
+ // S1 (Dota 2 buyback, adapted): a fallen hero starts exhausted unless bought back.
+ if (hero.Exhausted && model.State.Extended)
+ {
+ int percent = (int)Math.Round(model.State.Balance.ExhaustedHp * 100.0);
+ LuneUi.SetColor(LuneUi.Wrap(LuneUi.Label("ERSCHÖPFT: gefallen, startet mit " + percent + " % Leben.", "HudText", left)), LuneUiTheme.Warn);
+ int cost = model.State.BuybackCost(hero);
+ BuybackButton = new LuneButton("Rückkauf · " + cost + " G", LuneButtons.HudPrimary, left);
+ BuybackButton.Disabled = !canAct || model.OwnGold < cost;
+ BuybackButton.Clicked += () => CommandRequested?.Invoke(Command.Buyback());
+ }
if (hero.Fresh && model.Planning)
{
int refund = hero.Invested + hero.Items.Sum(id => LuneContent.Items[id].Cost);
diff --git a/Assets/Luneraria/UI/Hud/LuneHud.cs b/Assets/Luneraria/UI/Hud/LuneHud.cs
index 6124a7c..3bae2db 100644
--- a/Assets/Luneraria/UI/Hud/LuneHud.cs
+++ b/Assets/Luneraria/UI/Hud/LuneHud.cs
@@ -797,6 +797,7 @@ namespace Luneraria.UI
bool reduced = LunePreferences.ReducedMotion;
foreach (KeyValuePair pair in Lanes)
pair.Value.Apply(Model.Lane(pair.Key), Model.Team, delta, reduced);
+ _team.Blessing = Model.State.Extended ? Model.State.Boss.Blessing[Model.Team] : -1;
_team.Apply(Model.Members(Model.Team), Model.Team, planning, Model.Networked);
SyncRight(planning, fighting);
SyncBottom(planning, fighting);
@@ -900,7 +901,7 @@ namespace Luneraria.UI
void SyncBottom(bool planning, bool fighting)
{
- _gold.SetValues(Model.OwnGold, Model.Income, !Model.Over);
+ _gold.SetValues(Model.OwnGold, Model.Income, !Model.Over, Model.IncomeBonus);
List roster = Model.OwnRoster;
bool locked = Model.Locked;
bool canAct = planning && !locked;
@@ -1447,7 +1448,7 @@ namespace Luneraria.UI
Title = "Dein Gold", Glyph = "coin", GlyphColor = LuneUiTheme.Gold,
Text = "Dazu kommt Gold für besiegte Monster, Champions, Gebäude und den Boss auf der eigenen Seite (Teile gehen ans Team).",
Note = "Das Gold der anderen Spieler bleibt privat.",
- }.Stat("coin", "Jetzt", LuneUiTheme.Number(Model.OwnGold)).Stat("clock", "Einkommen", "+" + LuneUiTheme.Number(Model.Income) + " nach dieser Welle");
+ }.Stat("coin", "Jetzt", LuneUiTheme.Number(Model.OwnGold)).Stat("clock", "Einkommen", "+" + LuneUiTheme.Number(Model.Income) + " nach dieser Welle" + (Model.IncomeBonus > 0 ? " (davon " + Model.IncomeBonus + " Söldner-Einkommen aus Angriffschampions)" : ""));
}
LuneTip StarterTip()
diff --git a/Assets/Luneraria/UI/Hud/LuneLanePanels.cs b/Assets/Luneraria/UI/Hud/LuneLanePanels.cs
index d2a4980..c79d988 100644
--- a/Assets/Luneraria/UI/Hud/LuneLanePanels.cs
+++ b/Assets/Luneraria/UI/Hud/LuneLanePanels.cs
@@ -34,6 +34,8 @@ namespace Luneraria.UI
readonly LuneGlyphElement _mine;
readonly LuneGlyphElement _ownShape;
readonly LuneGlyphElement _enemyShape;
+ /// S1 barracks: crossed swords in the own team colour (own waves reinforced) or red (enemy waves).
+ public readonly LuneGlyphElement Barracks;
float _lowerHold;
float _pulse;
string _signature = "";
@@ -57,6 +59,8 @@ namespace Luneraria.UI
_mine = LuneUi.Glyph("star", LuneUiTheme.BronzeLight, 12f, head);
LuneUi.Show(_mine, false);
LuneUi.Spacer(head);
+ Barracks = LuneUi.Glyph("melee", LuneUiTheme.Muted, 14f, head);
+ LuneUi.Show(Barracks, false);
_badge = new LuneStatusBadge();
head.Add(_badge);
VisualElement chain = LuneUi.Box(false, 3f, column);
@@ -121,6 +125,8 @@ namespace Luneraria.UI
}
Color tint = LuneUiTheme.Threat[Level];
_badge.SetState(LuneLaneThreat.Names[Level], tint, Level >= 2 ? LuneLaneThreat.Glyphs[Level] : "", Level == LuneLaneThreat.Critical);
+ LuneUi.Show(Barracks, entry.OwnBarracks || entry.EnemyBarracks);
+ Barracks.Color = entry.EnemyBarracks ? LuneUiTheme.Danger : LuneUiTheme.TeamColor(ownTeam);
_ownShape.Glyph = "emblem_" + ownTeam;
_ownShape.Color = LuneUiTheme.TeamColor(ownTeam);
_enemyShape.Glyph = "emblem_" + (1 - ownTeam);
@@ -190,6 +196,11 @@ namespace Luneraria.UI
tip.Stat("tower", "Gegner " + LuneHudModel.BuildingTitle(Data.Towers[index].Id), TowerValue(Data.Towers[index]));
if (Data.HasCounts)
tip.Stat("melee", "Einheiten", Data.OwnCount + " eigene · " + Data.EnemyCount + " gegnerische");
+ // S1 barracks (Dota 2): a fallen inner tower reinforces the attackers' waves on this lane for good.
+ if (Data.OwnBarracks)
+ tip.Stat("melee", "Kaserne", "gegnerischer innerer Turm gefallen: eure Wellen +25 % und ein Monster mehr");
+ if (Data.EnemyBarracks)
+ tip.Stat("warning", "Kaserne", "euer innerer Turm ist gefallen: gegnerische Wellen +25 % und ein Monster mehr");
var notes = new List();
foreach (Announcement danger in Data.Dangers)
{
diff --git a/Assets/Luneraria/UI/Hud/LunePreferences.cs b/Assets/Luneraria/UI/Hud/LunePreferences.cs
index 7cfd268..7b54ed3 100644
--- a/Assets/Luneraria/UI/Hud/LunePreferences.cs
+++ b/Assets/Luneraria/UI/Hud/LunePreferences.cs
@@ -22,6 +22,22 @@ namespace Luneraria.UI
static int _muted = -1;
static int _fullscreen = -1;
static int _ambientOcclusion = -1;
+ static int _extendedRules = -1;
+
+ ///
+ /// S1: start matches with the extended rules (champion income, barracks, buyback,
+ /// Mondsegen; bots may pick Sylvara); on by default. Off = the reference rules of the port.
+ ///
+ public static bool ExtendedRules
+ {
+ get
+ {
+ if (_extendedRules < 0)
+ _extendedRules = PlayerPrefs.GetInt("lune.extended_rules", 1);
+ return _extendedRules == 1;
+ }
+ set { _extendedRules = value ? 1 : 0; Save("lune.extended_rules", _extendedRules); }
+ }
public static float UiScale
{
diff --git a/Assets/Luneraria/UI/Model/LuneHudModel.cs b/Assets/Luneraria/UI/Model/LuneHudModel.cs
index 574b1f9..1607e04 100644
--- a/Assets/Luneraria/UI/Model/LuneHudModel.cs
+++ b/Assets/Luneraria/UI/Model/LuneHudModel.cs
@@ -50,7 +50,9 @@ namespace Luneraria.UI
public int Enemy { get { return 1 - Team; } }
public int OwnGold { get { return State.GoldOf(Slot); } }
/// Base income paid at the end of the current round's combat (rules: LuneBalance.RoundIncome).
- public int Income { get { return State.Balance.RoundIncome(State.RoundNumber); } }
+ public int Income { get { return State.Balance.RoundIncome(State.RoundNumber) + IncomeBonus; } }
+ /// S1: the own permanent champion income (Legion TD 2), 0 under the reference rules.
+ public int IncomeBonus { get { return State.Extended ? Me.IncomeBonus : 0; } }
/// After Ready the plan is binding (the host refuses later commands).
public bool Locked { get { return Planning && Me.Ready; } }
public List OwnRoster { get { return Planning ? Me.Draft.Roster : Me.Roster; } }
@@ -301,6 +303,9 @@ namespace Luneraria.UI
public bool HasCounts;
public int OwnCount;
public int EnemyCount;
+ /// S1 barracks: own waves reinforced here (enemy inner tower fell) / enemy waves reinforced.
+ public bool OwnBarracks;
+ public bool EnemyBarracks;
}
public LaneInfo Lane(string lane)
@@ -314,6 +319,7 @@ namespace Luneraria.UI
Lane = lane, Title = LuneMap.LaneNames[lane].ToUpperInvariant(), OwnPlayer = PlayerName(ownSeat), EnemyPlayer = PlayerName(enemySeat), OwnIsMe = ownSeat == Slot,
Towers = new[] { Building(own + "_" + lane + "_inner"), Building(own + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_inner") },
Threat = LuneLaneThreat.Evaluate(State, Team, lane), OwnHero = HeroOf(ownSeat), EnemyHero = HeroOf(enemySeat),
+ OwnBarracks = State.Extended && State.BarracksBroken(Team, lane), EnemyBarracks = State.Extended && State.BarracksBroken(Enemy, lane),
};
foreach (Announcement entry in State.Announcements())
{
diff --git a/Assets/Luneraria/View/LuneMatchDriver.cs b/Assets/Luneraria/View/LuneMatchDriver.cs
index 73d1af4..b41d8ff 100644
--- a/Assets/Luneraria/View/LuneMatchDriver.cs
+++ b/Assets/Luneraria/View/LuneMatchDriver.cs
@@ -37,6 +37,8 @@ namespace Luneraria.View
public int SpeedIndex;
public bool Paused;
public bool RecordTrace = true;
+ /// S1: extended rules for matches the driver starts itself (tests, benchmark); the start screen decides for the game.
+ public bool Extended;
public LuneMatchRunner Runner { get; private set; }
/// Main-thread cost of the last frame's simulation (milliseconds).
@@ -51,7 +53,7 @@ namespace Luneraria.View
public void Restart()
{
- var options = new MatchOptions { Seed = Seed, HumanSlot = HumanSlot, Bunds = Bunds != null ? new List(Bunds) : new List() };
+ var options = new MatchOptions { Seed = Seed, HumanSlot = HumanSlot, Bunds = Bunds != null ? new List(Bunds) : new List(), Extended = Extended };
Runner = new LuneMatchRunner(options, StartRound, ExtraGold, Profile);
if (RecordTrace)
Runner.Trace = new LuneTrace();
diff --git a/docs/luneraria/IDEEN.md b/docs/luneraria/IDEEN.md
index 99f35af..7686976 100644
--- a/docs/luneraria/IDEEN.md
+++ b/docs/luneraria/IDEEN.md
@@ -73,6 +73,18 @@ Legende:
| Ping-Rad, Teamplanung | 🟢 | Absprachen wie „Grotte diese Runde?“. |
| Replays, Zuschauer | 🟢 | Die Simulation ist deterministisch; ein Replay sind Seed plus Befehle. |
+## Umsetzungsstand
+
+| Idee | Stand |
+| --- | --- |
+| Ultimate-Bedingungen | umgesetzt in H1 (Sylvara) |
+| Skins | umgesetzt in H1 (Blutmond) |
+| Einkommen über Champions | umgesetzt in S1 (Söldner-Einkommen) |
+| Kasernen | umgesetzt in S1 |
+| Respawn und Rückkauf | umgesetzt in S1, abgeschwächt (Erschöpfung statt Aussetzen) |
+| Drachen-Analogon | umgesetzt in S1 als Mondsegen über die Grotte |
+| Schadens- und Rüstungstypen | bestand bereits (3 × 3); Farben im HUD seit G3 |
+
## Empfohlene Reihenfolge
1. Ultimate- und Item-Bedingungen
diff --git a/docs/luneraria/S1-STRATEGIE.md b/docs/luneraria/S1-STRATEGIE.md
new file mode 100644
index 0000000..36fd430
--- /dev/null
+++ b/docs/luneraria/S1-STRATEGIE.md
@@ -0,0 +1,100 @@
+# S1 · Strategie-Regeln nach Legion TD 2, Dota 2 und League of Legends
+
+Stand: 11. Oktober 2026 · Branch `luneraria/s1-strategie`. Der Branch beginnt mit H1 (`luneraria/h1-sylvara`), in den `luneraria/u5.5` zusammengeführt wurde (Merge `2e05cbe`).
+Grundlage: [IDEEN.md](IDEEN.md). Auftrag: „weitermachen, an Legion TD 2, Dota 2 und League of Legends orientieren“.
+
+
+
+## Kurzfassung
+
+Vier Mechaniken, je eine typische Idee der Vorbilder, übertragen auf einen Autobattler ohne direkte Steuerung:
+
+| Vorbild | Mechanik | Strategische Frage |
+| --- | --- | --- |
+| Legion TD 2 (Söldner → Einkommen) | **Söldner-Einkommen:** Jeder gebuchte Angriffschampion erhöht das eigene Rundeneinkommen dauerhaft um Preis ÷ 12 (Rostschieber/Dunstsänger +12, Keilbrecher +15) | Jetzt Druck und Wirtschaft aufbauen oder Gold für die eigene Verteidigung behalten? |
+| Dota 2 (Kasernen, Megacreeps) | **Kasernen:** Fällt der innere Turm einer Lane, kommen die Monsterwellen des Angreifers dort dauerhaft mit +25 % Leben und Schaden und einem Monster mehr | Welche Lane brechen wir, welche müssen wir halten? |
+| Dota 2 (Rückkauf) | **Erschöpfung und Rückkauf:** Ein gefallener Held startet den nächsten Kampf mit 60 % Leben, außer er wird für 60 + 20 × Stufe Gold zurückgekauft | Gold in den Helden oder in die Armee? |
+| League of Legends (Drachenseelen) | **Mondsegen:** Jeder Grottensieg gibt dem Team dauerhaft +4 % Leben und Schaden für alle Truppen und Helden, bis 4 Stapel. Die „Mondseele“ (4/4) lässt Helden zusätzlich 1 % Leben pro Sekunde regenerieren | Lohnt die Grotte jetzt, auch wenn die Lane dafür dünner wird? |
+
+Außerdem nehmen die Bots mit erweiterten Regeln das Profil `assassine` in ihre Auswahl auf und wählen damit auch Sylvara. Das war die offene Frage aus H1.
+
+## Regelschalter und Parität
+
+- `MatchOptions.Extended` schaltet alles ein. Der Wert wird über das Netz an die Gäste übertragen.
+- **Standard im Spiel: erweitert.** Der Startbildschirm hat den Schalter **REGELN** („ERWEITERT · Legion TD 2, Dota 2, LoL“ oder „REFERENZ“). Die Wahl wird in den Einstellungen gespeichert (`lune.extended_rules`).
+- **Referenzregeln:** Ohne den Schalter gilt alles wie bisher. Das betrifft Golden-Replays, Fingerabdrücke, die HUD-Prüfung A–I und den Benchmark. Die Suite `s1` prüft ausdrücklich, dass keine Mechanik ohne Schalter wirkt.
+- **Neue Zustände** werden über das Netz synchronisiert: `Player.IncomeBonus`, `HeroState.Exhausted`, `BossState.Blessing`. Neuer Befehl: `buyback`.
+- **Zahlen:** Alle Werte stehen in `LuneBalance` (Abschnitt S1).
+
+## Warum der Rückkauf abgeschwächt ist
+
+In Dota 2 kostet ein Tod Zeit. In den Bot-Partien fallen Helden aber fast in jeder Runde (Ringborke nach etwa 19 s, Sylvara nach 13–20 s). „Eine Runde aussetzen“ würde jeden Helden jede zweite Runde stilllegen. Deshalb startet ein gefallener Held erschöpft mit 60 % Leben, und der Rückkauf stellt ihn voll her. So bleibt Dotas Abwägung „Gold gegen Stärke im nächsten Kampf“ erhalten, ohne dass Helden ganze Runden fehlen.
+
+## Oberfläche
+
+| Stelle | Anzeige |
+| --- | --- |
+| Gold unten links | „+109 · davon 15 Söldner“; der Tooltip nennt das Söldner-Einkommen |
+| Angriffschampion-Tooltip | „Söldner-Einkommen: +15 Gold in jeder folgenden Runde, dauerhaft.“ |
+| Lane-Anzeige | gekreuzte Klingen: in Teamfarbe, wenn die eigenen Wellen verstärkt sind, rot bei verstärkten Gegnerwellen; der Lane-Tooltip erklärt die Kaserne |
+| Heldenkarte | „1.450 Leben · ERSCHÖPFT“ |
+| Heldenfenster | „ERSCHÖPFT: gefallen, startet mit 60 % Leben.“ und **Rückkauf · 80 G** |
+| Teamkopf | „DEIN TEAM · BLAU · SEGEN 2/4“ |
+| Startbildschirm | Schalter REGELN mit Tooltip, der alle vier Mechaniken und ihre Vorbilder nennt |
+
+## Balance (Bot-Partien)
+
+Werkzeug: `dotnet run -c Release --project tools/dotnet/Luneraria.Balance s1 60`. Gespielt werden 60 Seeds mit der normalen Profil-Rotation, Rohdaten in `docs/port/logs/s1/balance-s1.txt`.
+
+| | Referenz | Erweitert |
+| --- | --- | --- |
+| Siege Blau / Rot | 30 / 30 | 29 / 31 |
+| Ø Runden (min–max) | 10,7 (9–12) | 12,8 (11–15) |
+| Wer den ersten Turm nimmt, gewinnt | 40 von 60 | 43 von 60 |
+| Söldner-Einkommen je Platz am Ende | – | 14,1 |
+| Kasernen-verstärkte Lane-Wellen | – | 103 |
+| Erschöpfte Helden / Rückkäufe | – | 832 / 520 |
+| Mondsegen-Stapel (gesamt) | – | 8 |
+| Sylvara (Bot-Plätze / Siege) | – | 48 / 24 (50 %) |
+
+Bewertung:
+- **Beide Seiten bleiben ausgeglichen.** Die Partien dauern etwa zwei Runden länger, es gibt also mehr Planungsentscheidungen.
+- **Vorsprünge schaukeln sich nur leicht stärker auf** (40 → 43 von 60). Die Kasernen erzeugen Druck, entscheiden die Partie aber nicht allein.
+- **Rückkauf ist eine echte Goldsenke:** Bots kaufen in etwa 60 % der Fälle zurück.
+- **Mondsegen ist selten,** weil nur das Bot-Profil `bossjagd` die Grotte angreift. Menschen können ihn gezielt suchen. Eine spätere Bot-Phase sollte die Grotte häufiger angehen.
+
+## Tests
+
+| Prüfung | Ergebnis |
+| --- | --- |
+| Kern (.NET 8) | 531/531, darunter **s1 31** (Referenz unberührt, Söldner-Einkommen, Kasernen, Erschöpfung und Rückkauf samt Rückgängig, Mondsegen bis zur Seele, Bot-Rotation mit Sylvara, Determinismus) |
+| Golden und Fingerabdrücke | 0 Abweichungen; `DCE95C96556F3A1A` bei 30/60/120 fps und unbegrenzt unverändert (die Referenzregeln bleiben Standard der Testfahrer) |
+| Unity EditMode | 31/31 |
+| Unity PlayMode | 16/16 (neu `S1RulesViewsTests.S1Hud`), Verify A–I 718/0 |
+| Player-Selbsttest | macOS 30/30, Startbildschirm mit Regelschalter (`screenshots/s1/spiel/00-startbildschirm-regeln.jpg`) |
+
+## Leistung
+
+S1 ändert nur Regeln und HUD-Texte. Benchmark im S1-Player, Seed 9 ab Welle 2, sechs Sylvaras auf dem Feld: Karte 150,3 · 148,5 · 148,5 fps, Mid nah 170,8 · 170,1 · 171,7 fps. HUD-Skripte 0,053 bzw. 0,039 ms pro Bild, also im Rahmen von U3 bis H1. Der Lauf fand unter geringerer Systemlast statt als H1 (136/159 fps); die absoluten Werte sind deshalb nicht direkt vergleichbar. Rohdaten liegen in `docs/port/logs/s1/`.
+
+## Bilder
+
+`docs/port/screenshots/s1/spiel/`:
+- 00: Startbildschirm mit Regelschalter
+- 01: Planung mit Söldner-Einkommen, Kaserne und Segen
+- 02: Kasernen-Tooltip
+- 03: Rückkauf im Heldenfenster
+- 04: verstärkte Welle auf Mid
+
+Unter `ablauf/` liegen die Momente des Vertical Function Test.
+
+## Offene Punkte
+
+- **Mondsegen-Häufigkeit:** Die Bot-Profile sollten die Grotte öfter angreifen. Das betrifft nur die erweiterten Regeln.
+- **Weitere Ideen aus IDEEN.md** für eine S2:
+ - Fog of War mit Spähern (Dota-Wards)
+ - Schadens- und Rüstungsfarben stärker im HUD (das Typensystem 3 × 3 besteht schon, nach Legion TD)
+ - Glyphe
+ - Neutrallager und Schreine (LoL-Buffs)
+ - Item-Rezepte
+- **Nächster Held** nach dem H1-Muster.
diff --git a/docs/port/PORT-QA.md b/docs/port/PORT-QA.md
index e504315..c69fb5e 100644
--- a/docs/port/PORT-QA.md
+++ b/docs/port/PORT-QA.md
@@ -276,6 +276,18 @@ Die Glutpakt-Arbeit entstand als Kette aufeinander aufbauender Zweige (`lunerari
| Bekannte Unterschiede | gewollte Erweiterung über die Referenz hinaus ([../luneraria/H1-SYLVARA.md](../luneraria/H1-SYLVARA.md)) |
| Commits | `luneraria/h1-sylvara` |
+## S1 · Strategie-Regeln (Legion TD 2, Dota 2, League of Legends)
+
+| Punkt | Ergebnis |
+| --- | --- |
+| Tests | Kern .NET 531/531 (neu `s1` 31), EditMode 31/31, PlayMode 16/16 (neu `S1Hud`), Verify A–I 718/0, Player-Selbsttest 30/30 |
+| Parität | Referenzregeln unverändert Standard der Tests: Golden 0 Abweichungen, Fingerabdruck `DCE95C96556F3A1A` gleich; `s1` prüft, dass ohne `Extended` nichts wirkt |
+| Inhalt | Söldner-Einkommen, Kasernen, Erschöpfung und Rückkauf, Mondsegen; Bots wählen Sylvara mit erweiterten Regeln; Regelschalter im Startbildschirm (Standard: erweitert) |
+| Balance | 60 Bot-Partien je Regelwerk: Blau/Rot 30/30 zu 29/31, Ø Runden 10,7 zu 12,8, Sieg nach erstem Turm 40 zu 43 (`logs/s1/balance-s1.txt`) |
+| Leistung | Karte 148,5–150,3 fps, Mid nah 170,1–171,7 fps, HUD 0,04–0,05 ms (`logs/s1/`) |
+| Bild | `screenshots/s1/spiel/` |
+| Commits | `luneraria/s1-strategie` (Basis: Merge H1 + U5.5 `2e05cbe`) |
+
## Ausblick
Der Pilot entscheidet über eine Serie weiterer Meshy-Figuren (Kosten je Figur, Lizenz, Stilgleichheit). Offen bleiben außerdem Künstler-Sculpts für Anatomie/Gesicht/Haar, LOD und Material-Atlanten sowie die Build-Module für Windows und Linux.
diff --git a/docs/port/PORT-STATUS.md b/docs/port/PORT-STATUS.md
index 58d5737..23d7681 100644
--- a/docs/port/PORT-STATUS.md
+++ b/docs/port/PORT-STATUS.md
@@ -1,6 +1,6 @@
# Port-Status · Champions of Luneraria in Unity
-Stand: 11. Oktober 2026 (H1 Sylvara und U5.5 zusammengeführt; S1 in Arbeit).
+Stand: 11. Oktober 2026 (S1, Strategie-Regeln).
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.
@@ -15,6 +15,7 @@ Zielplattformen sind Fedora 44 und Windows 11; macOS ist die Entwicklungsplattfo
| U4 · Netzwerk | **fertig** (`luneraria/u4` = `769e622`): LuneSession (Protokoll 4), TCP-Transport, Startbildschirm Host/Join, 43+12 Prüfungen, Mindestumfang M4. Nachtrag (`luneraria/u4-nachtrag`, in U5.3 zusammengeführt): Startbildschirm in der Szene, „Menü“ führt dorthin zurück | [U4-NETWORK.md](U4-NETWORK.md) |
| U5 · Visuelle Neuausrichtung (Vertical Slice Mid-Lane) | **fertig** (`luneraria/u5` = `2c1686b`, Stufen `luneraria/u5.2`): 6 PBR-Sätze, Surface-Shader mit Albedo/Normal/ORM, Stufen Lane/Rand, Hit- und Turm-VFX, 133/180 fps. **U5.3** (`luneraria/u5.3` = `07fa457`): Gebäude, Boss und Ringborkes Rinde auf PBR (11 Sätze); Bildfehler aus U5 behoben (doppelte Einfärbung, Datenkarten als sRGB, Streifen, bleichender Treffer-Flash, Nebel), Helligkeit und Grün wieder auf U3-Niveau **U5.4** (`luneraria/u5.4` = `4883d95`): Surface-Materialien für alle 146 Figurenmaterialien (Metalle metallisch, Randlicht, Struktur in den Porträts), SSAO (abschaltbar), Leistung −13 % Karte / −16 % Mid **U5.5** (`luneraria/u5.5` = `dd78c8d`): **Pilot** (`c386206`): Meshy-Figur für den Kohlenbeißer `g01` (Pipeline `tools/meshy/`, UV-Material mit Lava-Glut, Schatten ohne Normalen-Versatz), GPU-Batched-Skinning, Figuren-Stress −21 % Mid / −13 % Karte gegenüber Phase-4; **Lane-Fassung** (`f0d1e93`): 5.000-Dreiecke-LOD, Abstand −10 % Mid / −2 % Karte; **Schlackenklaue** `g02` (`714d0c6`) als zweite Meshy-Figur, −11 % Mid / −1 % Karte; **Schlackenwacht** `g03` (`4e2b6bc`), −8 % Mid / 0 % Karte; **Funkenlanze** `g05` mit Lanze als eigenem Modell (`eddf49d`), +15 % gegenüber der Phase-18-Figur; **Glasballiste** `g06` als Maschine mit übernommenem Skelett (`aa77d0e`); **Aschenwerfer** `g07` (`91ea297`), −11 % Mid / +2 % Karte; **Kraterrufer** `g08` (`eb8a537`), −9 % Mid / +10 % Karte; **Herdhüter** `g09` (`024937c`), −4 % Mid / +6 % Karte; **Sonnenesse** `g10` (`02c8839`), +11 % Mid / +15 % Karte; **Basaltbastion** `g04` (`9e253f4`): alle zehn Glutpakt-Truppen sind Meshy-Figuren | [U5-VISUAL.md](U5-VISUAL.md) |
| H1 · Sylvara | **fertig** (`luneraria/h1-sylvara`): erste spielbare Heldin nach dem Port mit Ultimate-Auslöser in der Planung und Skin-System (Blutmond); Referenzpartien unverändert | [../luneraria/H1-SYLVARA.md](../luneraria/H1-SYLVARA.md) |
+| S1 · Strategie-Regeln | **fertig** (`luneraria/s1-strategie`): Söldner-Einkommen (Legion TD 2), Kasernen und Rückkauf (Dota 2), Mondsegen (League of Legends) hinter dem Regelschalter „Erweitert“ (Standard im Spiel); Referenzregeln für Tests unverändert | [../luneraria/S1-STRATEGIE.md](../luneraria/S1-STRATEGIE.md) |
QA je Meilenstein: [PORT-QA.md](PORT-QA.md).
diff --git a/docs/port/logs/s1/balance-s1.txt b/docs/port/logs/s1/balance-s1.txt
new file mode 100644
index 0000000..92970df
--- /dev/null
+++ b/docs/port/logs/s1/balance-s1.txt
@@ -0,0 +1,3 @@
+rules; matches; blue wins; red wins; draws; open; mean rounds; min; max; first tower wins; champion income/seat; barracks waves; exhausted heroes; buybacks; blessings; sylvara seats; sylvara wins
+referenz; 60; 30; 30; 0; 0; 10.7; 9; 12; 40/60; 0.0; 0; 0; 0; 0; 0; 0
+erweitert; 60; 29; 31; 0; 0; 12.8; 11; 15; 43/60; 14.1; 103; 832; 520; 8; 48; 24
diff --git a/docs/port/logs/s1/core-tests-dotnet.json b/docs/port/logs/s1/core-tests-dotnet.json
new file mode 100644
index 0000000..f4972d6
--- /dev/null
+++ b/docs/port/logs/s1/core-tests-dotnet.json
@@ -0,0 +1,16 @@
+{
+ "suites": [
+ {"suite": "content", "checks": 76, "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.54, "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)", "domain ancients: 246 values, 0 differences, largest numeric deviation 0.00E+000", "domain announcements: 44 values, 0 differences, largest numeric deviation 0.00E+000", "domain boss: 1559 values, 0 differences, largest numeric deviation 0.00E+000", "domain combat: 676 values, 0 differences, largest numeric deviation 0.00E+000", "domain commands: 410 values, 0 differences, largest numeric deviation 0.00E+000", "domain events: 6937 values, 0 differences, largest numeric deviation 0.00E+000", "domain facing: 1140 values, 0 differences, largest numeric deviation 0.00E+000", "domain gold: 3584 values, 0 differences, largest numeric deviation 0.00E+000", "domain heroes: 1512 values, 0 differences, largest numeric deviation 0.00E+000", "domain hp: 3420 values, 0 differences, largest numeric deviation 0.00E+000", "domain other: 119 values, 0 differences, largest numeric deviation 0.00E+000", "domain positions: 3420 values, 0 differences, largest numeric deviation 0.00E+000", "domain purchases: 9311 values, 0 differences, largest numeric deviation 0.00E+000", "domain rounds: 119 values, 0 differences, largest numeric deviation 0.00E+000", "domain targets: 1140 values, 0 differences, largest numeric deviation 0.00E+000", "domain towers: 1994 values, 0 differences, largest numeric deviation 0.00E+000", "domain units: 14164 values, 0 differences, largest numeric deviation 0.00E+000", "domain waves: 731 values, 0 differences, largest numeric deviation 0.00E+000", "domain winner: 695 values, 0 differences, largest numeric deviation 0.00E+000"]},
+ {"suite": "determinism", "checks": 6, "failures": 0, "seconds": 1.45, "failed": [], "notes": []},
+ {"suite": "net", "checks": 43, "failures": 0, "seconds": 0.01, "failed": [], "notes": []},
+ {"suite": "net_rounds", "checks": 12, "failures": 0, "seconds": 0.07, "failed": [], "notes": []},
+ {"suite": "h1", "checks": 42, "failures": 0, "seconds": 1.00, "failed": [], "notes": ["sylvara-bot match seed 12: 13/1"]},
+ {"suite": "s1", "checks": 31, "failures": 0, "seconds": 0.60, "failed": [], "notes": ["extended seed 17: 14/0, income bonus 24,24,24,24,24,24, blessing 0/0"]}
+ ],
+ "runtime": ".NET 8.0.27",
+ "label": ".NET 8.0.27, Release",
+ "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"
+}
diff --git a/docs/port/logs/s1/editmode-results.xml b/docs/port/logs/s1/editmode-results.xml
new file mode 100644
index 0000000..2faf647
--- /dev/null
+++ b/docs/port/logs/s1/editmode-results.xml
@@ -0,0 +1,346 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/port/logs/s1/player-benchmark-sylvara-run1.json b/docs/port/logs/s1/player-benchmark-sylvara-run1.json
new file mode 100644
index 0000000..f6204ae
--- /dev/null
+++ b/docs/port/logs/s1/player-benchmark-sylvara-run1.json
@@ -0,0 +1,15 @@
+{
+ "build": "OSXPlayer player, 6000.3.20f1, release",
+ "device": "Mac17,2; Apple M5; Apple M5",
+ "graphics_api": "Metal",
+ "resolution": "1920x1080",
+ "vsync": 0,
+ "match": "seed 9 from round 2, army held_sylvara x6, speed 1x, measured after 10 s of combat",
+ // moving units at the start of the first window: w04 11, w06 11, g04 10, g02 9, g06 9, w02 7, sylvara 5, g08 4, g10 4, w08 4, w10 3
+ "windows": [
+ {"view": "overview (zoom 64)", "frames": 3007, "fps": 150.3, "frame_ms_avg": 6.65, "frame_ms_median": 2.90, "frame_ms_p95": 21.10, "frame_ms_p99": 27.60, "frame_ms_max": 37.21, "frames_over_16_7_ms": 304, "of_them_with_simulation_step": 32, "gc_collections": 20, "simulation_ms_per_frame": 0.059, "views_ms_per_frame": 0.193, "ui_ms_per_frame": 0.053, "units_alive_max": 77},
+ // timings overview (zoom 64): main thread avg 2.29 / median 2.19 / p95 3.14 ms, render thread avg 1.68 / median 1.67 / p95 1.96 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.36 / median 0.25 / p95 18.80 ms
+ {"view": "mid lane close (zoom 22)", "frames": 1708, "fps": 170.8, "frame_ms_avg": 5.86, "frame_ms_median": 2.71, "frame_ms_p95": 20.91, "frame_ms_p99": 28.81, "frame_ms_max": 33.00, "frames_over_16_7_ms": 158, "of_them_with_simulation_step": 12, "gc_collections": 8, "simulation_ms_per_frame": 0.033, "views_ms_per_frame": 0.161, "ui_ms_per_frame": 0.038, "units_alive_max": 37}
+ // timings mid lane close (zoom 22): main thread avg 1.73 / median 1.65 / p95 2.41 ms, render thread avg 1.31 / median 1.30 / p95 1.53 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.13 / median 0.87 / p95 19.07 ms
+ ]
+}
diff --git a/docs/port/logs/s1/player-benchmark-sylvara-run2.json b/docs/port/logs/s1/player-benchmark-sylvara-run2.json
new file mode 100644
index 0000000..bb403e5
--- /dev/null
+++ b/docs/port/logs/s1/player-benchmark-sylvara-run2.json
@@ -0,0 +1,15 @@
+{
+ "build": "OSXPlayer player, 6000.3.20f1, release",
+ "device": "Mac17,2; Apple M5; Apple M5",
+ "graphics_api": "Metal",
+ "resolution": "1920x1080",
+ "vsync": 0,
+ "match": "seed 9 from round 2, army held_sylvara x6, speed 1x, measured after 10 s of combat",
+ // moving units at the start of the first window: w04 11, w06 11, g04 10, g02 9, g06 9, w02 7, sylvara 5, g08 4, g10 4, w08 4, w10 3
+ "windows": [
+ {"view": "overview (zoom 64)", "frames": 2972, "fps": 148.5, "frame_ms_avg": 6.73, "frame_ms_median": 2.90, "frame_ms_p95": 21.29, "frame_ms_p99": 27.11, "frame_ms_max": 40.31, "frames_over_16_7_ms": 327, "of_them_with_simulation_step": 16, "gc_collections": 21, "simulation_ms_per_frame": 0.061, "views_ms_per_frame": 0.198, "ui_ms_per_frame": 0.054, "units_alive_max": 77},
+ // timings overview (zoom 64): main thread avg 2.35 / median 2.24 / p95 3.26 ms, render thread avg 1.71 / median 1.69 / p95 2.03 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.38 / median 0.23 / p95 19.00 ms
+ {"view": "mid lane close (zoom 22)", "frames": 1702, "fps": 170.1, "frame_ms_avg": 5.88, "frame_ms_median": 2.79, "frame_ms_p95": 19.29, "frame_ms_p99": 27.41, "frame_ms_max": 39.90, "frames_over_16_7_ms": 136, "of_them_with_simulation_step": 10, "gc_collections": 8, "simulation_ms_per_frame": 0.032, "views_ms_per_frame": 0.162, "ui_ms_per_frame": 0.039, "units_alive_max": 37}
+ // timings mid lane close (zoom 22): main thread avg 1.74 / median 1.67 / p95 2.40 ms, render thread avg 1.32 / median 1.30 / p95 1.54 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.15 / median 0.99 / p95 17.65 ms
+ ]
+}
diff --git a/docs/port/logs/s1/player-benchmark-sylvara-run3.json b/docs/port/logs/s1/player-benchmark-sylvara-run3.json
new file mode 100644
index 0000000..5b5a633
--- /dev/null
+++ b/docs/port/logs/s1/player-benchmark-sylvara-run3.json
@@ -0,0 +1,15 @@
+{
+ "build": "OSXPlayer player, 6000.3.20f1, release",
+ "device": "Mac17,2; Apple M5; Apple M5",
+ "graphics_api": "Metal",
+ "resolution": "1920x1080",
+ "vsync": 0,
+ "match": "seed 9 from round 2, army held_sylvara x6, speed 1x, measured after 10 s of combat",
+ // moving units at the start of the first window: w04 11, w06 11, g04 10, g02 9, g06 9, w02 7, sylvara 5, g08 4, g10 4, w08 4, w10 3
+ "windows": [
+ {"view": "overview (zoom 64)", "frames": 2970, "fps": 148.5, "frame_ms_avg": 6.74, "frame_ms_median": 2.91, "frame_ms_p95": 21.86, "frame_ms_p99": 27.91, "frame_ms_max": 42.25, "frames_over_16_7_ms": 317, "of_them_with_simulation_step": 35, "gc_collections": 21, "simulation_ms_per_frame": 0.061, "views_ms_per_frame": 0.196, "ui_ms_per_frame": 0.054, "units_alive_max": 77},
+ // timings overview (zoom 64): main thread avg 2.33 / median 2.22 / p95 3.29 ms, render thread avg 1.70 / median 1.69 / p95 2.01 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.39 / median 0.25 / p95 19.50 ms
+ {"view": "mid lane close (zoom 22)", "frames": 1718, "fps": 171.7, "frame_ms_avg": 5.82, "frame_ms_median": 2.68, "frame_ms_p95": 19.50, "frame_ms_p99": 27.41, "frame_ms_max": 34.60, "frames_over_16_7_ms": 144, "of_them_with_simulation_step": 10, "gc_collections": 8, "simulation_ms_per_frame": 0.033, "views_ms_per_frame": 0.163, "ui_ms_per_frame": 0.039, "units_alive_max": 37}
+ // timings mid lane close (zoom 22): main thread avg 1.73 / median 1.67 / p95 2.46 ms, render thread avg 1.32 / median 1.30 / p95 1.53 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.10 / median 0.51 / p95 17.73 ms
+ ]
+}
diff --git a/docs/port/logs/s1/player-smoke.json b/docs/port/logs/s1/player-smoke.json
new file mode 100644
index 0000000..29d4998
--- /dev/null
+++ b/docs/port/logs/s1/player-smoke.json
@@ -0,0 +1,11 @@
+{
+ "build": "OSXPlayer player, 6000.3.20f1, release",
+ "device": "Mac17,2; Apple M5; Apple M5",
+ "graphics_api": "Metal",
+ "resolution": "1920x1080",
+ "seconds": 11.6,
+ "passed": ["Startbildschirm sichtbar", "Partie wartet: Spielcontroller und Treiber aus", "vor dem Start kein HUD", "„Allein spielen“ bedienbar", "Offline: Startbildschirm verborgen", "Offline: Spielcontroller und Treiber laufen", "Offline: HUD sichtbar", "Offline: neue Partie, Sitz 1 (Mid)", "Offline: Planung", "Offline: lokale Sitzung", "„Startaufstellung“ bedienbar", "Startaufstellung gekauft (3 Einheiten)", "„Bereit“ bedienbar", "Bereit: Kampf beginnt", "Kampf: Figuren im Feld", "„Menü“ bedienbar", "Verlassen-Dialog mit „Zum Menü“", "Zurück: Startbildschirm sichtbar", "Zurück: HUD verborgen", "Zurück: Spielcontroller und Treiber aus", "Zurück: Sitzung beendet", "Zurück: keine Figuren im Feld", "Zurück: Kamera ruht", "Zurück: „Allein spielen“ bedienbar", "Zweite Partie: Startbildschirm verborgen, HUD sichtbar", "Zweite Partie: neue Partie in der Planung", "SSAO folgt der Einstellung", "Option „Umgebungsverdeckung“ sichtbar", "Option schaltet SSAO um", "Option stellt SSAO zurück"],
+ "failed": [],
+ "photos": ["00-startbildschirm.jpg", "01-planung.jpg", "02-kampf.jpg", "03-verlassen.jpg", "04-zurueck-im-startbildschirm.jpg", "05-zweite-partie.jpg", "06-optionen.jpg"],
+ "result": "PASS"
+}
diff --git a/docs/port/logs/s1/playmode-results.xml b/docs/port/logs/s1/playmode-results.xml
new file mode 100644
index 0000000..a3001d4
--- /dev/null
+++ b/docs/port/logs/s1/playmode-results.xml
@@ -0,0 +1,387 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/port/logs/s1/scene-frame-rates.json b/docs/port/logs/s1/scene-frame-rates.json
new file mode 100644
index 0000000..c0b3f27
--- /dev/null
+++ b/docs/port/logs/s1/scene-frame-rates.json
@@ -0,0 +1,11 @@
+{
+ "match": "seed 2, bots, speed 24x",
+ "core_hash": "DCE95C96556F3A1A",
+ "core_steps": 12690,
+ "runs": [
+ {"target": "30 fps", "frames": 816, "seconds": 27.178, "measured_fps": 30.024, "max_steps_per_frame": 17, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true},
+ {"target": "60 fps", "frames": 1626, "seconds": 27.086, "measured_fps": 60.031, "max_steps_per_frame": 10, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true},
+ {"target": "120 fps", "frames": 3225, "seconds": 26.91, "measured_fps": 119.845, "max_steps_per_frame": 7, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true},
+ {"target": "unlimited", "frames": 85223, "seconds": 26.82, "measured_fps": 3177.628, "max_steps_per_frame": 6, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true}
+ ]
+}
diff --git a/docs/port/logs/s1/vertical-function-test.json b/docs/port/logs/s1/vertical-function-test.json
new file mode 100644
index 0000000..c5e9653
--- /dev/null
+++ b/docs/port/logs/s1/vertical-function-test.json
@@ -0,0 +1,16 @@
+{
+ "runs": [
+ {"label": "match-seed2", "seed": 2, "start_round": 1, "extra_gold": 0, "speed": 6, "winner": 1, "end_note": "Ancient von Blau zerstört.", "steps": 12690, "hash": "DCE95C96556F3A1A", "core_hash": "DCE95C96556F3A1A", "same_as_core": true,
+ "units_alive_max": 84, "heroes_alive_max": 4, "boss_log": ["round 8: won team 1 ", "round 9: deployed team 1 bot"],
+ "rounds": [{"round": 1, "combat_seconds": 44.9, "lost": [], "boss": "none", "end": "cleared"}, {"round": 2, "combat_seconds": 57.1, "lost": [], "boss": "none", "end": "cleared"}, {"round": 3, "combat_seconds": 54.1, "lost": [], "boss": "none", "end": "cleared"}, {"round": 4, "combat_seconds": 62.9, "lost": [], "boss": "none", "end": "cleared"}, {"round": 5, "combat_seconds": 60.7, "lost": [], "boss": "none", "end": "cleared"}, {"round": 6, "combat_seconds": 62.1, "lost": [], "boss": "none", "end": "cleared"}, {"round": 7, "combat_seconds": 66.5, "lost": ["blau_bot_outer"], "boss": "none", "end": "cleared"}, {"round": 8, "combat_seconds": 89.65, "lost": ["rot_top_outer", "rot_top_inner"], "boss": "won", "end": "cleared"}, {"round": 9, "combat_seconds": 136.55, "lost": ["blau_bot_inner", "blau_base_2", "blau_base_1", "blau_ancient"], "boss": "deployed", "end": "core"}],
+ "events": ["round 7: blau_bot_outer destroyed", "round 8: rot_top_outer destroyed", "round 8: rot_top_inner destroyed", "round 8: grotto boss won", "round 9: blau_bot_inner destroyed", "round 9: blau_base_2 destroyed", "round 9: blau_base_1 destroyed", "round 9: blau_ancient destroyed", "round 9: grotto boss deployed"],
+ "shots": ["01-runde1-aufmarsch", "02-runde1-mid-kampf", "03-turm-faellt", "04-turm-ruine", "05-grotte-bosskampf", "06-held-kaempft", "07-boss-auf-der-lane", "08-ancient-faellt", "09-ancient-ruine", "10-ende-uebersicht"],
+ "performance": null},
+ {"label": "match-seed9-round21", "seed": 9, "start_round": 21, "extra_gold": 4000, "speed": 6, "winner": 0, "end_note": "Ancient von Rot zerstört.", "steps": 4195, "hash": "7B8E43AEFC0D60C5", "core_hash": "7B8E43AEFC0D60C5", "same_as_core": true,
+ "units_alive_max": 125, "heroes_alive_max": 6, "boss_log": [],
+ "rounds": [{"round": 21, "combat_seconds": 121.8, "lost": ["rot_mid_outer", "rot_top_outer", "rot_mid_inner"], "boss": "none", "end": "cleared"}, {"round": 22, "combat_seconds": 87.95, "lost": ["rot_top_inner", "rot_base_1", "blau_bot_outer", "rot_base_2", "rot_ancient"], "boss": "none", "end": "core"}],
+ "events": ["round 21: rot_mid_outer destroyed", "round 21: rot_top_outer destroyed", "round 21: rot_mid_inner destroyed", "round 22: rot_top_inner destroyed", "round 22: rot_base_1 destroyed", "round 22: blau_bot_outer destroyed", "round 22: rot_base_2 destroyed", "round 22: rot_ancient destroyed"],
+ "shots": ["11-welle21-uebersicht", "12-welle21-kampf", "13-ancient-rot-faellt", "14-welle-ende"],
+ "performance": {"window": "20 s real time at 1x speed, unlimited frame rate, round 21", "frames": 48702, "fps": 2435.099, "frame_ms_avg": 0.411, "frame_ms_p95": 0.418, "simulation_ms_per_frame": 0.004, "views_ms_per_frame": 0.129, "units_alive_max": 116}}
+ ]
+}
diff --git a/docs/port/screenshots/s1/ablauf/01-runde1-aufmarsch.jpg b/docs/port/screenshots/s1/ablauf/01-runde1-aufmarsch.jpg
new file mode 100644
index 0000000..8ec7c01
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/01-runde1-aufmarsch.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/02-runde1-mid-kampf.jpg b/docs/port/screenshots/s1/ablauf/02-runde1-mid-kampf.jpg
new file mode 100644
index 0000000..258c307
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/02-runde1-mid-kampf.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/03-turm-faellt.jpg b/docs/port/screenshots/s1/ablauf/03-turm-faellt.jpg
new file mode 100644
index 0000000..98d98c4
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/03-turm-faellt.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/04-turm-ruine.jpg b/docs/port/screenshots/s1/ablauf/04-turm-ruine.jpg
new file mode 100644
index 0000000..2b0483b
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/04-turm-ruine.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/05-grotte-bosskampf.jpg b/docs/port/screenshots/s1/ablauf/05-grotte-bosskampf.jpg
new file mode 100644
index 0000000..0643282
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/05-grotte-bosskampf.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/06-held-kaempft.jpg b/docs/port/screenshots/s1/ablauf/06-held-kaempft.jpg
new file mode 100644
index 0000000..ad13265
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/06-held-kaempft.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/07-boss-auf-der-lane.jpg b/docs/port/screenshots/s1/ablauf/07-boss-auf-der-lane.jpg
new file mode 100644
index 0000000..17fc81a
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/07-boss-auf-der-lane.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/08-ancient-faellt.jpg b/docs/port/screenshots/s1/ablauf/08-ancient-faellt.jpg
new file mode 100644
index 0000000..ced5fb5
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/08-ancient-faellt.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/09-ancient-ruine.jpg b/docs/port/screenshots/s1/ablauf/09-ancient-ruine.jpg
new file mode 100644
index 0000000..a0f5661
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/09-ancient-ruine.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/10-ende-uebersicht.jpg b/docs/port/screenshots/s1/ablauf/10-ende-uebersicht.jpg
new file mode 100644
index 0000000..6170696
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/10-ende-uebersicht.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/11-welle21-uebersicht.jpg b/docs/port/screenshots/s1/ablauf/11-welle21-uebersicht.jpg
new file mode 100644
index 0000000..770898b
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/11-welle21-uebersicht.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/12-welle21-kampf.jpg b/docs/port/screenshots/s1/ablauf/12-welle21-kampf.jpg
new file mode 100644
index 0000000..839a4a4
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/12-welle21-kampf.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/13-ancient-rot-faellt.jpg b/docs/port/screenshots/s1/ablauf/13-ancient-rot-faellt.jpg
new file mode 100644
index 0000000..ba15670
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/13-ancient-rot-faellt.jpg differ
diff --git a/docs/port/screenshots/s1/ablauf/14-welle-ende.jpg b/docs/port/screenshots/s1/ablauf/14-welle-ende.jpg
new file mode 100644
index 0000000..62fcac0
Binary files /dev/null and b/docs/port/screenshots/s1/ablauf/14-welle-ende.jpg differ
diff --git a/docs/port/screenshots/s1/spiel/00-startbildschirm-regeln.jpg b/docs/port/screenshots/s1/spiel/00-startbildschirm-regeln.jpg
new file mode 100644
index 0000000..90a5304
Binary files /dev/null and b/docs/port/screenshots/s1/spiel/00-startbildschirm-regeln.jpg differ
diff --git a/docs/port/screenshots/s1/spiel/01-planung-einkommen-kaserne-mondsegen.jpg b/docs/port/screenshots/s1/spiel/01-planung-einkommen-kaserne-mondsegen.jpg
new file mode 100644
index 0000000..311ead3
Binary files /dev/null and b/docs/port/screenshots/s1/spiel/01-planung-einkommen-kaserne-mondsegen.jpg differ
diff --git a/docs/port/screenshots/s1/spiel/02-kaserne-tooltip.jpg b/docs/port/screenshots/s1/spiel/02-kaserne-tooltip.jpg
new file mode 100644
index 0000000..1d3f6ea
Binary files /dev/null and b/docs/port/screenshots/s1/spiel/02-kaserne-tooltip.jpg differ
diff --git a/docs/port/screenshots/s1/spiel/03-rueckkauf.jpg b/docs/port/screenshots/s1/spiel/03-rueckkauf.jpg
new file mode 100644
index 0000000..e0fe274
Binary files /dev/null and b/docs/port/screenshots/s1/spiel/03-rueckkauf.jpg differ
diff --git a/docs/port/screenshots/s1/spiel/04-verstaerkte-welle-mid.jpg b/docs/port/screenshots/s1/spiel/04-verstaerkte-welle-mid.jpg
new file mode 100644
index 0000000..4847e0e
Binary files /dev/null and b/docs/port/screenshots/s1/spiel/04-verstaerkte-welle-mid.jpg differ
diff --git a/tools/dotnet/Luneraria.Balance/Program.cs b/tools/dotnet/Luneraria.Balance/Program.cs
index 25429cb..bfb6261 100644
--- a/tools/dotnet/Luneraria.Balance/Program.cs
+++ b/tools/dotnet/Luneraria.Balance/Program.cs
@@ -17,6 +17,8 @@ public static class Program
public static int Main(string[] args)
{
+ if (args.Length > 0 && args[0] == "s1")
+ return RulesComparison(args.Length > 1 ? int.Parse(args[1], CultureInfo.InvariantCulture) : 60);
int seeds = args.Length > 0 ? int.Parse(args[0], CultureInfo.InvariantCulture) : 40;
BotProfile reference = ContentData.BotProfiles["ausgewogen"];
foreach (string hero in Heroes)
@@ -69,4 +71,61 @@ public static class Program
}
return 0;
}
+
+ ///
+ /// S1: plain bot matches (seed rotation of profiles) under reference and extended rules:
+ /// match length, winners, how often each extended mechanic acts, and how often the team that
+ /// first took a tower also won (snowball).
+ ///
+ static int RulesComparison(int seeds)
+ {
+ Console.WriteLine("rules; matches; blue wins; red wins; draws; open; mean rounds; min; max; first tower wins; champion income/seat; barracks waves; exhausted heroes; buybacks; blessings; sylvara seats; sylvara wins");
+ foreach (bool extended in new[] { false, true })
+ {
+ int blue = 0, red = 0, draws = 0, open = 0, rounds = 0, min = 999, max = 0, firstTowerWins = 0, firstTowerGames = 0;
+ long income = 0; int barracks = 0, exhausted = 0, buybacks = 0, blessings = 0, sylvaraSeats = 0, sylvaraWins = 0;
+ for (int seed = 1; seed <= seeds; seed++)
+ {
+ var match = new LuneMatch();
+ match.Setup(new MatchOptions { Seed = seed, HumanSlot = -1, Extended = extended });
+ int firstTower = -1;
+ while (match.Phase != Phase.Over && match.RoundNumber <= 80)
+ {
+ for (int slot = 0; slot < 6; slot++)
+ {
+ bool wasExhausted = match.Players[slot].Hero != null && match.Players[slot].Hero.Exhausted;
+ LuneBot.Plan(match, slot);
+ if (wasExhausted && match.Players[slot].Draft.Hero != null && !match.Players[slot].Draft.Hero.Exhausted)
+ buybacks++;
+ }
+ for (int team = 0; team < 2; team++)
+ foreach (string lane in LuneMap.Lanes)
+ if (extended && match.BarracksBroken(team, lane)) barracks++;
+ match.BeginCombat();
+ match.FinishCombat();
+ foreach (Player player in match.Players)
+ if (player.Hero != null && player.Hero.Exhausted) exhausted++;
+ if (firstTower < 0 && match.LastSettlement != null && match.LastSettlement.BuildingsLost.Count > 0)
+ firstTower = match.LastSettlement.BuildingsLost[0].StartsWith("rot") ? 0 : 1;
+ }
+ int length = match.RoundNumber;
+ rounds += length; min = Math.Min(min, length); max = Math.Max(max, length);
+ if (match.Phase != Phase.Over) open++;
+ else if (match.Winner == 0) blue++;
+ else if (match.Winner == 1) red++;
+ else draws++;
+ if (firstTower >= 0 && match.Phase == Phase.Over) { firstTowerGames++; if (match.Winner == firstTower) firstTowerWins++; }
+ foreach (Player player in match.Players)
+ {
+ income += player.IncomeBonus;
+ if (player.Hero != null && player.Hero.HeroId == "held_sylvara") { sylvaraSeats++; if (match.Winner == player.Team) sylvaraWins++; }
+ }
+ blessings += match.Boss.Blessing[0] + match.Boss.Blessing[1];
+ }
+ Console.WriteLine(string.Format(CultureInfo.InvariantCulture, "{0}; {1}; {2}; {3}; {4}; {5}; {6:F1}; {7}; {8}; {9}/{10}; {11:F1}; {12}; {13}; {14}; {15}; {16}; {17}",
+ extended ? "erweitert" : "referenz", seeds, blue, red, draws, open, (double)rounds / seeds, min, max, firstTowerWins, firstTowerGames,
+ (double)income / (seeds * 6), barracks, exhausted, buybacks, blessings, sylvaraSeats, sylvaraWins));
+ }
+ return 0;
+ }
}