diff --git a/Assets/Luneraria/Core/Content/ContentData.cs b/Assets/Luneraria/Core/Content/ContentData.cs
index 99e1127..4d4d2a5 100644
--- a/Assets/Luneraria/Core/Content/ContentData.cs
+++ b/Assets/Luneraria/Core/Content/ContentData.cs
@@ -126,9 +126,17 @@ namespace Luneraria.Core
new ItemSpec { Id = "rankenmantel", Name = "Rankenmantel", Cost = 200, Text = "+10 % Schadensminderung", Reduction = 0.1 },
new ItemSpec { Id = "funkenfokus", Name = "Funkenfokus", Cost = 200, Text = "+15 % Angriffstempo", AttackSpeed = 0.15 },
new ItemSpec { Id = "quellamulett", Name = "Quellamulett", Cost = 220, Text = "Regeneriert 1,5 % der Lebenspunkte pro Sekunde", Regen = 0.015 },
- new ItemSpec { Id = "kristallsplitter", Name = "Kristallsplitter", Cost = 260, Text = "Jeder Treffer: 25 % an bis zu 2 Gegner in 1,5 m · ab Stufe 3", Splash = 0.25, MinLevel = 3 }
+ new ItemSpec { Id = "kristallsplitter", Name = "Kristallsplitter", Cost = 260, Text = "Jeder Treffer: 25 % an bis zu 2 Gegner in 1,5 m · ab Stufe 3", Splash = 0.25, MinLevel = 3 },
+ // S2 recipes (extended rules only; Dota 2 / League of Legends): two items plus a fee become one stronger item.
+ new ItemSpec { Id = "bollwerk", Name = "Bollwerk", Cost = 500, Text = "Rezept: Basaltschild + Rankenmantel + 120 · +600 Lebenspunkte, +15 % Schadensminderung", HpFlat = 600.0, Reduction = 0.15, Parts = new[] { "basaltschild", "rankenmantel" }, RecipeFee = 120 },
+ new ItemSpec { Id = "glutsturm", Name = "Glutsturm", Cost = 520, Text = "Rezept: Glutklinge + Funkenfokus + 140 · +25 % Schaden, +22 % Angriffstempo", Damage = 0.25, AttackSpeed = 0.22, Parts = new[] { "glutklinge", "funkenfokus" }, RecipeFee = 140 },
+ new ItemSpec { Id = "quellherz", Name = "Quellherz", Cost = 520, Text = "Rezept: Quellamulett + Basaltschild + 120 · +450 Lebenspunkte, regeneriert 2,2 % pro Sekunde", HpFlat = 450.0, Regen = 0.022, Parts = new[] { "quellamulett", "basaltschild" }, RecipeFee = 120 },
+ new ItemSpec { Id = "splitterkrone", Name = "Splitterkrone", Cost = 600, Text = "Rezept: Kristallsplitter + Glutklinge + 160 · +20 % Schaden; jeder Treffer 35 % an bis zu 2 Gegner in 1,5 m · ab Stufe 5", Damage = 0.20, Splash = 0.35, MinLevel = 5, Parts = new[] { "kristallsplitter", "glutklinge" }, RecipeFee = 160 }
);
+ /// Items of the Godot reference; ContentTests compares exactly these, the S2 recipes follow them.
+ public static readonly string[] ReferenceItems = { "basaltschild", "glutklinge", "rankenmantel", "funkenfokus", "quellamulett", "kristallsplitter" };
+
public static readonly OrderedMap Champions = Ordered(
new ChampionSpec { Id = "ch_schutz", Name = "Rostschieber", RoleText = "Schutz", Model = "s02", Cost = 150, Base = "s02", Text = "Schildträger: startet mit Schild, gibt alle 6 s bis zu 4 Verbündeten in 4 m einen Schild von 120.", ViewScale = 1.25 },
new ChampionSpec { Id = "ch_druck", Name = "Dunstsänger", RoleText = "Druck auf Unterstützer", Model = "s03", Cost = 150, Base = "s03", Text = "Sucht Unterstützer und Fernkämpfer und verlangsamt deren Angriffe.", ViewScale = 1.25, Priority = "support" },
diff --git a/Assets/Luneraria/Core/Content/Definitions.cs b/Assets/Luneraria/Core/Content/Definitions.cs
index f41cdc5..ba5c224 100644
--- a/Assets/Luneraria/Core/Content/Definitions.cs
+++ b/Assets/Luneraria/Core/Content/Definitions.cs
@@ -105,6 +105,11 @@ namespace Luneraria.Core
public double Regen;
public double Splash;
public int MinLevel = 1;
+ /// S2 recipe items (extended rules): the two items it is made of and the fee on top; Cost is the sum.
+ public string[] Parts = new string[0];
+ public int RecipeFee;
+
+ public bool IsRecipe { get { return Parts.Length > 0; } }
}
public sealed class ChampionSpec
diff --git a/Assets/Luneraria/Core/Content/LuneBalance.cs b/Assets/Luneraria/Core/Content/LuneBalance.cs
index b627340..d458da4 100644
--- a/Assets/Luneraria/Core/Content/LuneBalance.cs
+++ b/Assets/Luneraria/Core/Content/LuneBalance.cs
@@ -76,8 +76,22 @@ namespace Luneraria.Core
/// Moon soul (all stacks): heroes regenerate this share of their health per second.
public double SoulRegen = 0.01;
+ // S2: extended rules.
+ /// Dota 2 glyph: a planned lane's towers turn invulnerable for this long once one falls below half health.
+ public double GlyphSeconds = 5.0;
+ public double GlyphThreshold = 0.5;
+ public int GlyphCooldown = 4;
+ /// Legion TD 2 king: Ancient levels bought by the team's players; price per level, attack and repair per level.
+ public int CoreLevelMax = 3;
+ public int CoreLevelPrice = 150;
+ public double[] CoreDamage = { 0.0, 45.0, 75.0, 110.0 };
+ public double CoreRange = 7.5;
+ public double CoreRepair = 1000.0;
+
public static LuneBalance Default() { return new LuneBalance(); }
+ public int CoreUpgradeCost(int level) { return CoreLevelPrice * (level + 1); }
+
public int BuybackCost(int level) { return BuybackBase + BuybackPerLevel * level; }
public int RoundIncome(int roundNumber)
diff --git a/Assets/Luneraria/Core/Net/LuneSession.cs b/Assets/Luneraria/Core/Net/LuneSession.cs
index e21440d..7befa8f 100644
--- a/Assets/Luneraria/Core/Net/LuneSession.cs
+++ b/Assets/Luneraria/Core/Net/LuneSession.cs
@@ -624,6 +624,10 @@ namespace Luneraria.Core
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();
+ if (source.GlyphReady != null && source.GlyphReady.Length == 2)
+ MatchState.Boss.GlyphReady = (int[])source.GlyphReady.Clone();
+ if (source.CoreLevel != null && source.CoreLevel.Length == 2)
+ MatchState.Boss.CoreLevel = (int[])source.CoreLevel.Clone();
}
public void ApplyLobby(List entries)
@@ -861,7 +865,9 @@ namespace Luneraria.Core
NextAvailableRound = MatchState.Boss.NextAvailableRound,
SettledRound = MatchState.Boss.SettledRound,
Claim = MatchState.Boss.Claim != null ? MatchState.Boss.Claim.Clone() : null,
- Blessing = (int[])MatchState.Boss.Blessing.Clone()
+ Blessing = (int[])MatchState.Boss.Blessing.Clone(),
+ GlyphReady = (int[])MatchState.Boss.GlyphReady.Clone(),
+ CoreLevel = (int[])MatchState.Boss.CoreLevel.Clone()
};
if (MatchState.Combat != null)
state.DestroyedCores.AddRange(MatchState.Combat.DestroyedCores);
diff --git a/Assets/Luneraria/Core/Simulation/LuneBot.cs b/Assets/Luneraria/Core/Simulation/LuneBot.cs
index 1435ae2..74e6cbe 100644
--- a/Assets/Luneraria/Core/Simulation/LuneBot.cs
+++ b/Assets/Luneraria/Core/Simulation/LuneBot.cs
@@ -40,6 +40,9 @@ namespace Luneraria.Core
// Items and champions are planned before the army so the roster cannot drain the reserve.
PlanItems(match, slot, profile, 110);
PlanChampion(match, slot, profile);
+ // S2 team actions come before the army too, so a threatened base gets its Ancient level.
+ if (match.Extended)
+ PlanTeam(match, slot);
for (int guard = 0; guard < 40; guard++)
{
List roster = player.Draft.Roster;
@@ -90,9 +93,60 @@ namespace Luneraria.Core
}
}
+ ///
+ /// S2 team actions (extended rules only): hold the glyph for the own lane when one of its
+ /// towers is below 60 %, and raise the Ancient once the base is under threat.
+ ///
+ static void PlanTeam(LuneMatch match, int slot)
+ {
+ Player player = match.Players[slot];
+ if (match.RoundNumber >= match.Boss.GlyphReady[player.Team])
+ {
+ foreach (BuildingDef definition in match.Map.Buildings)
+ {
+ if (definition.Team == player.Team && definition.Lane == player.Lane && definition.Tier != "core"
+ && match.BuildingHp[definition.Id] > 0.0 && match.BuildingHp[definition.Id] < definition.MaxHp * 0.6)
+ {
+ match.Submit(slot, Command.Glyph(player.Lane));
+ break;
+ }
+ }
+ }
+ int level = match.Boss.CoreLevel[player.Team] + player.Draft.CoreInvest;
+ bool threatened = false;
+ foreach (BuildingDef definition in match.Map.Buildings)
+ {
+ if (definition.Team == player.Team && definition.Tier == "inner" && match.BuildingHp[definition.Id] <= 0.0)
+ threatened = true;
+ }
+ if (threatened && level < match.Balance.CoreLevelMax && player.Draft.Gold >= match.Balance.CoreUpgradeCost(level))
+ match.Submit(slot, Command.CoreUpgrade());
+ }
+
+ /// S2: both own towers of the player's lane stand above 70 % (an extra grotto hunter leaves only a safe lane).
+ static bool LaneSafe(LuneMatch match, Player player)
+ {
+ foreach (BuildingDef definition in match.Map.Buildings)
+ {
+ if (definition.Team == player.Team && definition.Lane == player.Lane && (definition.Tier == "outer" || definition.Tier == "inner")
+ && match.BuildingHp[definition.Id] < definition.MaxHp * 0.7)
+ return false;
+ }
+ return true;
+ }
+
static void PlanItems(LuneMatch match, int slot, BotProfile profile, int reserve)
{
HeroState hero = match.Players[slot].Draft.Hero;
+ // S2 recipes: combine owned parts first (frees a slot), one per planning.
+ if (hero != null && match.Extended)
+ {
+ foreach (ItemSpec recipe in ContentData.Items.Values)
+ {
+ if (recipe.IsRecipe && match.Players[slot].Draft.Gold >= recipe.RecipeFee + reserve && match.Submit(slot, Command.ItemBuy(recipe.Id)).Ok)
+ break;
+ }
+ }
if (hero == null || hero.Items.Count >= LuneContent.ItemSlots)
return;
foreach (string itemId in profile.Items)
@@ -148,14 +202,18 @@ namespace Luneraria.Core
string lane = WeakestLane(match, 1 - player.Team);
match.Submit(slot, Command.BossLaneChoice(lane));
}
- if (!profile.HuntsBoss || match.RoundNumber < profile.BossFrom || !match.GrottoAvailable())
+ // S2 (extended rules): mid players also hunt the boss from round 8, so the Mondsegen of S1 really happens.
+ bool hunts = profile.HuntsBoss || (match.Extended && LuneMap.LaneIndex(player.Lane) == 1 && LaneSafe(match, player));
+ int from = profile.HuntsBoss ? profile.BossFrom : 8;
+ int troops = profile.HuntsBoss ? profile.BossTroops : 5;
+ if (!hunts || match.RoundNumber < from || !match.GrottoAvailable())
return;
List roster = player.Draft.Roster;
- if (roster.Count < profile.BossTroops)
+ if (roster.Count < troops)
return;
var picks = new List(roster);
GdMath.SortStable(picks, (a, b) => a.Invested == b.Invested ? a.Seq < b.Seq : a.Invested > b.Invested);
- int keep = GdMath.Min(profile.BossTroops - 1, picks.Count);
+ int keep = GdMath.Min(troops - 1, picks.Count);
picks.RemoveRange(keep, picks.Count - keep);
HeroState hero = player.Draft.Hero;
// The boss formula is public. Lanchester estimate: total HP x total damage of the
diff --git a/Assets/Luneraria/Core/Simulation/LuneCombat.cs b/Assets/Luneraria/Core/Simulation/LuneCombat.cs
index 65507ad..3c763e5 100644
--- a/Assets/Luneraria/Core/Simulation/LuneCombat.cs
+++ b/Assets/Luneraria/Core/Simulation/LuneCombat.cs
@@ -56,6 +56,10 @@ namespace Luneraria.Core
public int GrottoActiveTeam = -1;
/// none, success or failed.
public string GrottoOutcome = "none";
+ /// S2 glyph per team: the held lane ("" none), when its towers stop being invulnerable, whether it fired.
+ public readonly string[] GlyphLane = { "", "" };
+ public readonly double[] GlyphUntil = { -1.0, -1.0 };
+ public readonly bool[] GlyphUsed = new bool[2];
int _nextId = 1;
readonly int[] _teamCount = new int[3];
@@ -291,7 +295,7 @@ namespace Luneraria.Core
}
foreach (LuneUnit unit in living)
{
- if (unit.IsMobile() || unit.Kind == "tower")
+ if (unit.IsMobile() || unit.Kind == "tower" || (unit.Kind == "core" && unit.Def.Damage > 0.0))
{
bool keep = unit.Target != null && unit.Target.Alive;
if (unit.LeapTarget != null)
@@ -350,10 +354,40 @@ namespace Luneraria.Core
RecordDeath(unit);
}
}
+ TriggerGlyphs();
UpdateGrotto();
CheckEnd();
}
+ /// S2: a tower of the glyph lane is invulnerable while its team's glyph runs.
+ public bool Glyphed(LuneUnit unit)
+ {
+ if (unit.Kind != "tower" || unit.Team > 1)
+ return false;
+ return GlyphLane[unit.Team].Length > 0 && unit.Lane == GlyphLane[unit.Team] && Elapsed < GlyphUntil[unit.Team] - 0.000001;
+ }
+
+ /// S2 (Dota 2 glyph): the first time a tower of the held lane is below the threshold, the glyph fires.
+ void TriggerGlyphs()
+ {
+ for (int team = 0; team < 2; team++)
+ {
+ if (GlyphLane[team].Length == 0 || GlyphUsed[team])
+ continue;
+ IReadOnlyList buildings = Buildings.Values;
+ for (int i = 0; i < buildings.Count; i++)
+ {
+ LuneUnit tower = buildings[i];
+ if (tower.Alive && tower.Kind == "tower" && tower.Team == team && tower.Lane == GlyphLane[team] && tower.Hp < tower.MaxHp * Balance.GlyphThreshold)
+ {
+ GlyphUsed[team] = true;
+ GlyphUntil[team] = Elapsed + Balance.GlyphSeconds;
+ break;
+ }
+ }
+ }
+ }
+
/// Expeditions fight each other first; the boss wakes when at most one team remains.
void UpdateGrotto()
{
@@ -463,11 +497,11 @@ namespace Luneraria.Core
{
if (!current.IsBuilding() || BuildingAttackable(current, gates))
{
- if (unit.Kind != "tower" || unit.Position.DistanceTo(current.Position) <= unit.AttackRange())
+ if (!unit.IsBuilding() || unit.Position.DistanceTo(current.Position) <= unit.AttackRange())
return current;
}
}
- double radius = unit.Kind == "tower" ? unit.AttackRange() : Balance.AggroRadius;
+ double radius = unit.IsBuilding() ? unit.AttackRange() : Balance.AggroRadius;
LuneUnit best = null;
double bestDistance = double.PositiveInfinity;
string priority = unit.Def.Priority;
@@ -505,7 +539,7 @@ namespace Luneraria.Core
bestDistance = d;
}
}
- if (best != null || unit.Kind == "tower")
+ if (best != null || unit.IsBuilding())
return best;
// Buildings: nearby attackable ones; at the end of a lane the whole base is in reach.
for (int i = 0; i < buildings.Count; i++)
@@ -647,7 +681,7 @@ namespace Luneraria.Core
{
unit.InRange = false;
LuneUnit target = unit.Target;
- if (target == null || !target.Alive || unit.Kind == "core")
+ if (target == null || !target.Alive || (unit.Kind == "core" && unit.Def.Damage <= 0.0))
{
unit.AttackProgress = 0.0;
return;
@@ -664,7 +698,7 @@ namespace Luneraria.Core
return;
}
unit.InRange = true;
- double interval = unit.Kind == "tower" ? Balance.TowerAttackInterval : 1.0;
+ double interval = unit.IsBuilding() ? Balance.TowerAttackInterval : 1.0;
unit.AttackProgress += dt * unit.AttackSpeedFactor() / interval;
while (unit.AttackProgress >= 1.0 - 0.000001)
{
@@ -704,6 +738,8 @@ namespace Luneraria.Core
// The building gate applies to every source: ranged, splash, DoT and abilities.
if (target.IsBuilding() && !BuildingAttackable(target, gates))
return;
+ if (Glyphed(target))
+ return;
double current;
_pendingDamage.TryGetValue(target.Id, out current);
_pendingDamage[target.Id] = current + amount;
diff --git a/Assets/Luneraria/Core/Simulation/LuneMatch.cs b/Assets/Luneraria/Core/Simulation/LuneMatch.cs
index d0f790d..91abc40 100644
--- a/Assets/Luneraria/Core/Simulation/LuneMatch.cs
+++ b/Assets/Luneraria/Core/Simulation/LuneMatch.cs
@@ -50,6 +50,8 @@ namespace Luneraria.Core
SeedValue = options.Seed;
Extended = options.Extended;
Boss.Blessing = new int[2];
+ Boss.GlyphReady = new[] { 1, 1 };
+ Boss.CoreLevel = new int[2];
RoundNumber = 1;
Phase = Phase.Planning;
Winner = Ongoing;
@@ -255,6 +257,8 @@ namespace Luneraria.Core
ItemSpec item;
if (!ContentData.Items.TryGetValue(itemId, out item))
return Result(false, "Unbekanntes Item.");
+ if (item.IsRecipe)
+ return Combine(hero, draft, item);
if (hero.Items.Contains(itemId))
return Result(false, item.Name + " trägt dein Held bereits; Items sind einzigartig.");
if (hero.Items.Count >= LuneContent.ItemSlots)
@@ -320,6 +324,32 @@ namespace Luneraria.Core
hero.Skin = skin;
return Result(true, skin.Length == 0 ? "Standard-Aussehen gewählt." : "Skin gewählt.");
}
+ case "glyph":
+ {
+ if (!Extended)
+ return Result(false, "Die Glyphe gibt es nur mit erweiterten Regeln.");
+ string lane = command.Lane ?? "";
+ if (lane.Length > 0 && LuneMap.LaneIndex(lane) < 0)
+ return Result(false, "Unbekannte Lane.");
+ if (lane.Length > 0 && RoundNumber < Boss.GlyphReady[player.Team])
+ return Result(false, string.Format("Die Glyphe ist ab Runde {0} wieder bereit.", Boss.GlyphReady[player.Team]));
+ draft.GlyphLane = lane;
+ return Result(true, lane.Length == 0 ? "Glyphe zurückgenommen." : "Glyphe für " + LuneMap.LaneNames[lane] + " bereitgehalten.");
+ }
+ case "core_upgrade":
+ {
+ if (!Extended)
+ return Result(false, "Ancient-Stufen gibt es nur mit erweiterten Regeln.");
+ int level = Boss.CoreLevel[player.Team] + draft.CoreInvest;
+ if (level >= Balance.CoreLevelMax)
+ return Result(false, "Der Ancient hat die höchste Stufe.");
+ int cost = Balance.CoreUpgradeCost(level);
+ if (draft.Gold < cost)
+ return Result(false, string.Format("Es fehlen {0} Gold.", cost - draft.Gold));
+ draft.Gold -= cost;
+ draft.CoreInvest += 1;
+ return Result(true, string.Format("Ancient auf Stufe {0} gestärkt ({1} Gold).", level + 1, cost));
+ }
case "buyback":
{
if (!Extended)
@@ -459,11 +489,27 @@ namespace Luneraria.Core
claim.Lane = draft.BossLane;
player.Undo.Clear();
}
+ if (Extended)
+ CommitTeamActions();
Combat = new LuneCombat(Map, Balance, RoundNumber);
foreach (BuildingDef definition in Map.Buildings)
{
if (BuildingHp[definition.Id] > 0.0)
- Combat.AddBuilding(definition, BuildingHp[definition.Id]);
+ {
+ LuneUnit building = Combat.AddBuilding(definition, BuildingHp[definition.Id]);
+ int level = Extended && definition.Tier == "core" ? Boss.CoreLevel[definition.Team] : 0;
+ if (level > 0)
+ {
+ // S2 (Legion TD 2 king): the Ancient shoots at attackers in its reach.
+ building.Def.Damage = Balance.CoreDamage[GdMath.Min(level, Balance.CoreDamage.Length - 1)];
+ building.Def.AttackRange = Balance.CoreRange;
+ }
+ }
+ }
+ if (Extended)
+ {
+ for (int team = 0; team < 2; team++)
+ Combat.GlyphLane[team] = _glyphLane[team];
}
SpawnWaves();
SpawnChampions();
@@ -530,6 +576,70 @@ namespace Luneraria.Core
}
}
+ // ---------------------------------------------------------------- S2 extended rules
+ readonly string[] _glyphLane = { "", "" };
+
+ ///
+ /// Team actions of the drafts (S2): the glyph lane of the first player in slot order who
+ /// holds one, and the Ancient levels all players of a team bought (capped; the excess is
+ /// refunded). Each level repairs the Ancient by CoreRepair.
+ ///
+ void CommitTeamActions()
+ {
+ for (int team = 0; team < 2; team++)
+ {
+ _glyphLane[team] = "";
+ for (int slot = team * 3; slot < team * 3 + 3; slot++)
+ {
+ Player player = Players[slot];
+ if (_glyphLane[team].Length == 0 && player.Draft.GlyphLane.Length > 0 && RoundNumber >= Boss.GlyphReady[team])
+ _glyphLane[team] = player.Draft.GlyphLane;
+ for (int i = 0; i < player.Draft.CoreInvest; i++)
+ {
+ int level = Boss.CoreLevel[team];
+ if (level >= Balance.CoreLevelMax)
+ {
+ player.Gold += Balance.CoreUpgradeCost(level - 1);
+ continue;
+ }
+ Boss.CoreLevel[team] = level + 1;
+ foreach (BuildingDef definition in Map.Buildings)
+ {
+ if (definition.Team == team && definition.Tier == "core" && BuildingHp[definition.Id] > 0.0)
+ BuildingHp[definition.Id] = GdMath.Min(definition.MaxHp, BuildingHp[definition.Id] + Balance.CoreRepair);
+ }
+ }
+ }
+ }
+ }
+
+ /// S2 recipe (Dota 2 / League of Legends): both parts and the fee become the recipe item, freeing a slot.
+ CommandResult Combine(HeroState hero, Draft draft, ItemSpec item)
+ {
+ if (!Extended)
+ return Result(false, "Rezepte gibt es nur mit erweiterten Regeln.");
+ if (hero.Items.Contains(item.Id))
+ return Result(false, item.Name + " trägt dein Held bereits; Items sind einzigartig.");
+ foreach (string part in item.Parts)
+ {
+ if (!hero.Items.Contains(part))
+ return Result(false, item.Name + " braucht " + string.Join(" und ", Array.ConvertAll(item.Parts, p => ContentData.Items[p].Name)) + ".");
+ }
+ if (LuneContent.LevelFor(hero.Xp) < item.MinLevel)
+ return Result(false, string.Format("{0} erfordert Stufe {1}.", item.Name, item.MinLevel));
+ if (draft.Gold < item.RecipeFee)
+ return Result(false, string.Format("Es fehlen {0} Gold.", item.RecipeFee - draft.Gold));
+ draft.Gold -= item.RecipeFee;
+ int at = hero.Items.IndexOf(item.Parts[0]);
+ foreach (string part in item.Parts)
+ {
+ hero.Items.Remove(part);
+ hero.FreshItems.Remove(part);
+ }
+ hero.Items.Insert(GdMath.Min(at, hero.Items.Count), item.Id);
+ return Result(true, item.Name + " geschmiedet.");
+ }
+
// ---------------------------------------------------------------- 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); }
@@ -810,6 +920,12 @@ namespace Luneraria.Core
}
if (Extended)
{
+ // S2 (Dota 2 glyph): only a glyph that fired goes on cooldown.
+ for (int team = 0; team < 2; team++)
+ {
+ if (Combat.GlyphUsed[team])
+ Boss.GlyphReady[team] = RoundNumber + 1 + Balance.GlyphCooldown;
+ }
// S1 (Dota 2 buyback, adapted): a hero that fell is exhausted for the next combat.
foreach (LuneUnit unit in Combat.Units)
{
@@ -894,6 +1010,7 @@ namespace Luneraria.Core
// 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);
+ // (S2 glyph cooldowns are settled in Settle.)
result = "won";
}
else if (Combat.GrottoOutcome == "failed")
diff --git a/Assets/Luneraria/Core/Simulation/MatchModel.cs b/Assets/Luneraria/Core/Simulation/MatchModel.cs
index 666dffa..d285c27 100644
--- a/Assets/Luneraria/Core/Simulation/MatchModel.cs
+++ b/Assets/Luneraria/Core/Simulation/MatchModel.cs
@@ -75,10 +75,14 @@ namespace Luneraria.Core
public List NewBookings = new List();
/// Lane chosen by the boss chooser in this planning, "" if unchanged.
public string BossLane = "";
+ /// S2: lane the player holds the team glyph ready for, "" = none.
+ public string GlyphLane = "";
+ /// S2: Ancient levels this player buys in this planning.
+ public int CoreInvest;
public Draft Clone()
{
- var copy = new Draft { Gold = Gold, NextSeq = NextSeq, BossLane = BossLane, Hero = Hero != null ? Hero.Clone() : null };
+ var copy = new Draft { Gold = Gold, NextSeq = NextSeq, BossLane = BossLane, GlyphLane = GlyphLane, CoreInvest = CoreInvest, Hero = Hero != null ? Hero.Clone() : null };
foreach (RosterEntry entry in Roster)
copy.Roster.Add(entry.Clone());
foreach (Booking booking in NewBookings)
@@ -130,6 +134,10 @@ namespace Luneraria.Core
public int SettledRound;
/// S1 (extended rules): Mondsegen stacks per team from grotto victories (League of Legends dragon souls).
public int[] Blessing = new int[2];
+ /// S2 (extended rules): first round each team's glyph can be held again (Dota 2).
+ public int[] GlyphReady = { 1, 1 };
+ /// S2 (extended rules): Ancient level per team, 0..3 (Legion TD 2 king upgrades).
+ public int[] CoreLevel = new int[2];
}
public sealed class BossLogEntry
@@ -254,5 +262,9 @@ namespace Luneraria.Core
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" }; }
+ /// S2: hold the team glyph for a lane in the coming combat ("" withdraws it).
+ public static Command Glyph(string lane) { return new Command { Type = "glyph", Lane = lane }; }
+ /// S2: buy the next Ancient level for the team.
+ public static Command CoreUpgrade() { return new Command { Type = "core_upgrade" }; }
}
}
diff --git a/Assets/Luneraria/Net/LuneWire.cs b/Assets/Luneraria/Net/LuneWire.cs
index e5734fe..cccf188 100644
--- a/Assets/Luneraria/Net/LuneWire.cs
+++ b/Assets/Luneraria/Net/LuneWire.cs
@@ -226,6 +226,11 @@ namespace Luneraria.Net
}
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);
+ for (int team = 0; team < 2; team++)
+ {
+ Int(text, boss.GlyphReady != null && boss.GlyphReady.Length > team ? boss.GlyphReady[team] : 1);
+ Int(text, boss.CoreLevel != null && boss.CoreLevel.Length > team ? boss.CoreLevel[team] : 0);
+ }
var cores = state.DestroyedCores ?? new List();
Int(text, cores.Count);
foreach (int core in cores)
@@ -278,6 +283,8 @@ namespace Luneraria.Net
Int(text, draft.Gold);
Int(text, draft.NextSeq);
Str(text, draft.BossLane ?? "");
+ Str(text, draft.GlyphLane ?? "");
+ Int(text, draft.CoreInvest);
WriteRosterList(text, draft.Roster);
Int(text, draft.Hero != null ? 1 : 0);
if (draft.Hero != null)
@@ -657,6 +664,11 @@ namespace Luneraria.Net
};
}
state.Boss.Blessing = new[] { Integer(), Integer() };
+ for (int team = 0; team < 2; team++)
+ {
+ state.Boss.GlyphReady[team] = Integer(1);
+ state.Boss.CoreLevel[team] = Integer();
+ }
int coreCount = Integer();
for (int i = 0; i < coreCount; i++)
state.DestroyedCores.Add(Integer());
@@ -705,7 +717,7 @@ namespace Luneraria.Net
row.Bookings = ReadBookingList();
if (Flag())
{
- var draft = new Draft { Gold = Integer(), NextSeq = Integer(), BossLane = Text() ?? "" };
+ var draft = new Draft { Gold = Integer(), NextSeq = Integer(), BossLane = Text() ?? "", GlyphLane = Text() ?? "", CoreInvest = Integer() };
draft.Roster = ReadRosterList();
if (Flag())
draft.Hero = ReadHero();
diff --git a/Assets/Luneraria/Tests/Core/CheckSuite.cs b/Assets/Luneraria/Tests/Core/CheckSuite.cs
index f425cd0..aac1dcf 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(), new S1RulesSuite() };
+ return new List { new ContentSuite(), new CoreRulesSuite(), new M2RulesSuite(), new GoldenSuite(), new DeterminismSuite(), new NetSuite(), new NetRoundsSuite(), new H1HeroSuite(), new S1RulesSuite(), new S2RulesSuite() };
}
}
}
diff --git a/Assets/Luneraria/Tests/Core/ContentSuite.cs b/Assets/Luneraria/Tests/Core/ContentSuite.cs
index 42ce134..37ef2a6 100644
--- a/Assets/Luneraria/Tests/Core/ContentSuite.cs
+++ b/Assets/Luneraria/Tests/Core/ContentSuite.cs
@@ -116,7 +116,10 @@ namespace Luneraria.Core.Tests
void CompareItems(OrderedMap expected)
{
- Check(expected.Keys.SequenceEqual(ContentData.Items.Keys), "items: same ids in the same order");
+ // S2 recipe items follow the reference items; those must match exactly.
+ Check(expected.Keys.SequenceEqual(ContentData.ReferenceItems) && ContentData.Items.Keys.Take(ContentData.ReferenceItems.Length).SequenceEqual(ContentData.ReferenceItems),
+ "items: same reference ids in the same order, recipes after them");
+ Check(ContentData.Items.Values.Skip(ContentData.ReferenceItems.Length).All(item => item.IsRecipe), "items after the reference are recipes (S2)");
foreach (KeyValuePair pair in expected)
{
var entry = MiniJson.Obj(pair.Value);
diff --git a/Assets/Luneraria/Tests/Core/S2RulesSuite.cs b/Assets/Luneraria/Tests/Core/S2RulesSuite.cs
new file mode 100644
index 0000000..4da9cce
--- /dev/null
+++ b/Assets/Luneraria/Tests/Core/S2RulesSuite.cs
@@ -0,0 +1,162 @@
+using System.Collections.Generic;
+using System.Linq;
+
+namespace Luneraria.Core.Tests
+{
+ ///
+ /// S2: glyph (Dota 2), item recipes (Dota 2 / League of Legends), Ancient levels (Legion TD 2
+ /// king) and the bots' grotto hunt under the extended rules; none of it under the reference rules.
+ ///
+ public sealed class S2RulesSuite : CheckSuite
+ {
+ public override string Name { get { return "s2"; } }
+
+ protected override void RunChecks()
+ {
+ TestReference();
+ TestGlyph();
+ TestRecipes();
+ TestCore();
+ TestBots();
+ }
+
+ static LuneMatch NewMatch(bool extended, int seed = 51)
+ {
+ 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 TestReference()
+ {
+ LuneMatch state = NewMatch(false);
+ state.Players[0].Draft.Gold += 3000;
+ Check(!state.Submit(0, Command.Glyph("mid")).Ok, "reference: no glyph");
+ Check(!state.Submit(0, Command.CoreUpgrade()).Ok, "reference: no Ancient levels");
+ state.Submit(0, Command.HeroBuy("held_ringborke"));
+ state.Submit(0, Command.ItemBuy("basaltschild"));
+ state.Submit(0, Command.ItemBuy("rankenmantel"));
+ Check(!state.Submit(0, Command.ItemBuy("bollwerk")).Ok, "reference: no recipes");
+ state.BeginCombat();
+ Check(state.Combat.Buildings.Values.All(b => b.Kind != "core" || b.Def.Damage == 0.0), "reference: the Ancients do not shoot");
+ }
+
+ void TestGlyph()
+ {
+ LuneMatch state = NewMatch(true);
+ Check(!state.Submit(0, Command.Glyph("river")).Ok, "unknown lane rejected");
+ Check(state.Submit(1, Command.Glyph("mid")).Ok && state.Players[1].Draft.GlyphLane == "mid", "a player holds the team glyph for a lane");
+ state.Submit(0, Command.Glyph("top"));
+ state.BeginCombat();
+ Check(state.Combat.GlyphLane[0] == "top", "the first seat in slot order decides the team glyph");
+ LuneUnit tower = state.Combat.Buildings["blau_top_outer"];
+ tower.Hp = tower.MaxHp * 0.4;
+ state.Combat.Step();
+ Check(state.Combat.GlyphUsed[0] && state.Combat.Glyphed(tower), "the glyph fires once a tower of the lane is below half health");
+ double before = tower.Hp;
+ var gates = state.Combat.AliveMap();
+ state.Combat.QueueDamage(tower, 500.0, gates);
+ Check(!state.Combat.HasPendingDamage(tower), "glyphed towers take no damage");
+ Check(!state.Combat.Glyphed(state.Combat.Buildings["blau_mid_outer"]), "other lanes are not protected");
+ for (int i = 0; i < 120; i++)
+ state.Combat.Step();
+ Check(!state.Combat.Glyphed(tower), "the glyph ends after 5 s");
+ state.FinishCombat();
+ Check(state.Boss.GlyphReady[0] == state.RoundNumber - 1 + 1 + 4, "a fired glyph returns after 4 rounds (" + state.Boss.GlyphReady[0] + ")");
+ Check(!state.Submit(0, Command.Glyph("top")).Ok, "on cooldown the glyph cannot be held");
+ Check(state.Boss.GlyphReady[1] == 1, "the other team's glyph is untouched");
+ LuneMatch idle = NewMatch(true);
+ idle.Submit(2, Command.Glyph("bot"));
+ idle.BeginCombat();
+ idle.FinishCombat();
+ Check(idle.Combat.GlyphUsed[0] || idle.Boss.GlyphReady[0] == 1, "a glyph that never fired costs no cooldown");
+ }
+
+ void TestRecipes()
+ {
+ LuneMatch state = NewMatch(true);
+ state.Players[0].Draft.Gold += 3000;
+ state.Submit(0, Command.HeroBuy("held_ringborke"));
+ Check(!state.Submit(0, Command.ItemBuy("bollwerk")).Ok, "a recipe needs its parts");
+ state.Submit(0, Command.ItemBuy("basaltschild"));
+ state.Submit(0, Command.ItemBuy("rankenmantel"));
+ state.Submit(0, Command.ItemBuy("glutklinge"));
+ int gold = state.GoldOf(0);
+ Check(state.Submit(0, Command.ItemBuy("bollwerk")).Ok && state.GoldOf(0) == gold - 120, "combining costs the fee of 120");
+ HeroState hero = state.Players[0].Draft.Hero;
+ Check(hero.Items.SequenceEqual(new[] { "bollwerk", "glutklinge" }), "parts become the recipe item, a slot is free");
+ UnitDef definition = LuneContent.HeroDefinition(hero);
+ Check(GdMath.IsEqualApprox(definition.MaxHp, 2200.0 + 600.0) && GdMath.IsEqualApprox(definition.ReductionBonus, 0.15), "Bollwerk: +600 health, +15 % reduction");
+ Check(state.Undo(0).Ok && state.Players[0].Draft.Hero.Items.Count == 3, "combining can be undone");
+ state.Submit(0, Command.ItemBuy("bollwerk"));
+ Check(!state.Submit(0, Command.ItemBuy("splitterkrone")).Ok, "Splitterkrone needs its parts and level 5");
+ state.BeginCombat();
+ state.FinishCombat();
+ int sellGold = state.GoldOf(0);
+ Check(state.Submit(0, Command.ItemSell("bollwerk")).Ok && state.GoldOf(0) == sellGold + 250, "a recipe item sells for half its total (250)");
+ }
+
+ void TestCore()
+ {
+ LuneMatch state = NewMatch(true);
+ state.Players[0].Draft.Gold += 3000;
+ state.Players[1].Draft.Gold += 3000;
+ state.BuildingHp["blau_ancient"] = 5000.0;
+ int gold = state.GoldOf(0);
+ Check(state.Submit(0, Command.CoreUpgrade()).Ok && state.GoldOf(0) == gold - 150, "the first Ancient level costs 150");
+ Check(state.Submit(0, Command.CoreUpgrade()).Ok && state.GoldOf(0) == gold - 450, "the second costs 300");
+ Check(state.Submit(1, Command.CoreUpgrade()).Ok && state.Submit(1, Command.CoreUpgrade()).Ok, "a teammate buys too (priced by the committed level)");
+ state.BeginCombat();
+ Check(state.Boss.CoreLevel[0] == 3 && state.Boss.CoreLevel[1] == 0, "levels are capped at 3 for the team");
+ Check(state.Players[1].Gold > 0, "the teammate's excess level is refunded");
+ LuneUnit core = state.Combat.Buildings["blau_ancient"];
+ Check(GdMath.IsEqualApprox(core.Def.Damage, 110.0) && GdMath.IsEqualApprox(core.Def.AttackRange, 7.5), "level 3: the Ancient shoots 110 in 7.5 m");
+ Check(GdMath.IsEqualApprox(core.Hp, 8000.0), "each level repaired 1,000 (5,000 → 8,000)");
+ Check(!state.Submit(0, Command.CoreUpgrade()).Ok, "no commands in combat");
+ state.FinishCombat();
+ Check(!state.Submit(0, Command.CoreUpgrade()).Ok, "no level above 3");
+ }
+
+ void TestBots()
+ {
+ int hunts = 0;
+ int blessings = 0;
+ int combines = 0;
+ int glyphs = 0;
+ int cores = 0;
+ string first = "";
+ for (int seed = 1; seed <= 12; seed++)
+ {
+ LuneMatch match = NewMatch(true, seed);
+ while (match.Phase != Phase.Over && match.RoundNumber <= 80)
+ {
+ for (int slot = 0; slot < 6; slot++)
+ LuneBot.Plan(match, slot);
+ if (match.Players.Any(p => p.Draft.Roster.Any(r => r.Lane == LuneMatch.Grotto)))
+ hunts++;
+ match.BeginCombat();
+ match.FinishCombat();
+ glyphs += match.Combat.GlyphUsed.Count(used => used);
+ }
+ blessings += match.Boss.Blessing[0] + match.Boss.Blessing[1];
+ cores += match.Boss.CoreLevel[0] + match.Boss.CoreLevel[1];
+ combines += match.Players.Count(p => p.Hero != null && p.Hero.Items.Any(id => ContentData.Items[id].IsRecipe));
+ if (seed == 1)
+ first = match.RoundNumber + "/" + match.Winner;
+ }
+ Check(hunts > 0 && blessings > 0, "extended bots go to the grotto and win Mondsegen (" + hunts + " expeditions, " + blessings + " stacks in 12 matches)");
+ // Bots recruit heroes late (reference bot behaviour), so recipes rarely come up in whole
+ // matches; a prepared planning shows that a bot forges one when it can.
+ LuneMatch forge = NewMatch(true, 3);
+ Player bot = forge.Players[0];
+ bot.Hero = new HeroState { Uid = "h0-9", Seq = 9, HeroId = "held_ringborke", Lane = bot.Lane, Xp = 300, Invested = 260, Items = new List { "basaltschild", "rankenmantel" } };
+ bot.Gold = 1200;
+ bot.Draft = forge.Snapshot(bot);
+ LuneBot.Plan(forge, 0);
+ Check(bot.Draft.Hero.Items.Contains("bollwerk"), "a bot forges Bollwerk from its parts (" + string.Join("+", bot.Draft.Hero.Items) + ")");
+ Check(glyphs > 0, "extended bots fire glyphs (" + glyphs + ")");
+ Note("12 extended matches: expeditions " + hunts + ", Mondsegen " + blessings + ", recipe heroes " + combines + ", glyphs " + glyphs + ", Ancient levels " + cores + "; seed 1 " + first);
+ }
+ }
+}
diff --git a/Assets/Luneraria/Tests/Core/S2RulesSuite.cs.meta b/Assets/Luneraria/Tests/Core/S2RulesSuite.cs.meta
new file mode 100644
index 0000000..ed3754e
--- /dev/null
+++ b/Assets/Luneraria/Tests/Core/S2RulesSuite.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 04183de557804f08aac74a3d73fe5f3c
diff --git a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs
index a818111..ad9812d 100644
--- a/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs
+++ b/Assets/Luneraria/Tests/Core/Unity/CoreSuiteTests.cs
@@ -53,5 +53,6 @@ namespace Luneraria.Core.Tests
[Test] public void NetRounds() { RunSuite(new NetRoundsSuite()); }
[Test] public void H1Heroes() { RunSuite(new H1HeroSuite()); }
[Test] public void S1Rules() { RunSuite(new S1RulesSuite()); }
+ [Test] public void S2Rules() { RunSuite(new S2RulesSuite()); }
}
}
diff --git a/Assets/Luneraria/Tests/UI/PlayMode/HudVerifyTests.cs b/Assets/Luneraria/Tests/UI/PlayMode/HudVerifyTests.cs
index 7a74633..a275cb9 100644
--- a/Assets/Luneraria/Tests/UI/PlayMode/HudVerifyTests.cs
+++ b/Assets/Luneraria/Tests/UI/PlayMode/HudVerifyTests.cs
@@ -205,7 +205,8 @@ namespace Luneraria.UI.Tests
// Items: buy one, the card turns unique; slots fill up; higher items need levels.
yield return s.Tap(() => s.Hud.HeroDetail.Tabs.Tabs[LuneHeroDetail.TabItems]);
Dictionary cards = s.Hud.HeroDetail.ItemCards;
- Check(cards.Count == LuneContent.Items.Count, "item shop lists every item");
+ // S2: recipes only under the extended rules; the verify plays the reference rules.
+ Check(cards.Count == LuneContent.Items.Values.Count(item => !item.IsRecipe || s.Match.Extended), "item shop lists every item of the rules in play");
string first = cards.First(c => c.Value.Current == LuneItemSlot.State.Available).Key;
yield return s.Tap(() => cards[first]);
Check(s.Model.OwnHero.Items.Contains(first), "item bought by click");
diff --git a/Assets/Luneraria/Tests/UI/PlayMode/S2RulesViewsTests.cs b/Assets/Luneraria/Tests/UI/PlayMode/S2RulesViewsTests.cs
new file mode 100644
index 0000000..7e848b1
--- /dev/null
+++ b/Assets/Luneraria/Tests/UI/PlayMode/S2RulesViewsTests.cs
@@ -0,0 +1,130 @@
+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
+{
+ ///
+ /// S2 in the real scene: the team actions (glyph lane, Ancient level) in the team panel, the
+ /// recipe in the hero window's item shop, the glyph ring on an invulnerable tower and a
+ /// levelled Ancient in combat. S2Bilder (explicit) captures them at 1920×1080;
+ /// LUNE_EVIDENCE names the folder below docs/port/screenshots (default s2/spiel).
+ ///
+ public sealed class S2RulesViewsTests
+ {
+ 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 Plan(HudSession s)
+ {
+ Assert.IsTrue(s.Match.Extended, "extended match");
+ yield return s.Starter();
+ s.Leave();
+ Send(s, Command.HeroBuy("held_ringborke"));
+ Send(s, Command.ItemBuy("basaltschild"));
+ Send(s, Command.ItemBuy("rankenmantel"));
+ // Public fixture: Blau's outer mid tower is already damaged.
+ s.Match.BuildingHp["blau_mid_outer"] = s.Match.Map.BuildingDef("blau_mid_outer").MaxHp * 0.55;
+ s.Sync();
+ }
+
+ static GameObject Ring(string id)
+ {
+ return UnityEngine.Object.FindObjectsByType(FindObjectsInactive.Include, FindObjectsSortMode.None)
+ .Where(t => t.name == "GlyphRing_" + id).Select(t => t.gameObject).FirstOrDefault();
+ }
+
+ [UnityTest, Timeout(600000)]
+ public IEnumerator S2Hud()
+ {
+ var s = new HudSession();
+ yield return s.Open(1920, 1080, seed: 4, startRound: 6, extraGold: 3000, extended: true);
+ yield return Plan(s);
+ Assert.IsTrue(HudSession.Visible(s.Hud.Team.CoreButton), "team actions are visible under the extended rules");
+ s.Click(s.Hud.Team.GlyphButtons["mid"]);
+ yield return HudSession.Frames(2);
+ s.Sync();
+ Assert.AreEqual("mid", s.Me.Draft.GlyphLane, "the mid button holds the glyph");
+ Assert.IsTrue(s.Hud.Team.GlyphButtons["mid"].On, "the button shows it");
+ int gold = s.Me.Draft.Gold;
+ s.Click(s.Hud.Team.CoreButton);
+ yield return HudSession.Frames(2);
+ s.Sync();
+ Assert.AreEqual(1, s.Me.Draft.CoreInvest, "the Ancient button buys a level");
+ Assert.AreEqual(gold - 150, s.Me.Draft.Gold, "for 150 gold");
+ s.Hud.OpenHero(LuneHeroDetail.TabItems);
+ yield return HudSession.Frames(2);
+ s.Sync();
+ Assert.IsTrue(s.Hud.HeroDetail.ItemCards.ContainsKey("bollwerk"), "recipes appear in the shop");
+ Assert.AreEqual(LuneItemSlot.State.Available, s.Hud.HeroDetail.ItemCards["bollwerk"].Current, "Bollwerk can be forged");
+ Assert.AreEqual(LuneItemSlot.State.MissingParts, s.Hud.HeroDetail.ItemCards["glutsturm"].Current, "Glutsturm lacks its parts");
+ s.Click(s.Hud.HeroDetail.ItemCards["bollwerk"]);
+ yield return HudSession.Frames(2);
+ s.Sync();
+ CollectionAssert.AreEqual(new[] { "bollwerk" }, s.Me.Draft.Hero.Items, "the shop card forges Bollwerk");
+ s.Hud.CloseModal();
+ s.Game.Key(KeyCode.Space);
+ yield return HudSession.Frames(2);
+ Assert.AreEqual("mid", s.Match.Combat.GlyphLane[0], "Blau holds the glyph on mid");
+ LuneUnit core = s.Match.Combat.Buildings["blau_ancient"];
+ Assert.AreEqual(45.0, core.Def.Damage, 1e-9, "the Ancient shoots at level 1");
+ LuneUnit tower = s.Match.Combat.Buildings["blau_mid_outer"];
+ tower.Hp = tower.MaxHp * .45;
+ s.Runner.Advance(.05);
+ yield return HudSession.Frames(3);
+ Assert.IsTrue(s.Match.Combat.Glyphed(tower), "the glyph fires below half health");
+ GameObject ring = Ring("blau_mid_outer");
+ Assert.IsTrue(ring != null && ring.activeSelf, "the tower shows the glyph ring");
+ s.Close();
+ }
+
+ [UnityTest, Timeout(600000), Explicit("Bildwerkzeug: nur gezielt per -testFilter")]
+ public IEnumerator S2Bilder()
+ {
+ string evidence = Environment.GetEnvironmentVariable("LUNE_EVIDENCE");
+ string folder = "docs/port/screenshots/" + (string.IsNullOrEmpty(evidence) ? "s2/spiel" : evidence) + "/";
+ var s = new HudSession();
+ yield return s.Open(1920, 1080, seed: 4, startRound: 6, extraGold: 3000, extended: true);
+ yield return Plan(s);
+ Send(s, Command.Glyph("mid"));
+ Send(s, Command.CoreUpgrade());
+ yield return new WaitForSecondsRealtime(.5f);
+ yield return s.Shot(folder + "01-team-aktionen.png");
+ s.Hover(s.Hud.Team.CoreButton);
+ yield return new WaitForSecondsRealtime(.3f);
+ yield return s.Shot(folder + "02-ancient-tooltip.png");
+ s.Leave();
+ s.Hud.OpenHero(LuneHeroDetail.TabItems);
+ yield return new WaitForSecondsRealtime(.6f);
+ s.Hover(s.Hud.HeroDetail.ItemCards["bollwerk"]);
+ yield return new WaitForSecondsRealtime(.3f);
+ yield return s.Shot(folder + "03-rezept-im-shop.png");
+ Send(s, Command.ItemBuy("bollwerk"));
+ s.Leave();
+ yield return new WaitForSecondsRealtime(.4f);
+ yield return s.Shot(folder + "04-bollwerk-geschmiedet.png");
+ s.Hud.CloseModal();
+ s.Game.Key(KeyCode.Space);
+ yield return HudSession.Frames(2);
+ LuneUnit tower = s.Match.Combat.Buildings["blau_mid_outer"];
+ tower.Hp = tower.MaxHp * .45;
+ s.Runner.Advance(.05);
+ yield return s.Fight(1.0);
+ s.Driver.Paused = true;
+ s.Rig.LookAt(tower.Position, 14f, true);
+ yield return new WaitForSecondsRealtime(.5f);
+ yield return s.Shot(folder + "05-glyphe-am-turm.png");
+ s.Close();
+ }
+ }
+}
diff --git a/Assets/Luneraria/Tests/UI/PlayMode/S2RulesViewsTests.cs.meta b/Assets/Luneraria/Tests/UI/PlayMode/S2RulesViewsTests.cs.meta
new file mode 100644
index 0000000..968934a
--- /dev/null
+++ b/Assets/Luneraria/Tests/UI/PlayMode/S2RulesViewsTests.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: f22ed633e8804b2f85703375622e0e99
diff --git a/Assets/Luneraria/UI/Hud/LuneComponents.cs b/Assets/Luneraria/UI/Hud/LuneComponents.cs
index 50f3ba2..242eb92 100644
--- a/Assets/Luneraria/UI/Hud/LuneComponents.cs
+++ b/Assets/Luneraria/UI/Hud/LuneComponents.cs
@@ -115,6 +115,14 @@ namespace Luneraria.UI
readonly List _rows = new List();
readonly Label _header;
+ /// S2 team actions (extended rules): glyph lane toggles and the Ancient level.
+ public event Action CommandRequested;
+ public readonly Dictionary GlyphButtons = new Dictionary();
+ public LuneButton CoreButton;
+ VisualElement _actions;
+ Label _glyphState;
+ Label _coreState;
+ string _actionSignature = "";
readonly LuneGlyphElement _emblem;
string _signature = "";
@@ -145,6 +153,74 @@ namespace Luneraria.UI
int captured = index;
LuneTooltip.Attach(element, () => Tip(captured));
}
+ // S2: team actions; hidden under the reference rules.
+ _actions = LuneUi.Box(true, 3f, column);
+ _actions.style.marginTop = 4f;
+ VisualElement glyphRow = LuneUi.Box(false, 4f, _actions);
+ LuneUi.Glyph("tower", LuneUiTheme.BronzeLight, 14f, glyphRow);
+ LuneUi.Label("GLYPHE", "HudCaps", glyphRow).style.minWidth = 64f;
+ foreach (string lane in LuneMap.Lanes)
+ {
+ var button = new LuneButton(lane == "bot" ? "BOT" : LuneUi.Upper(LuneMap.LaneNames[lane]), LuneButtons.HudToggle, glyphRow);
+ string captured = lane;
+ button.Clicked += () => CommandRequested?.Invoke(Command.Glyph(button.On ? "" : captured));
+ GlyphButtons[lane] = button;
+ }
+ _glyphState = LuneUi.Label("", "HudMuted", glyphRow);
+ LuneTooltip.Attach(glyphRow, () => new LuneTip
+ {
+ Title = "Glyphe (wie in Dota 2)", Glyph = "tower", GlyphColor = LuneUiTheme.BronzeLight,
+ Text = "Halte die Glyphe für eine Lane bereit: Fällt dort im Kampf ein eigener Turm unter 50 %, werden die Türme der Lane 5 s unverwundbar. Gilt für das ganze Team; danach 4 Runden Abklingzeit. Löst sie nicht aus, bleibt sie bereit.",
+ Note = "Entscheidet sich ein Mitspieler auf einem früheren Platz anders, gilt seine Wahl.",
+ });
+ VisualElement coreRow = LuneUi.Box(false, 4f, _actions);
+ LuneUi.Glyph("crown", LuneUiTheme.BronzeLight, 14f, coreRow);
+ LuneUi.Label("ANCIENT", "HudCaps", coreRow).style.minWidth = 64f;
+ _coreState = LuneUi.Label("", "HudText", coreRow);
+ _coreState.style.flexGrow = 1f;
+ CoreButton = new LuneButton("", LuneButtons.HudButton, coreRow);
+ CoreButton.Clicked += () => CommandRequested?.Invoke(Command.CoreUpgrade());
+ LuneTooltip.Attach(coreRow, () => new LuneTip
+ {
+ Title = "Ancient stärken (wie der König in Legion TD 2)", Glyph = "crown", GlyphColor = LuneUiTheme.BronzeLight,
+ Text = "Jeder im Team kann Stufen kaufen (höchstens 3). Jede Stufe repariert den Ancient um 1.000 und lässt ihn auf Angreifer in 7,5 m schießen: 45 / 75 / 110 Schaden pro Sekunde.",
+ Note = "Preis: 150 × nächste Stufe.",
+ });
+ LuneUi.Show(_actions, false);
+ }
+
+ /// S2: glyph and Ancient level of the own team (planning commands; hidden under the reference rules).
+ public void ApplyActions(LuneHudModel model)
+ {
+ LuneMatch state = model.State;
+ bool on = state.Extended;
+ int team = model.Team;
+ Draft draft = model.Me.Draft;
+ int ready = state.Boss.GlyphReady[team];
+ int level = state.Boss.CoreLevel[team];
+ int pending = model.Planning ? draft.CoreInvest : 0;
+ string held = model.Planning ? draft.GlyphLane : "";
+ string signature = on + "|" + model.Planning + "|" + model.Locked + "|" + ready + "|" + state.RoundNumber + "|" + level + "|" + pending + "|" + held + "|" + model.OwnGold;
+ if (signature == _actionSignature)
+ return;
+ _actionSignature = signature;
+ LuneUi.Show(_actions, on);
+ if (!on)
+ return;
+ bool canAct = model.Planning && !model.Locked;
+ bool glyphReady = state.RoundNumber >= ready;
+ foreach (KeyValuePair pair in GlyphButtons)
+ {
+ pair.Value.On = held == pair.Key;
+ pair.Value.Disabled = !canAct || !glyphReady;
+ }
+ _glyphState.text = glyphReady ? (held.Length > 0 ? "bereit" : "") : "ab Runde " + ready;
+ int shown = level + pending;
+ _coreState.text = "STUFE " + shown + "/" + state.Balance.CoreLevelMax + (pending > 0 ? " (+" + pending + ")" : "");
+ bool max = shown >= state.Balance.CoreLevelMax;
+ int cost = max ? 0 : state.Balance.CoreUpgradeCost(shown);
+ CoreButton.Text = max ? "MAX" : "+" + cost + " G";
+ CoreButton.Disabled = !canAct || max || model.OwnGold < cost;
}
/// S1: Mondsegen stacks of the own team (-1 under the reference rules).
@@ -401,17 +477,19 @@ namespace Luneraria.UI
///
public sealed class LuneItemSlot : LuneButton
{
- public enum State { Empty, Equipped, Available, Unaffordable, Incompatible, Unique, Full, Locked }
+ public enum State { Empty, Equipped, Available, Unaffordable, Incompatible, Unique, Full, Locked, MissingParts }
static readonly Dictionary StateText = new Dictionary
{
{ State.Equipped, "AUSGERÜSTET" }, { State.Available, "KAUFBAR" }, { State.Unaffordable, "ZU TEUER" }, { State.Incompatible, "STUFE FEHLT" },
- { State.Unique, "GETRAGEN" }, { State.Full, "PLÄTZE VOLL" }, { State.Locked, "GESPERRT" }, { State.Empty, "LEER" },
+ { State.Unique, "GETRAGEN" }, { State.Full, "PLÄTZE VOLL" }, { State.Locked, "GESPERRT" }, { State.Empty, "LEER" }, { State.MissingParts, "TEILE FEHLEN" },
};
static readonly Dictionary ItemColors = new Dictionary
{
{ "basaltschild", LuneUiTheme.Hex("9fb2c8") }, { "glutklinge", LuneUiTheme.Hex("ff9a5a") }, { "rankenmantel", LuneUiTheme.Hex("8fd08a") },
{ "funkenfokus", LuneUiTheme.Hex("f5d76a") }, { "quellamulett", LuneUiTheme.Hex("7ad6e6") }, { "kristallsplitter", LuneUiTheme.Hex("c7a2ff") },
+ // S2 recipes: the colour of their first part, a little brighter.
+ { "bollwerk", LuneUiTheme.Hex("c3d3e6") }, { "glutsturm", LuneUiTheme.Hex("ffb27a") }, { "quellherz", LuneUiTheme.Hex("a2e8f2") }, { "splitterkrone", LuneUiTheme.Hex("dcc2ff") },
};
public string ItemId = "";
@@ -485,13 +563,13 @@ namespace Luneraria.UI
}
LuneContent.Items.TryGetValue(ItemId, out ItemSpec item);
_name.text = item != null ? item.Name : "";
- _cost.text = item != null ? item.Cost.ToString() : "";
+ _cost.text = item == null ? "" : item.IsRecipe ? "+" + item.RecipeFee : item.Cost.ToString();
LuneUi.SetColor(_cost, state == State.Unaffordable ? LuneUiTheme.Danger : LuneUiTheme.Bright);
_effect.text = item != null ? item.Text : "";
Color badge = state == State.Equipped ? LuneUiTheme.Good : state == State.Available ? LuneUiTheme.BronzeLight : LuneUiTheme.Muted;
if (state == State.Unaffordable)
badge = LuneUiTheme.Danger;
- string glyph = state == State.Equipped || state == State.Unique ? "check" : state == State.Incompatible || state == State.Locked ? "lock" : "";
+ string glyph = state == State.Equipped || state == State.Unique ? "check" : state == State.Incompatible || state == State.Locked || state == State.MissingParts ? "lock" : "";
_badge.SetState(StateText[state], badge, glyph, state == State.Equipped);
Disabled = !usable;
}
diff --git a/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs b/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
index 987738c..57ac500 100644
--- a/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
+++ b/Assets/Luneraria/UI/Hud/LuneHeroPanels.cs
@@ -568,6 +568,9 @@ namespace Luneraria.UI
{
string itemId = pair.Key;
ItemSpec item = pair.Value;
+ // S2 recipes exist only under the extended rules.
+ if (item.IsRecipe && !model.State.Extended)
+ continue;
if (column % 2 == 0)
line = LuneUi.Box(false, 8f, grid);
column++;
@@ -576,15 +579,19 @@ namespace Luneraria.UI
card.style.flexBasis = 0f;
line.Add(card);
LuneItemSlot.State state = LuneItemSlot.State.Available;
+ bool partsMissing = item.IsRecipe && !item.Parts.All(items.Contains);
+ int price = item.IsRecipe ? item.RecipeFee : item.Cost;
if (items.Contains(itemId))
state = LuneItemSlot.State.Unique;
else if (!canAct)
state = LuneItemSlot.State.Locked;
+ else if (partsMissing)
+ state = LuneItemSlot.State.MissingParts;
else if (level < item.MinLevel)
state = LuneItemSlot.State.Incompatible;
- else if (items.Count >= LuneContent.ItemSlots)
+ else if (!item.IsRecipe && items.Count >= LuneContent.ItemSlots)
state = LuneItemSlot.State.Full;
- else if (gold < item.Cost)
+ else if (gold < price)
state = LuneItemSlot.State.Unaffordable;
card.SetItem(itemId, state);
ItemCards[itemId] = card;
@@ -596,11 +603,13 @@ namespace Luneraria.UI
};
string why = state == LuneItemSlot.State.Unique ? "Dein Held trägt es bereits; Items sind einzigartig."
: state == LuneItemSlot.State.Locked ? WhyNot(model)
+ : state == LuneItemSlot.State.MissingParts ? "Braucht " + string.Join(" und ", item.Parts.Select(id => LuneContent.Items[id].Name)) + " im Inventar."
: state == LuneItemSlot.State.Incompatible ? "Erfordert Heldenstufe " + item.MinLevel + "."
: state == LuneItemSlot.State.Full ? "Alle " + LuneContent.ItemSlots + " Plätze belegt; verkaufe zuerst ein Item."
- : state == LuneItemSlot.State.Unaffordable ? "Es fehlen " + (item.Cost - gold) + " Gold."
- : "Klick: kaufen und ausrüsten.";
- LuneTooltip.Attach(card, () => new LuneTip { Title = item.Name + " · " + item.Cost + " Gold", Glyph = itemId, GlyphColor = LuneItemSlot.ColorOf(itemId), Text = item.Text, Note = why });
+ : state == LuneItemSlot.State.Unaffordable ? "Es fehlen " + (price - gold) + " Gold."
+ : item.IsRecipe ? "Klick: schmieden (beide Teile werden eins, ein Platz wird frei)." : "Klick: kaufen und ausrüsten.";
+ string title = item.IsRecipe ? item.Name + " · Rezept +" + item.RecipeFee + " Gold" : item.Name + " · " + item.Cost + " Gold";
+ LuneTooltip.Attach(card, () => new LuneTip { Title = title, Glyph = itemId, GlyphColor = LuneItemSlot.ColorOf(itemId), Text = item.Text, Note = why });
}
}
diff --git a/Assets/Luneraria/UI/Hud/LuneHud.cs b/Assets/Luneraria/UI/Hud/LuneHud.cs
index 3bae2db..bae3826 100644
--- a/Assets/Luneraria/UI/Hud/LuneHud.cs
+++ b/Assets/Luneraria/UI/Hud/LuneHud.cs
@@ -107,6 +107,8 @@ namespace Luneraria.UI
VisualElement _system;
VisualElement _left;
LuneTeamPanel _team;
+ /// The own team panel (S2 team actions; tests).
+ public LuneTeamPanel Team { get { return _team; } }
VisualElement _right;
LunePanel _bottom;
LuneResourceDisplay _gold;
@@ -438,6 +440,7 @@ namespace Luneraria.UI
_left.Add(teamPanel);
_team = new LuneTeamPanel();
teamPanel.Add(_team);
+ _team.CommandRequested += command => CommandRequested?.Invoke(command);
Feed = new LuneEventFeed();
_left.Add(Feed);
Feed.FocusRequested += FocusTarget;
@@ -799,6 +802,7 @@ namespace Luneraria.UI
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);
+ _team.ApplyActions(Model);
SyncRight(planning, fighting);
SyncBottom(planning, fighting);
SyncReady(planning, fighting);
diff --git a/Assets/Luneraria/UI/Theme/LuneGlyphs.cs b/Assets/Luneraria/UI/Theme/LuneGlyphs.cs
index 62ff957..0a47a79 100644
--- a/Assets/Luneraria/UI/Theme/LuneGlyphs.cs
+++ b/Assets/Luneraria/UI/Theme/LuneGlyphs.cs
@@ -16,7 +16,7 @@ namespace Luneraria.UI
"ancient", "warning", "check", "close", "bot", "offline", "clock", "heart", "tempo", "range", "armor",
"move", "star", "lock", "up", "lane_top", "lane_mid", "lane_bot", "grotto", "pause", "play", "menu",
"gear", "human", "sword", "basaltschild", "glutklinge", "rankenmantel", "funkenfokus", "quellamulett",
- "kristallsplitter", "hero", "skull" };
+ "kristallsplitter", "hero", "skull", "bollwerk", "glutsturm", "quellherz", "splitterkrone" };
const float Tau = Mathf.PI * 2f;
static readonly Dictionary Cache = new Dictionary();
@@ -28,6 +28,16 @@ namespace Luneraria.UI
return;
var f = new Frame(rect);
Color dark = accent.a > 0f ? accent : new Color(color.r * .45f, color.g * .45f, color.b * .45f, color.a);
+ // S2 recipes: the first part's symbol inside a forged ring with a small spark.
+ Luneraria.Core.ItemSpec recipe;
+ if (Luneraria.Core.ContentData.Items.TryGetValue(glyph, out recipe) && recipe.IsRecipe)
+ {
+ float inset = rect.width * .16f;
+ Draw(painter, recipe.Parts[0], new Rect(rect.x + inset, rect.y + inset, rect.width - 2f * inset, rect.height - 2f * inset), color, accent);
+ ArcLine(painter, f.P(.5f, .5f), f.S * .47f, 0f, Tau, color, Mathf.Max(1f, f.S * .06f));
+ Shard(painter, f, V(.82f, .18f), 0f, .14f, LuneUiTheme.Hex("fff2c0"));
+ return;
+ }
switch (glyph)
{
case "coin":
diff --git a/Assets/Luneraria/View/LuneMatchView.cs b/Assets/Luneraria/View/LuneMatchView.cs
index d6535d4..6e55258 100644
--- a/Assets/Luneraria/View/LuneMatchView.cs
+++ b/Assets/Luneraria/View/LuneMatchView.cs
@@ -146,11 +146,43 @@ namespace Luneraria.View
view.SetHealth((float)hp, (float)definition.MaxHp, _synced);
if (view.Tier == "core")
SyncAncientBar(view, (float)hp, (float)definition.MaxHp);
- if (unit != null && unit.Kind == "tower" && _combat != null)
+ // S2: a levelled Ancient shoots like a tower.
+ if (unit != null && (unit.Kind == "tower" || (unit.Kind == "core" && unit.Def.Damage > 0.0)) && _combat != null)
TowerAttack(view, unit);
+ GlyphRing(definition, view, unit != null && fighting && _combat.Glyphed(unit));
}
}
+ /// S2 (Dota 2 glyph): a bright hexagon ring around an invulnerable tower.
+ readonly Dictionary _glyphRings = new Dictionary();
+
+ void GlyphRing(BuildingDef definition, LuneBuildingView view, bool on)
+ {
+ _glyphRings.TryGetValue(definition.Id, out GameObject ring);
+ if (!on)
+ {
+ if (ring != null)
+ ring.SetActive(false);
+ return;
+ }
+ if (ring == null)
+ {
+ if (Library == null || Library.RingHexagon == null || Library.MoonBurst == null)
+ return;
+ ring = new GameObject("GlyphRing_" + definition.Id);
+ ring.transform.SetParent(view.transform, false);
+ ring.transform.localPosition = new Vector3(0f, .12f, 0f);
+ ring.transform.localScale = new Vector3(4.2f, 1f, 4.2f);
+ ring.AddComponent().sharedMesh = Library.RingHexagon;
+ var renderer = ring.AddComponent();
+ renderer.sharedMaterial = Library.MoonBurst;
+ renderer.shadowCastingMode = UnityEngine.Rendering.ShadowCastingMode.Off;
+ _glyphRings[definition.Id] = ring;
+ }
+ ring.SetActive(true);
+ ring.transform.Rotate(0f, 40f * Time.deltaTime, 0f);
+ }
+
void TowerAttack(LuneBuildingView view, LuneUnit tower)
{
_towerSerials.TryGetValue(tower.BuildingId, out int last);
diff --git a/docs/luneraria/IDEEN.md b/docs/luneraria/IDEEN.md
index 7686976..6bfc321 100644
--- a/docs/luneraria/IDEEN.md
+++ b/docs/luneraria/IDEEN.md
@@ -84,6 +84,10 @@ Legende:
| 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 |
+| Glyphe | umgesetzt in S2 |
+| Item-Rezepte | umgesetzt in S2 |
+| Ancient stärken (Legion TD 2) | umgesetzt in S2 (neu, nicht in der ursprünglichen Liste) |
+| Fog of War, Neutrallager, Schreine | offen (S3) |
## Empfohlene Reihenfolge
diff --git a/docs/luneraria/S2-STRATEGIE.md b/docs/luneraria/S2-STRATEGIE.md
new file mode 100644
index 0000000..a76c51e
--- /dev/null
+++ b/docs/luneraria/S2-STRATEGIE.md
@@ -0,0 +1,87 @@
+# S2 · Glyphe, Item-Rezepte und Ancient-Stufen
+
+Stand: 11. Oktober 2026 · Branch `luneraria/s2-strategie` (von `luneraria/s1-strategie` = `8e4b52f`).
+Grundlage: [IDEEN.md](IDEEN.md) und [S1-STRATEGIE.md](S1-STRATEGIE.md). Auftrag: „mach weiter mit S2“.
+
+
+
+## Kurzfassung
+
+| Vorbild | Mechanik | Strategische Frage |
+| --- | --- | --- |
+| Dota 2 (Glyphe der Befestigung) | **Glyphe:** Das Team hält sie in der Planung für eine Lane bereit. Fällt dort im Kampf ein eigener Turm unter 50 %, sind die Türme dieser Lane 5 s unverwundbar. Danach folgen 4 Runden Abklingzeit. Löst sie nicht aus, bleibt sie bereit | Wo greift der Gegner an? Die Glyphe ist eine Wette auf seinen Plan |
+| Dota 2 / League of Legends (Rezepte) | **Item-Rezepte:** Zwei Items plus ein Rezeptpreis ergeben ein stärkeres Item, und ein Platz wird frei. Es gibt Bollwerk, Glutsturm, Quellherz und Splitterkrone | Breit kaufen oder auf ein Rezept hinarbeiten? |
+| Legion TD 2 (König-Upgrades) | **Ancient-Stufen:** Jeder Spieler eines Teams kann Stufen des eigenen Ancients kaufen (höchstens 3, Preis 150 × nächste Stufe). Jede Stufe repariert 1.000 und lässt den Ancient auf Angreifer in 7,5 m schießen (45 / 75 / 110 Schaden pro Sekunde) | Gold in Angriff, Armee oder in die letzte Verteidigung? |
+| S1-Befund | **Bots und Grotte:** Mit erweiterten Regeln jagen auch die Mid-Bots ab Runde 8 den Boss, aber nur, wenn ihre Lane sicher ist (beide eigenen Türme über 70 %) | Mondsegen kommt in Bot-Partien jetzt wirklich vor |
+
+Wie in S1 wirkt alles nur mit `MatchOptions.Extended`. Ohne den Schalter bleiben die Referenzregeln unverändert. Das betrifft Golden-Replays, Fingerabdrücke und die HUD-Prüfung; die Suite `s2` prüft das ausdrücklich.
+
+## Rezepte
+
+| Rezept | Teile | Preis | Wirkung |
+| --- | --- | ---: | --- |
+| Bollwerk | Basaltschild + Rankenmantel | 120 | +600 Leben, +15 % Schadensminderung |
+| Glutsturm | Glutklinge + Funkenfokus | 140 | +25 % Schaden, +22 % Angriffstempo |
+| Quellherz | Quellamulett + Basaltschild | 120 | +450 Leben, 2,2 % Regeneration pro Sekunde |
+| Splitterkrone | Kristallsplitter + Glutklinge | 160 | +20 % Schaden; Treffer 35 % an 2 Gegner in 1,5 m; ab Stufe 5 |
+
+Das fertige Item nimmt den Platz des ersten Teils ein, damit wird ein Platz frei. Verkauft bringt es die Hälfte des Gesamtpreises (Teile plus Rezept). Rückgängig machen geht wie bei jedem Planungsschritt.
+
+## Regeldetails
+
+- **Glyphe (Teamentscheidung):** Es gilt die Wahl des ersten Spielers in Platzreihenfolge, der eine Lane hält. Mitspieler sehen ihre Pläne gegenseitig nicht, das ist bestehende Privatsphäre. Die Unverwundbarkeit greift für jeden Schaden, auch Fläche und Schaden über Zeit. Nur eine ausgelöste Glyphe startet die Abklingzeit.
+- **Ancient-Stufen:** Der Preis richtet sich nach der festen Teamstufe plus den eigenen Käufen dieser Planung. Kaufen zwei Spieler gleichzeitig über Stufe 3 hinaus, wird der Überschuss erstattet. Der Ancient zielt wie ein Turm (nur in Reichweite, Einheiten zuerst) und nutzt den Turmtakt.
+- **Netz:** `Draft.GlyphLane`, `Draft.CoreInvest`, `BossState.GlyphReady` und `BossState.CoreLevel` werden übertragen. Den Glyphen-Ring sehen Host und Offline-Spieler; Gäste sehen den Kampfzustand der Gebäude nur über die Lebenspunkte (bekannte Einschränkung, wie bei den Turmschüssen).
+
+## Oberfläche
+
+| Stelle | Anzeige |
+| --- | --- |
+| Teampanel | Zeile **GLYPHE** mit TOP, MID, BOT und Status („bereit“ / „ab Runde N“); Zeile **ANCIENT** mit „STUFE 1/3 (+1)“ und Kaufknopf „+300 G“; Tooltips nennen die Vorbilder |
+| Heldenfenster, Items | Rezepte mit Rahmen-Symbol, Preis „+120“, Zustand KAUFBAR, TEILE FEHLEN oder STUFE FEHLT; Klick schmiedet |
+| Feld | Bei ausgelöster Glyphe ein drehender heller Sechseckring um die geschützten Türme; ein Ancient mit Stufe schießt sichtbar wie ein Turm |
+
+## Balance (Bot-Partien)
+
+Werkzeug: `dotnet run -c Release --project tools/dotnet/Luneraria.Balance s1 60`. Ausgewertet sind 60 Seeds mit der normalen Profil-Rotation, Rohdaten in `docs/port/logs/s2/balance-s2.txt`.
+
+| | Referenz | S1 | S2 |
+| --- | --- | --- | --- |
+| Siege Blau / Rot | 30 / 30 | 29 / 31 | 30 / 30 |
+| Ø Runden | 10,7 | 12,8 | 12,1 |
+| Wer den ersten Turm nimmt, gewinnt | 40/60 | 43/60 | 43/60 |
+| Mondsegen-Stapel | – | 8 | 25 |
+| Ausgelöste Glyphen | – | – | 128 |
+| Gekaufte Ancient-Stufen | – | – | 50 |
+| Rezepte bei Bots | – | – | 0 |
+
+So ist die Balance entstanden:
+- **Erste Fassung:** Mid-Bots zogen ohne Lane-Prüfung in die Grotte. Dadurch stieg „erster Turm gewinnt“ auf 52 von 60, die Grottengänge rissen Lanes auf.
+- **Lane-Prüfung (über 70 %):** Damit liegt der Wert wieder bei 43 von 60.
+- **Ancient-Stufen:** Die Bots kauften sie erst, nachdem ihr Teamplan vor den Heerkauf gezogen wurde.
+- **Rezepte:** Bots werben ihre Helden erst um Runde 10 bis 12 an (bestehendes Referenzverhalten) und kommen deshalb selten zu zwei passenden Items. Rezepte sind daher vor allem ein Werkzeug für Menschen. Dass ein Bot mit beiden Teilen schmiedet, prüft `s2` gezielt.
+
+## Tests und Nachweise
+
+| Prüfung | Ergebnis |
+| --- | --- |
+| Kern (.NET 8) | 566/566, darunter **s2 35** (Referenz unberührt, Glyphe: Teamwahl, Auslösen, Schutz, Ende, Abklingzeit nur nach Auslösen; Rezepte: Teile, Preis, Platz frei, Rückgängig, Verkauf, Stufe; Ancient: Preise, Teamdeckel mit Erstattung, Reparatur, Schuss; Bots: Grotte, Mondsegen, Glyphen, Schmieden) |
+| Golden und Fingerabdrücke | 0 Abweichungen; `DCE95C96556F3A1A` bei 30/60/120 fps und unbegrenzt unverändert |
+| Unity EditMode | 32/32 |
+| Unity PlayMode | 17/17 (neu `S2RulesViewsTests.S2Hud`); Verify A–I 718/0, eine Prüfung angepasst: „item shop lists every item of the rules in play“ (unter Referenzregeln ohne Rezepte) |
+| Player-Selbsttest | macOS 30/30 |
+| Leistung | sechs Sylvaras, Seed 9 ab Welle 2: Karte 148,4–150,1 fps, Mid nah 169,5–172,8 fps, HUD 0,04–0,05 ms. Gleich S1 (148,5–150,3 / 170,1–171,7) |
+
+Die Bilder liegen in `docs/port/screenshots/s2/spiel/`:
+1. Team-Aktionen
+2. Ancient-Tooltip
+3. Rezept im Shop
+4. Bollwerk geschmiedet
+5. Glyphe am Turm
+
+## Offene Punkte
+
+- **Fog of War mit Spähern (S3):** braucht einen Umbau von Sichtmodell, Planungsvorschau und Netz-Privatsphäre.
+- **Neutrallager und Schreine (LoL-Buffs):** brauchen neue Kartenorte samt Ansichten.
+- **Glyphen-Ring auf Gästen:** Dafür muss der Glyphen-Zustand in den Kampfrahmen.
+- **Bots werben Helden spät:** Ein früherer Heldenkauf würde Items und Rezepte in Bot-Partien beleben. Das ist eine eigene Bot-Phase, im erweiterten Modus aber ohne Paritätsfolgen machbar.
diff --git a/docs/port/PORT-QA.md b/docs/port/PORT-QA.md
index c69fb5e..e6ab39a 100644
--- a/docs/port/PORT-QA.md
+++ b/docs/port/PORT-QA.md
@@ -288,6 +288,18 @@ Die Glutpakt-Arbeit entstand als Kette aufeinander aufbauender Zweige (`lunerari
| Bild | `screenshots/s1/spiel/` |
| Commits | `luneraria/s1-strategie` (Basis: Merge H1 + U5.5 `2e05cbe`) |
+## S2 · Glyphe, Rezepte, Ancient-Stufen (Dota 2, League of Legends, Legion TD 2)
+
+| Punkt | Ergebnis |
+| --- | --- |
+| Tests | Kern .NET 566/566 (neu `s2` 35), EditMode 32/32, PlayMode 17/17 (neu `S2Hud`), Verify A–I 718/0 (Shop-Prüfung auf die Regeln im Spiel angepasst), Player-Selbsttest 30/30 |
+| Parität | Referenzregeln unverändert: Golden 0 Abweichungen, Fingerabdruck `DCE95C96556F3A1A` gleich |
+| Inhalt | Glyphe (Teamwahl einer Lane, 5 s unverwundbar unter 50 %, 4 Runden Abklingzeit nach Auslösen), vier Item-Rezepte, Ancient-Stufen 1–3 mit Reparatur und Schuss, Grottenjagd der Mid-Bots bei sicherer Lane |
+| Balance | 60 Bot-Partien: Blau/Rot 30/30, Ø 12,1 Runden, erster Turm gewinnt 43/60, Mondsegen 25, Glyphen 128, Ancient-Stufen 50 (`logs/s2/balance-s2.txt`) |
+| Leistung | Karte 148,4–150,1 fps, Mid nah 169,5–172,8 fps, HUD 0,04–0,05 ms (`logs/s2/`) |
+| Bild | `screenshots/s2/spiel/` |
+| Commits | `luneraria/s2-strategie` |
+
## 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 23d7681..13c20fc 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 (S1, Strategie-Regeln).
+Stand: 11. Oktober 2026 (S2, Glyphe, Rezepte, Ancient).
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.
@@ -16,6 +16,7 @@ Zielplattformen sind Fedora 44 und Windows 11; macOS ist die Entwicklungsplattfo
| 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) |
+| S2 · Glyphe, Rezepte, Ancient | **fertig** (`luneraria/s2-strategie`): Glyphe (Dota 2), Item-Rezepte (Dota 2/LoL), Ancient-Stufen (Legion TD 2), Grottenjagd der Bots; hinter dem Regelschalter „Erweitert“ | [../luneraria/S2-STRATEGIE.md](../luneraria/S2-STRATEGIE.md) |
QA je Meilenstein: [PORT-QA.md](PORT-QA.md).
diff --git a/docs/port/logs/s2/balance-s2.txt b/docs/port/logs/s2/balance-s2.txt
new file mode 100644
index 0000000..a668edb
--- /dev/null
+++ b/docs/port/logs/s2/balance-s2.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; glyphs fired; Ancient levels; recipe items
+referenz; 60; 30; 30; 0; 0; 10.7; 9; 12; 40/60; 0.0; 0; 0; 0; 0; 0; 0; 0; 0; 0
+erweitert; 60; 30; 30; 0; 0; 12.1; 11; 15; 43/60; 12.2; 52; 588; 250; 25; 48; 24; 128; 50; 0
diff --git a/docs/port/logs/s2/core-tests-dotnet.json b/docs/port/logs/s2/core-tests-dotnet.json
new file mode 100644
index 0000000..e3b0110
--- /dev/null
+++ b/docs/port/logs/s2/core-tests-dotnet.json
@@ -0,0 +1,17 @@
+{
+ "suites": [
+ {"suite": "content", "checks": 77, "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.70, "failed": [], "notes": ["full match seed 2 -> Rot after 9 rounds"]},
+ {"suite": "m2", "checks": 73, "failures": 0, "seconds": 0.39, "failed": [], "notes": []},
+ {"suite": "golden", "checks": 157, "failures": 0, "seconds": 1.53, "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.49, "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.02, "failed": [], "notes": ["sylvara-bot match seed 12: 13/1"]},
+ {"suite": "s1", "checks": 31, "failures": 0, "seconds": 0.43, "failed": [], "notes": ["extended seed 17: 12/1, income bonus 12,12,12,24,24,24, blessing 0/1"]},
+ {"suite": "s2", "checks": 34, "failures": 0, "seconds": 2.82, "failed": [], "notes": ["12 extended matches: expeditions 5, Mondsegen 5, recipe heroes 0, glyphs 25, Ancient levels 8; seed 1 13/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/s2/editmode-results.xml b/docs/port/logs/s2/editmode-results.xml
new file mode 100644
index 0000000..2c3265f
--- /dev/null
+++ b/docs/port/logs/s2/editmode-results.xml
@@ -0,0 +1,355 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/port/logs/s2/player-benchmark-sylvara-run1.json b/docs/port/logs/s2/player-benchmark-sylvara-run1.json
new file mode 100644
index 0000000..05eaafc
--- /dev/null
+++ b/docs/port/logs/s2/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": 2994, "fps": 149.7, "frame_ms_avg": 6.68, "frame_ms_median": 3.00, "frame_ms_p95": 21.50, "frame_ms_p99": 28.41, "frame_ms_max": 40.40, "frames_over_16_7_ms": 311, "of_them_with_simulation_step": 32, "gc_collections": 21, "simulation_ms_per_frame": 0.061, "views_ms_per_frame": 0.193, "ui_ms_per_frame": 0.053, "units_alive_max": 77},
+ // timings overview (zoom 64): main thread avg 2.30 / median 2.19 / p95 3.18 ms, render thread avg 1.69 / median 1.68 / p95 1.99 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.37 / median 0.28 / p95 19.12 ms
+ {"view": "mid lane close (zoom 22)", "frames": 1702, "fps": 170.2, "frame_ms_avg": 5.88, "frame_ms_median": 2.56, "frame_ms_p95": 20.43, "frame_ms_p99": 27.60, "frame_ms_max": 33.30, "frames_over_16_7_ms": 165, "of_them_with_simulation_step": 7, "gc_collections": 7, "simulation_ms_per_frame": 0.034, "views_ms_per_frame": 0.162, "ui_ms_per_frame": 0.038, "units_alive_max": 37}
+ // timings mid lane close (zoom 22): main thread avg 1.74 / median 1.68 / p95 2.40 ms, render thread avg 1.31 / median 1.29 / p95 1.52 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.15 / median 0.28 / p95 18.57 ms
+ ]
+}
diff --git a/docs/port/logs/s2/player-benchmark-sylvara-run2.json b/docs/port/logs/s2/player-benchmark-sylvara-run2.json
new file mode 100644
index 0000000..c81ff38
--- /dev/null
+++ b/docs/port/logs/s2/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": 2968, "fps": 148.4, "frame_ms_avg": 6.74, "frame_ms_median": 2.91, "frame_ms_p95": 21.51, "frame_ms_p99": 27.40, "frame_ms_max": 35.38, "frames_over_16_7_ms": 330, "of_them_with_simulation_step": 33, "gc_collections": 21, "simulation_ms_per_frame": 0.063, "views_ms_per_frame": 0.197, "ui_ms_per_frame": 0.054, "units_alive_max": 77},
+ // timings overview (zoom 64): main thread avg 2.33 / median 2.23 / p95 3.20 ms, render thread avg 1.72 / median 1.71 / p95 2.03 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.40 / median 0.25 / p95 19.07 ms
+ {"view": "mid lane close (zoom 22)", "frames": 1695, "fps": 169.5, "frame_ms_avg": 5.90, "frame_ms_median": 2.70, "frame_ms_p95": 19.91, "frame_ms_p99": 28.40, "frame_ms_max": 34.41, "frames_over_16_7_ms": 139, "of_them_with_simulation_step": 11, "gc_collections": 7, "simulation_ms_per_frame": 0.032, "views_ms_per_frame": 0.164, "ui_ms_per_frame": 0.039, "units_alive_max": 37}
+ // timings mid lane close (zoom 22): main thread avg 1.74 / median 1.69 / p95 2.39 ms, render thread avg 1.31 / median 1.29 / p95 1.53 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.15 / median 0.79 / p95 18.04 ms
+ ]
+}
diff --git a/docs/port/logs/s2/player-benchmark-sylvara-run3.json b/docs/port/logs/s2/player-benchmark-sylvara-run3.json
new file mode 100644
index 0000000..f65326a
--- /dev/null
+++ b/docs/port/logs/s2/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": 3002, "fps": 150.1, "frame_ms_avg": 6.66, "frame_ms_median": 2.82, "frame_ms_p95": 21.89, "frame_ms_p99": 28.70, "frame_ms_max": 40.11, "frames_over_16_7_ms": 317, "of_them_with_simulation_step": 29, "gc_collections": 21, "simulation_ms_per_frame": 0.061, "views_ms_per_frame": 0.188, "ui_ms_per_frame": 0.053, "units_alive_max": 77},
+ // timings overview (zoom 64): main thread avg 2.26 / median 2.18 / p95 3.01 ms, render thread avg 1.67 / median 1.67 / p95 1.95 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.39 / median 0.24 / p95 19.50 ms
+ {"view": "mid lane close (zoom 22)", "frames": 1731, "fps": 172.8, "frame_ms_avg": 5.79, "frame_ms_median": 2.73, "frame_ms_p95": 19.11, "frame_ms_p99": 27.71, "frame_ms_max": 34.30, "frames_over_16_7_ms": 136, "of_them_with_simulation_step": 20, "gc_collections": 7, "simulation_ms_per_frame": 0.036, "views_ms_per_frame": 0.159, "ui_ms_per_frame": 0.036, "units_alive_max": 37}
+ // timings mid lane close (zoom 22): main thread avg 1.71 / median 1.63 / p95 2.40 ms, render thread avg 1.29 / median 1.28 / p95 1.51 ms, gpu avg 0.00 / median 0.00 / p95 0.00 ms, present wait avg 4.07 / median 0.95 / p95 17.19 ms
+ ]
+}
diff --git a/docs/port/logs/s2/player-smoke.json b/docs/port/logs/s2/player-smoke.json
new file mode 100644
index 0000000..29d4998
--- /dev/null
+++ b/docs/port/logs/s2/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/s2/playmode-results.xml b/docs/port/logs/s2/playmode-results.xml
new file mode 100644
index 0000000..0de74ab
--- /dev/null
+++ b/docs/port/logs/s2/playmode-results.xml
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/docs/port/logs/s2/scene-frame-rates.json b/docs/port/logs/s2/scene-frame-rates.json
new file mode 100644
index 0000000..e0dfcbe
--- /dev/null
+++ b/docs/port/logs/s2/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.186, "measured_fps": 30.015, "max_steps_per_frame": 17, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true},
+ {"target": "60 fps", "frames": 1626, "seconds": 27.096, "measured_fps": 60.008, "max_steps_per_frame": 10, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true},
+ {"target": "120 fps", "frames": 3222, "seconds": 26.903, "measured_fps": 119.764, "max_steps_per_frame": 11, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true},
+ {"target": "unlimited", "frames": 85192, "seconds": 26.821, "measured_fps": 3176.355, "max_steps_per_frame": 7, "steps": 12690, "hash": "DCE95C96556F3A1A", "winner": 1, "same_as_core": true}
+ ]
+}
diff --git a/docs/port/logs/s2/vertical-function-test.json b/docs/port/logs/s2/vertical-function-test.json
new file mode 100644
index 0000000..d7db2bd
--- /dev/null
+++ b/docs/port/logs/s2/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": 48451, "fps": 2422.539, "frame_ms_avg": 0.413, "frame_ms_p95": 0.42, "simulation_ms_per_frame": 0.004, "views_ms_per_frame": 0.134, "units_alive_max": 116}}
+ ]
+}
diff --git a/docs/port/screenshots/s2/ablauf/01-runde1-aufmarsch.jpg b/docs/port/screenshots/s2/ablauf/01-runde1-aufmarsch.jpg
new file mode 100644
index 0000000..256b7d3
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/01-runde1-aufmarsch.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/02-runde1-mid-kampf.jpg b/docs/port/screenshots/s2/ablauf/02-runde1-mid-kampf.jpg
new file mode 100644
index 0000000..afdd725
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/02-runde1-mid-kampf.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/03-turm-faellt.jpg b/docs/port/screenshots/s2/ablauf/03-turm-faellt.jpg
new file mode 100644
index 0000000..9cb234d
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/03-turm-faellt.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/04-turm-ruine.jpg b/docs/port/screenshots/s2/ablauf/04-turm-ruine.jpg
new file mode 100644
index 0000000..5e8955f
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/04-turm-ruine.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/05-grotte-bosskampf.jpg b/docs/port/screenshots/s2/ablauf/05-grotte-bosskampf.jpg
new file mode 100644
index 0000000..de0f45f
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/05-grotte-bosskampf.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/06-held-kaempft.jpg b/docs/port/screenshots/s2/ablauf/06-held-kaempft.jpg
new file mode 100644
index 0000000..846a91a
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/06-held-kaempft.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/07-boss-auf-der-lane.jpg b/docs/port/screenshots/s2/ablauf/07-boss-auf-der-lane.jpg
new file mode 100644
index 0000000..e0fffdb
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/07-boss-auf-der-lane.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/08-ancient-faellt.jpg b/docs/port/screenshots/s2/ablauf/08-ancient-faellt.jpg
new file mode 100644
index 0000000..5080e65
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/08-ancient-faellt.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/09-ancient-ruine.jpg b/docs/port/screenshots/s2/ablauf/09-ancient-ruine.jpg
new file mode 100644
index 0000000..50ab49a
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/09-ancient-ruine.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/10-ende-uebersicht.jpg b/docs/port/screenshots/s2/ablauf/10-ende-uebersicht.jpg
new file mode 100644
index 0000000..4c4ef0e
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/10-ende-uebersicht.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/11-welle21-uebersicht.jpg b/docs/port/screenshots/s2/ablauf/11-welle21-uebersicht.jpg
new file mode 100644
index 0000000..0fa5955
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/11-welle21-uebersicht.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/12-welle21-kampf.jpg b/docs/port/screenshots/s2/ablauf/12-welle21-kampf.jpg
new file mode 100644
index 0000000..0bf0675
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/12-welle21-kampf.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/13-ancient-rot-faellt.jpg b/docs/port/screenshots/s2/ablauf/13-ancient-rot-faellt.jpg
new file mode 100644
index 0000000..4417a27
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/13-ancient-rot-faellt.jpg differ
diff --git a/docs/port/screenshots/s2/ablauf/14-welle-ende.jpg b/docs/port/screenshots/s2/ablauf/14-welle-ende.jpg
new file mode 100644
index 0000000..1cf8ccc
Binary files /dev/null and b/docs/port/screenshots/s2/ablauf/14-welle-ende.jpg differ
diff --git a/docs/port/screenshots/s2/spiel/01-team-aktionen.jpg b/docs/port/screenshots/s2/spiel/01-team-aktionen.jpg
new file mode 100644
index 0000000..77afc85
Binary files /dev/null and b/docs/port/screenshots/s2/spiel/01-team-aktionen.jpg differ
diff --git a/docs/port/screenshots/s2/spiel/02-ancient-tooltip.jpg b/docs/port/screenshots/s2/spiel/02-ancient-tooltip.jpg
new file mode 100644
index 0000000..3481c9a
Binary files /dev/null and b/docs/port/screenshots/s2/spiel/02-ancient-tooltip.jpg differ
diff --git a/docs/port/screenshots/s2/spiel/03-rezept-im-shop.jpg b/docs/port/screenshots/s2/spiel/03-rezept-im-shop.jpg
new file mode 100644
index 0000000..22fd1e6
Binary files /dev/null and b/docs/port/screenshots/s2/spiel/03-rezept-im-shop.jpg differ
diff --git a/docs/port/screenshots/s2/spiel/04-bollwerk-geschmiedet.jpg b/docs/port/screenshots/s2/spiel/04-bollwerk-geschmiedet.jpg
new file mode 100644
index 0000000..ac260e9
Binary files /dev/null and b/docs/port/screenshots/s2/spiel/04-bollwerk-geschmiedet.jpg differ
diff --git a/docs/port/screenshots/s2/spiel/05-glyphe-am-turm.jpg b/docs/port/screenshots/s2/spiel/05-glyphe-am-turm.jpg
new file mode 100644
index 0000000..55fd2aa
Binary files /dev/null and b/docs/port/screenshots/s2/spiel/05-glyphe-am-turm.jpg differ
diff --git a/tools/dotnet/Luneraria.Balance/Program.cs b/tools/dotnet/Luneraria.Balance/Program.cs
index bfb6261..573c59b 100644
--- a/tools/dotnet/Luneraria.Balance/Program.cs
+++ b/tools/dotnet/Luneraria.Balance/Program.cs
@@ -79,11 +79,11 @@ public static class Program
///
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");
+ 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; glyphs fired; Ancient levels; recipe items");
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;
+ long income = 0; int barracks = 0, exhausted = 0, buybacks = 0, blessings = 0, sylvaraSeats = 0, sylvaraWins = 0, glyphs = 0, cores = 0, recipes = 0;
for (int seed = 1; seed <= seeds; seed++)
{
var match = new LuneMatch();
@@ -103,6 +103,7 @@ public static class Program
if (extended && match.BarracksBroken(team, lane)) barracks++;
match.BeginCombat();
match.FinishCombat();
+ glyphs += (match.Combat.GlyphUsed[0] ? 1 : 0) + (match.Combat.GlyphUsed[1] ? 1 : 0);
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)
@@ -121,10 +122,13 @@ public static class Program
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];
+ cores += match.Boss.CoreLevel[0] + match.Boss.CoreLevel[1];
+ foreach (Player player in match.Players)
+ if (player.Hero != null) recipes += player.Hero.Items.Count(id => ContentData.Items[id].IsRecipe);
}
- 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}",
+ 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}; {18}; {19}; {20}",
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));
+ (double)income / (seeds * 6), barracks, exhausted, buybacks, blessings, sylvaraSeats, sylvaraWins, glyphs, cores, recipes));
}
return 0;
}