using System.Collections.Generic; using System.Globalization; using System.Text; using Luneraria.Core; namespace Luneraria.UI { /// Network state the HUD shows (role, connection); implemented by the session in U4. public interface ILuneHudSession { bool IsNetworked { get; } bool IsAuthority { get; } } /// /// What one seat may see, prepared for display (port of scripts/luneraria/ui/lune_hud_model.gd). /// Every HUD element reads through this model, so the privacy rules live in one place: /// - gold, draft, undo and uncommitted bookings exist only for the viewer's own seat; /// - of other players the model exposes names, lanes, readiness, human/bot/offline and the /// committed hero (public since the last combat), never their gold or current plans; /// - lanes, buildings, the boss and the waves come from public state only. /// The model never changes the match; commands still go through the game controller. /// public sealed class LuneHudModel { public static readonly Dictionary TowerNames = new Dictionary { { "outer", "Grenzwacht" }, { "inner", "Bastion" }, { "base", "Kernwacht" }, { "core", "Ancient" } }; public static readonly Dictionary TierText = new Dictionary { { "outer", "Außenturm" }, { "inner", "Innenturm" }, { "base", "Basisturm" }, { "core", "Ancient" } }; public readonly LuneMatch State; public readonly int Slot; /// Optional network session; only read for the display (role, connection). public ILuneHudSession Session; readonly HashSet _wasHuman = new HashSet(); public LuneHudModel(LuneMatch state, int viewer, ILuneHudSession session = null) { State = state; Slot = System.Math.Max(0, viewer); Session = session; } // ---------------------------------------------------------------- phase and own seat public bool Planning { get { return State.Phase == Phase.Planning; } } public bool Fighting { get { return State.Phase == Phase.Combat && State.Combat != null; } } public bool Over { get { return State.Phase == Phase.Over; } } public Player Me { get { return State.Players[Slot]; } } public int Team { get { return Me.Team; } } public int Enemy { get { return 1 - Team; } } public int OwnGold { get { return State.GoldOf(Slot); } } /// Base income paid at the end of the current round's combat (rules: LuneBalance.RoundIncome). public int Income { get { return State.Balance.RoundIncome(State.RoundNumber) + IncomeBonus; } } /// S1: the own permanent champion income (Legion TD 2), 0 under the reference rules. public int IncomeBonus { get { return State.Extended ? Me.IncomeBonus : 0; } } /// After Ready the plan is binding (the host refuses later commands). public bool Locked { get { return Planning && Me.Ready; } } public List OwnRoster { get { return Planning ? Me.Draft.Roster : Me.Roster; } } /// The own hero: the draft in planning, the committed hero otherwise; null without a hero. public HeroState OwnHero { get { return Planning ? Me.Draft.Hero : Me.Hero; } } public bool CanUndo { get { return Planning && !Locked && Me.Undo.Count > 0; } } public int SupplyCap { get { return State.Balance.SupplyCap; } } public bool Networked { get { return Session != null && Session.IsNetworked; } } public bool IsGuest { get { return Networked && !Session.IsAuthority; } } public sealed class BookingView { public string Uid = ""; public string ChampionId = ""; public string Lane = ""; public int DeployRound; public int Cost; /// Booked in this planning (still refundable). public bool Fresh; public override string ToString() { return Uid + ":" + ChampionId + ":" + Lane + ":" + DeployRound + ":" + Fresh; } } public List OwnBookings() { var result = new List(); if (Planning) { foreach (Booking booking in Me.Draft.NewBookings) result.Add(new BookingView { Uid = booking.Uid, ChampionId = booking.ChampionId, Lane = booking.Lane, Cost = booking.Cost, DeployRound = State.RoundNumber + 1, Fresh = true }); } foreach (Booking booking in Me.Bookings) { if (booking.DeployRound >= State.RoundNumber) result.Add(new BookingView { Uid = booking.Uid, ChampionId = booking.ChampionId, Lane = booking.Lane, Cost = booking.Cost, DeployRound = booking.DeployRound }); } return result; } // ---------------------------------------------------------------- units and heroes public string RoleOf(string typeId) { return State.RoleOf(typeId); } public sealed class TroopInfo { public UnitDef Def; public string Role = ""; public int Stage; } /// Catalogue definition of a troop with its role and stage. public TroopInfo Troop(string typeId) { return new TroopInfo { Def = DataCatalog.GetUnit(typeId), Role = RoleOf(typeId), Stage = LuneStats.StageOf(typeId) }; } /// Live combat unit for a persistent uid of the viewer (or null). public LuneUnit LiveUnit(string uid) { if (!Fighting || string.IsNullOrEmpty(uid)) return null; foreach (LuneUnit unit in State.Combat.Units) { if (unit.PersistentUid == uid) return unit; } return null; } public UnitDef HeroDefinition(HeroState hero) { UnitDef definition = LuneContent.HeroDefinition(hero); definition.Role = ContentData.HeroRoles.GetValueOrDefault(hero.HeroId, "front"); return definition; } // ---------------------------------------------------------------- buildings public sealed class BuildingInfo { public string Id = ""; public int Team; public string Tier = "outer"; public string Lane = ""; public bool Alive; public double Hp; public double MaxHp = 1.0; public double Fraction; public bool Attackable; public BuildingInfo[] Base; public override string ToString() { return Id + ":" + Alive + ":" + LuneUiTheme.Round(Hp) + ":" + Attackable; } } public BuildingInfo Building(string id) { BuildingDef definition = State.Map.BuildingDef(id); double maxHp = definition != null ? definition.MaxHp : 1.0; double hp = State.BuildingHp.GetValueOrDefault(id, 0.0); bool alive = hp > 0.0; if (Fighting && State.Combat.Buildings.TryGetValue(id, out LuneUnit unit)) { alive = unit.Alive; hp = alive ? unit.Hp : 0.0; } return new BuildingInfo { Id = id, Team = definition != null ? definition.Team : 0, Tier = definition != null ? definition.Tier : "outer", Lane = definition != null ? definition.Lane : "", Alive = alive, Hp = hp, MaxHp = maxHp, Fraction = GdMath.Clamp(hp / GdMath.Max(1.0, maxHp), 0.0, 1.0), Attackable = alive && definition != null && LuneMap.IsAttackable(definition, AliveMap()), }; } Dictionary AliveMap() { if (Fighting) return State.Combat.AliveMap(); var result = new Dictionary(); foreach (KeyValuePair pair in State.BuildingHp) result[pair.Key] = pair.Value > 0.0; return result; } public static string BuildingTitle(string id) { string[] parts = id.Split('_'); if (parts[1] == "ancient") return "Ancient"; if (parts[1] == "base") return "Kernwacht"; return TowerNames.TryGetValue(parts[2], out string name) ? name : id; } public static string BuildingPlace(string id) { string[] parts = id.Split('_'); string team = parts[0] == "blau" ? "Blau" : "Rot"; if (parts[1] == "ancient") return "Ancient " + team; if (parts[1] == "base") return string.Format("Basis {0} · Kernwacht {1}", team, parts[2]); return string.Format("{0} · {1} {2}", LuneMap.LaneNames[parts[1]].ToUpperInvariant(), BuildingTitle(id), team); } /// A team's Ancient with its two base towers (Kernwacht) in Base. public BuildingInfo Ancient(int team) { string prefix = team == 0 ? "blau" : "rot"; BuildingInfo core = Building(prefix + "_ancient"); core.Base = new[] { Building(prefix + "_base_1"), Building(prefix + "_base_2") }; return core; } // ---------------------------------------------------------------- players public string PlayerName(int seat) { return seat == Slot ? "Du" : State.Players[seat].Name; } /// Seat status for display: self, human, bot, or offline (was human, a bot plays the seat). public string StatusOf(int seat) { Player player = State.Players[seat]; if (player.IsHuman) _wasHuman.Add(seat); if (seat == Slot) return "self"; if (player.IsHuman) return "human"; return _wasHuman.Contains(seat) ? "offline" : "bot"; } public sealed class MemberInfo { public int Slot; public string Name = ""; public string Lane = ""; public bool IsSelf; public bool Ready; public string Status = ""; public string Bund = ""; public string HeroId = ""; public int HeroLevel; public override string ToString() { return Slot + ":" + Name + ":" + Lane + ":" + IsSelf + ":" + Ready + ":" + Status + ":" + Bund + ":" + HeroId + ":" + HeroLevel; } } public List Members(int team) { var result = new List(); for (int seat = team * 3; seat < team * 3 + 3; seat++) { Player player = State.Players[seat]; // Own seat: current draft. Everyone else: the committed hero, public since the last combat. HeroState hero = seat == Slot ? OwnHero : player.Hero; result.Add(new MemberInfo { Slot = seat, Name = PlayerName(seat), Lane = player.Lane, IsSelf = seat == Slot, Ready = player.Ready, Status = StatusOf(seat), Bund = player.Bund, HeroId = hero != null ? hero.HeroId : "", HeroLevel = hero != null ? LuneContent.LevelFor(hero.Xp) : 0, }); } return result; } /// Ready humans and humans in the match. public void ReadyCount(out int ready, out int humans) { ready = 0; humans = 0; foreach (Player player in State.Players) { if (!player.IsHuman) continue; humans++; if (player.Ready) ready++; } } // ---------------------------------------------------------------- lanes public sealed class HeroBrief { public string Model = ""; public string Name = ""; public int Level; public override string ToString() { return Model + ":" + Level; } } public sealed class LaneInfo { public string Lane = ""; public string Title = ""; public string OwnPlayer = ""; public string EnemyPlayer = ""; public bool OwnIsMe; /// Own inner, own outer, enemy outer, enemy inner: the chain from the own base to the enemy's. public BuildingInfo[] Towers; public LuneLaneThreat.Result Threat; public readonly List Dangers = new List(); public HeroBrief OwnHero; public HeroBrief EnemyHero; /// Unit counts exist only in combat. public bool HasCounts; public int OwnCount; public int EnemyCount; /// S1 barracks: own waves reinforced here (enemy inner tower fell) / enemy waves reinforced. public bool OwnBarracks; public bool EnemyBarracks; } public LaneInfo Lane(string lane) { int ownSeat = State.SlotOf(Team, lane); int enemySeat = State.SlotOf(Enemy, lane); string own = Team == 0 ? "blau" : "rot"; string enemy = Team == 0 ? "rot" : "blau"; var result = new LaneInfo { Lane = lane, Title = LuneMap.LaneNames[lane].ToUpperInvariant(), OwnPlayer = PlayerName(ownSeat), EnemyPlayer = PlayerName(enemySeat), OwnIsMe = ownSeat == Slot, Towers = new[] { Building(own + "_" + lane + "_inner"), Building(own + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_outer"), Building(enemy + "_" + lane + "_inner") }, Threat = LuneLaneThreat.Evaluate(State, Team, lane), OwnHero = HeroOf(ownSeat), EnemyHero = HeroOf(enemySeat), OwnBarracks = State.Extended && State.BarracksBroken(Team, lane), EnemyBarracks = State.Extended && State.BarracksBroken(Enemy, lane), }; foreach (Announcement entry in State.Announcements()) { if (entry.Lane == lane) result.Dangers.Add(entry); } if (Fighting) { LuneLaneThreat.Battle battle = LuneLaneThreat.Battlefield(State, Team, lane); result.HasCounts = true; result.OwnCount = battle.OwnCount; result.EnemyCount = battle.EnemyCount; } return result; } /// A seat's hero for display: the own draft for the viewer, the committed (public) hero for everyone else; null without a hero. public HeroBrief HeroOf(int seat) { HeroState hero = seat == Slot ? OwnHero : State.Players[seat].Hero; if (hero == null) return null; HeroSpec spec = LuneContent.Heroes[hero.HeroId]; return new HeroBrief { Model = spec.Model, Name = spec.Name, Level = LuneContent.LevelFor(hero.Xp) }; } // ---------------------------------------------------------------- boss grotto public sealed class BossInfo { /// available, contested, conquered, failed, deploy_next, deploying, defeated or cooldown. public string State = "cooldown"; public int Team = -1; public string Lane = ""; public int DeployRound; public int WonRound; public int Bounty; public int RoundsLeft; public bool Own; public bool Chooser; public int NextRound; public bool HasHp; public double Hp; public double MaxHp = 1.0; /// The public part (without the viewer's own/chooser flags). public string PublicText() { return string.Format(CultureInfo.InvariantCulture, "{0}:{1}:{2}:{3}:{4}:{5}:{6}:{7}:{8}", State, Team, Lane, DeployRound, WonRound, Bounty, RoundsLeft, NextRound, HasHp ? LuneUiTheme.Round(Hp) + "/" + LuneUiTheme.Round(MaxHp) : "-"); } public override string ToString() { return PublicText() + ":" + Own + ":" + Chooser; } } /// /// Public boss state: available, contested, conquered, failed, deploy_next, deploying, /// defeated, cooldown. On a guest the grotto fight is read from the mirrored units. The /// chooser sees the lane of the own draft; everyone else the public claim lane. /// public BossInfo Boss() { return Boss(false); } BossInfo Boss(bool publicOnly) { BossClaim claim = State.Boss.Claim; int round = State.RoundNumber; var result = new BossInfo { RoundsLeft = System.Math.Max(0, State.Boss.NextAvailableRound - round), NextRound = State.Boss.NextAvailableRound }; if (Fighting) { foreach (LuneUnit unit in State.Combat.Units) { if (unit.Kind != "boss") continue; if (unit.Zone == "grotto") { if (unit.Alive) { result.State = "contested"; return result; } int winner = -1; foreach (LuneUnit other in State.Combat.Units) { if (other.Alive && other.Zone == "grotto" && other.Team <= 1) winner = other.Team; } result.State = winner >= 0 ? "conquered" : "failed"; result.Team = winner; result.Own = winner == Team; result.DeployRound = round + 1; return result; } if (unit.Zone == "lane") { result.State = unit.Alive ? "deploying" : "defeated"; result.Team = unit.Team; result.Own = unit.Team == Team; result.Lane = unit.Lane; result.DeployRound = round; result.Bounty = LuneContent.BossBountyFor(round); result.HasHp = true; result.Hp = unit.Hp; result.MaxHp = unit.MaxHp; return result; } } } if (claim != null && claim.DeployRound >= round) { result.State = Planning ? "deploy_next" : "deploying"; result.Team = claim.Team; result.Own = claim.Team == Team; result.WonRound = claim.WonRound; result.DeployRound = claim.DeployRound; result.Bounty = LuneContent.BossBountyFor(claim.DeployRound); result.Chooser = claim.ChooserSlot == Slot; // The chooser's lane change is private until combat; others see the public claim lane. string chosen = !publicOnly && result.Chooser && Planning ? Me.Draft.BossLane ?? "" : ""; result.Lane = chosen.Length > 0 ? chosen : claim.Lane; return result; } if (State.GrottoAvailable()) result.State = "available"; return result; } // ---------------------------------------------------------------- waves public sealed class MonsterGroup { public string Id = ""; public string Name = ""; public int Count; public double Hp; public double Damage; public double Range; public bool New; public int Armor; public int Attack; public override string ToString() { return Id + "x" + Count + (New ? "+" : ""); } } public sealed class ChampionNotice { public int Team; public string Lane = ""; public string ChampionId = ""; public string Name = ""; public bool Own; public override string ToString() { return Team + ":" + Lane + ":" + ChampionId; } } public sealed class BossNotice { public int Team; public string Lane = ""; public int Bounty; public bool Own; public override string ToString() { return Team + ":" + Lane + ":" + Bounty; } } public sealed class WaveInfo { public int Round; public readonly List Monsters = new List(); public int Count; public readonly List Champions = new List(); public BossNotice Boss; public override string ToString() { return Round + "|" + string.Join(",", Monsters) + "|" + Count + "|" + string.Join(",", Champions) + "|" + (Boss != null ? Boss.ToString() : "-"); } } /// /// The wave of the coming combat (planning) or the one after (combat). Monsters are public /// and identical for both sides; champions and the boss only once they are announced. /// public WaveInfo NextWave() { int round = Fighting ? State.RoundNumber + 1 : State.RoundNumber; List wave = LuneWaves.Build(State.Balance, round); List before = round > 1 ? LuneWaves.UnlockedTypes(State.Balance, round - 1) : new List(); var result = new WaveInfo { Round = round, Count = wave.Count }; var groups = new Dictionary(); foreach (UnitDef monster in wave) { if (!groups.TryGetValue(monster.Id, out MonsterGroup group)) { group = new MonsterGroup { Id = monster.Id, Name = monster.DisplayName, Hp = monster.MaxHp, Damage = monster.Damage, Range = monster.AttackRange, New = round > 1 && !before.Contains(monster.Id), Armor = monster.ArmorType, Attack = monster.AttackType, }; groups[monster.Id] = group; result.Monsters.Add(group); } group.Count++; } if (!Fighting) { foreach (Announcement entry in State.Announcements()) { if (entry.Kind == "boss") result.Boss = new BossNotice { Team = entry.Team, Lane = entry.Lane, Bounty = entry.Bounty, Own = entry.Team == Team }; else result.Champions.Add(new ChampionNotice { Team = entry.Team, Lane = entry.Lane, ChampionId = entry.ChampionId, Name = LuneContent.Champions[entry.ChampionId].Name, Own = entry.Team == Team }); } } return result; } // ---------------------------------------------------------------- network test support /// /// Everything the HUD shows that must be identical for every seat (host, guest, bot views), /// as one string. The boss uses the public claim lane: the chooser's draft lane is private. /// public string PublicDigest() { var text = new StringBuilder(); text.Append("round ").Append(State.RoundNumber).Append(" phase ").Append(State.Phase).Append('\n'); foreach (BuildingDef definition in State.Map.Buildings) { BuildingInfo entry = Building(definition.Id); text.Append(definition.Id).Append('=').Append(entry.Alive).Append('/').Append(LuneUiTheme.Round(entry.Hp)).Append(' '); } text.Append('\n'); foreach (string name in LuneMap.Lanes) { for (int side = 0; side < 2; side++) { LuneLaneThreat.Result threat = LuneLaneThreat.Evaluate(State, side, name); text.Append(name).Append('/').Append(side).Append('=').Append(threat.Level).Append(' ').Append(string.Join(";", threat.Reasons)).Append('\n'); } } foreach (Player player in State.Players) { text.Append(player.Slot).Append(':').Append(player.Lane).Append(':').Append(player.Ready).Append(':').Append(player.IsHuman).Append(':') .Append(player.Hero != null ? player.Hero.HeroId + "@" + player.Hero.Xp : "-").Append(' '); } text.Append('\n').Append("boss ").Append(Boss(true).PublicText()).Append('\n'); text.Append("wave ").Append(NextWave()).Append('\n'); return text.ToString(); } } }