Erweiterte Regeln: in jeder dritten Runde ein neutraler Wächter in jeder Lanemitte; wer beim Fall mehr Einheiten (dann mehr Leben) in 8 m hat, nimmt das Lager: Glutsegen (+10 % Schaden), Mondschild (Schild 10 %), Beute (50 Gold je Spieler). Fog of War (LuneFog, nur Darstellung): Gegner sichtbar in 9 m um eigene Einheiten, 10 m um Gebäude, 12 m um Späher; Späher (40 Gold, 3 Runden, Teamwirkung) zeigen in der Planung die gegnerische Armee ihrer Lane. HUD: Teampanel SPÄHER und LAGER, Lager-Sechseck, Nebel in Feld, Minimap, Namensschildern und Lane-Zählung. Kern 597/597 (s3 31), EditMode 33/33, PlayMode 18/18, Verify 718/0, Selbsttest 30/30, Golden 0, Fingerabdruck unverändert. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
195 lines
8.5 KiB
C#
195 lines
8.5 KiB
C#
using System.Collections.Generic;
|
||
using Luneraria.Core;
|
||
|
||
namespace Luneraria.UI
|
||
{
|
||
/// <summary>
|
||
/// Deterministic lane threat for one team (port of scripts/luneraria/ui/lune_lane_threat.gd),
|
||
/// read only from public state: building HP, the units on the battlefield (positions and HP,
|
||
/// which every client also sees) and the public announcements of this round. It never reads
|
||
/// drafts, gold, unannounced bookings or bot notes, so host and guest compute the same level.
|
||
/// Positions are projected onto the lane, because a network guest has positions but no lane
|
||
/// progress.
|
||
///
|
||
/// RUHIG nothing below applies
|
||
/// UMKÄMPFT a lane tower is below 85 %, an enemy attack champion is announced here, or
|
||
/// enemy units stand in the own half of the lane
|
||
/// UNTER DRUCK the Grenzwacht fell or is below 50 %, enemies attack the Grenzwacht, the enemy
|
||
/// has more than 1.5 times the own unit HP in the own half, or the enemy boss
|
||
/// is deployed on this lane
|
||
/// KRITISCH both lane towers fell (base open), the Bastion is below 50 % after the
|
||
/// Grenzwacht fell, enemies attack the Bastion, or the enemy boss comes while the
|
||
/// Grenzwacht is already down
|
||
/// </summary>
|
||
public static class LuneLaneThreat
|
||
{
|
||
public const int Calm = 0;
|
||
public const int Contested = 1;
|
||
public const int Pressure = 2;
|
||
public const int Critical = 3;
|
||
public static readonly string[] Names = { "RUHIG", "UMKÄMPFT", "UNTER DRUCK", "KRITISCH" };
|
||
public static readonly string[] Glyphs = { "check", "melee", "warning", "warning" };
|
||
|
||
public sealed class Result
|
||
{
|
||
public int Level;
|
||
public string Name = "";
|
||
public readonly List<string> Reasons = new List<string>();
|
||
}
|
||
|
||
public sealed class Battle
|
||
{
|
||
public double Length;
|
||
public int OwnCount;
|
||
public int EnemyCount;
|
||
public double OwnHp;
|
||
public double EnemyHp;
|
||
/// <summary>Deepest enemy position measured from the own base along the lane (infinity without enemies).</summary>
|
||
public double EnemyDepth = double.PositiveInfinity;
|
||
public double OwnFront;
|
||
}
|
||
|
||
static void Building(LuneMatch state, string id, out bool alive, out double fraction)
|
||
{
|
||
double maxHp = state.Map.BuildingDef(id)?.MaxHp ?? 1.0;
|
||
if (state.Phase == Phase.Combat && state.Combat != null && state.Combat.Buildings.TryGetValue(id, out LuneUnit unit))
|
||
{
|
||
alive = unit.Alive;
|
||
fraction = alive ? GdMath.Clamp(unit.Hp / GdMath.Max(1.0, maxHp), 0.0, 1.0) : 0.0;
|
||
return;
|
||
}
|
||
double hp = state.BuildingHp.GetValueOrDefault(id, 0.0);
|
||
alive = hp > 0.0;
|
||
fraction = GdMath.Clamp(hp / GdMath.Max(1.0, maxHp), 0.0, 1.0);
|
||
}
|
||
|
||
static string Percent(double value)
|
||
{
|
||
return LuneUiTheme.Round(value * 100.0) + " %";
|
||
}
|
||
|
||
public static Result Evaluate(LuneMatch state, int team, string lane, ICollection<int> hidden = null)
|
||
{
|
||
var result = new Result();
|
||
int level = Calm;
|
||
string prefix = team == 0 ? "blau" : "rot";
|
||
int enemy = 1 - team;
|
||
Building(state, prefix + "_" + lane + "_outer", out bool outerAlive, out double outer);
|
||
Building(state, prefix + "_" + lane + "_inner", out bool innerAlive, out double inner);
|
||
if (!outerAlive && !innerAlive)
|
||
{
|
||
level = Critical;
|
||
result.Reasons.Add("Grenzwacht und Bastion gefallen – der Weg zur Basis ist offen");
|
||
}
|
||
else if (!outerAlive)
|
||
{
|
||
level = Pressure;
|
||
result.Reasons.Add("Grenzwacht gefallen");
|
||
if (inner < .5)
|
||
{
|
||
level = Critical;
|
||
result.Reasons.Add("Bastion bei " + Percent(inner));
|
||
}
|
||
else if (inner < .85)
|
||
{
|
||
result.Reasons.Add("Bastion bei " + Percent(inner));
|
||
}
|
||
}
|
||
else if (outer < .5)
|
||
{
|
||
level = Pressure;
|
||
result.Reasons.Add("Grenzwacht bei " + Percent(outer));
|
||
}
|
||
else if (outer < .85)
|
||
{
|
||
level = Contested;
|
||
result.Reasons.Add("Grenzwacht bei " + Percent(outer));
|
||
}
|
||
foreach (Announcement entry in state.Announcements())
|
||
{
|
||
if (entry.Team != enemy || entry.Lane != lane)
|
||
continue;
|
||
if (entry.Kind == "boss")
|
||
{
|
||
level = System.Math.Max(level, !outerAlive ? Critical : Pressure);
|
||
result.Reasons.Add("Gegnerische Schlundkrone greift hier an");
|
||
}
|
||
else
|
||
{
|
||
level = System.Math.Max(level, Contested);
|
||
result.Reasons.Add("Angriffschampion " + LuneContent.Champions[entry.ChampionId].Name + " angekündigt");
|
||
}
|
||
}
|
||
if (state.Phase == Phase.Combat && state.Combat != null)
|
||
{
|
||
Battle battle = Battlefield(state, team, lane, hidden);
|
||
double length = battle.Length;
|
||
if (battle.EnemyCount > 0)
|
||
{
|
||
double deepest = battle.EnemyDepth;
|
||
double innerAt = length * state.Balance.InnerTowerFraction;
|
||
double outerAt = length * state.Balance.OuterTowerFraction;
|
||
double reach = state.Balance.TowerRange * .8;
|
||
if (deepest <= innerAt + reach)
|
||
{
|
||
level = Critical;
|
||
result.Reasons.Add("Gegner greifen die Bastion oder die Basis an");
|
||
}
|
||
else if (deepest <= outerAt + reach && outerAlive)
|
||
{
|
||
level = System.Math.Max(level, Pressure);
|
||
result.Reasons.Add("Gegner greifen die Grenzwacht an");
|
||
}
|
||
else if (deepest <= length * .5)
|
||
{
|
||
level = System.Math.Max(level, Contested);
|
||
result.Reasons.Add("Gegner in der eigenen Lanehälfte");
|
||
}
|
||
if (battle.EnemyHp > battle.OwnHp * 1.5 && deepest <= length * .5)
|
||
{
|
||
level = System.Math.Max(level, Pressure);
|
||
result.Reasons.Add(string.Format("Übermacht: {0} gegen {1} Einheiten", battle.EnemyCount, battle.OwnCount));
|
||
}
|
||
}
|
||
}
|
||
result.Level = level;
|
||
result.Name = Names[level];
|
||
return result;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Units of both sides on one lane: counts, HP sums and the deepest enemy position measured
|
||
/// from the own base along the lane (from positions only).
|
||
/// </summary>
|
||
/// <summary>Units on the lane in combat; S3: enemies in the fog (hidden) are not counted.</summary>
|
||
public static Battle Battlefield(LuneMatch state, int team, string lane, ICollection<int> hidden = null)
|
||
{
|
||
LanePath path = state.Map.LanePaths[lane];
|
||
var result = new Battle { Length = path.Length };
|
||
if (state.Combat == null)
|
||
return result;
|
||
foreach (LuneUnit unit in state.Combat.Units)
|
||
{
|
||
if (!unit.Alive || !unit.IsMobile() || unit.Zone != "lane" || unit.Lane != lane || unit.Team > 1)
|
||
continue;
|
||
if (hidden != null && hidden.Contains(unit.Id))
|
||
continue;
|
||
double s = path.Project(unit.Position);
|
||
double fromOwnBase = team == 0 ? s : path.Length - s;
|
||
if (unit.Team == team)
|
||
{
|
||
result.OwnCount++;
|
||
result.OwnHp += unit.Hp;
|
||
result.OwnFront = GdMath.Max(result.OwnFront, fromOwnBase);
|
||
}
|
||
else
|
||
{
|
||
result.EnemyCount++;
|
||
result.EnemyHp += unit.Hp;
|
||
result.EnemyDepth = GdMath.Min(result.EnemyDepth, fromOwnBase);
|
||
}
|
||
}
|
||
return result;
|
||
}
|
||
}
|
||
}
|