using System.Numerics; using Timers = ACE.Server.Entity.Timers; namespace Aeshnidae.Invasions; public enum Stage { /// Announced; the siege has not begun. Warning, /// Bands keep arriving until SiegeEndsAt. Siege, /// The leader is in town; it must die by BossDeadline. Boss, } /// One monster sent at the town. public sealed class Invader { public required Creature Creature { get; init; } public uint Guid { get; init; } public string Name { get; init; } = ""; /// Which band brought it (1, 2, ...); 0 = the boss wave. public int Band { get; init; } public bool Boss { get; init; } public bool Dead { get; set; } /// Left the world without dying: its landblock unloaded, an admin deleted it, its lifespan ran out. public bool Gone { get; set; } public bool Done => Dead || Gone; /// Where it walks once MarchAt comes: a point near the town centre, made its home. public Position? March { get; init; } public DateTime MarchAt { get; init; } public bool Marched { get; set; } public DateTime SpawnedAt { get; init; } public bool ArrivalShown { get; set; } } /// A player who has hurt an invader, and how much, per invader (the highest ACE recorded). public sealed class Defender { public uint Guid { get; init; } public string Name { get; set; } = ""; public Dictionary ByInvader { get; } = new(); public float Total => ByInvader.Values.Sum(); } /// The invasion in progress. One at a time, server-wide. public sealed class Run { public required Town Town { get; init; } public required WaveSet Set { get; init; } public Vector2 Center { get; init; } public string StartedBy { get; init; } = ""; public Stage Stage { get; set; } = Stage.Warning; public DateTime WarnedAt { get; init; } public DateTime AttackAt { get; init; } public DateTime SiegeEndsAt { get; set; } public DateTime NextBandAt { get; set; } public DateTime NextUpdateAt { get; set; } public DateTime? BossDeadline { get; set; } public HashSet RemindersSent { get; } = new(); public int Bands { get; set; } public int BandsHeldBack { get; set; } public List Invaders { get; } = new(); public Dictionary Defenders { get; } = new(); public int Killed { get; set; } public bool BossKilled { get; set; } public string BossName { get; set; } = ""; public int Standing => Invaders.Count(i => !i.Done); } /// /// The scheduler and the invasion state machine. Everything that touches the world runs on /// the world thread, from Patches.PostTick (LandblockManager.Tick); commands, which may /// arrive on the console's thread, only queue work for it (Enqueue). /// /// An invasion is a siege of fixed length (Tom, 2026-09-27, after the first live one was over /// in two minutes: "hold the town"): a band every BandEverySeconds from a random side, sized by /// the defenders in town, for SiegeMinutes; then the boss and its escort, who must die within /// BossMinutes. Killing fast kills more; it does not end the siege early. /// public static class Invasions { public static Run? Current { get; private set; } public static DateTime NextScheduled { get; private set; } = DateTime.MaxValue; /// Town name -> when its last invasion ended (cooldown for the timer). public static Dictionary LastInvaded { get; } = new(StringComparer.OrdinalIgnoreCase); public static string LastResult { get; private set; } = "none since the mod loaded"; public static long Ticks; private static DateTime _lastStep = DateTime.MinValue; private static readonly ConcurrentQueue _pending = new(); private static Settings S => Mod.Settings; public static void Initialize() { Current = null; NextScheduled = DateTime.UtcNow.AddMinutes(S.FirstDelayMinutes); } /// Run on the world thread at the next tick. public static void Enqueue(Action action) => _pending.Enqueue(action); public static void Tick() { Ticks++; while (_pending.TryDequeue(out var action)) { try { action(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] queued action failed: {ex}", ModManager.LogLevel.Error); } } var now = DateTime.UtcNow; if ((now - _lastStep).TotalSeconds < 1) return; _lastStep = now; Effects.Tick(now); if (Current is not null) { Step(Current, now); return; } if (S.Enabled && S.AutoSchedule && now >= NextScheduled) TrySchedule(now); } // --- Scheduling ------------------------------------------------------------------------ /// Players standing (outdoors) within the town's radius. public static List PlayersNear(Vector2 center, double radius) => PlayerManager.GetAllOnline() .Where(p => p is not null && !p.IsDestroyed && Placement.Distance(p.Location, center) <= radius) .ToList(); /// Why a town cannot be invaded by the timer right now, or null if it can. public static string? WhyNot(Town town, DateTime now, bool ignoreCooldownAndPlayers = false) { if (!S.WaveSets.TryGetValue(town.WaveSet, out var set) || set.Bands.Count == 0 || !set.Boss.Any(b => b.Boss)) return $"its wave set '{town.WaveSet}' does not exist, has no bands, or has no boss"; var center = Placement.ParseMapCoords(town.Center); if (center is null) return $"its centre '{town.Center}' is not map coordinates like 42.0N 33.5E"; if (ignoreCooldownAndPlayers) return null; if (!town.Enabled) return "it is switched off for the timer"; if (LastInvaded.TryGetValue(town.Name, out var last) && now < last.AddMinutes(S.TownCooldownMinutes)) return $"cooldown until {last.AddMinutes(S.TownCooldownMinutes):HH:mm} UTC"; var here = PlayersNear(center.Value, town.TownRadius).Count; if (here < S.MinPlayersInTown) return $"needs at least {S.MinPlayersInTown}"; return null; } private static void TrySchedule(DateTime now) { var eligible = S.Towns.Where(t => WhyNot(t, now) is null).ToList(); if (eligible.Count == 0) { NextScheduled = now.AddMinutes(S.RetryMinutes); ModManager.Log($"[{Mod.Name}] timer due, no town qualifies; looking again at {NextScheduled:HH:mm} UTC"); return; } var town = eligible[Random.Shared.Next(eligible.Count)]; Begin(town, "the timer", TimeSpan.FromMinutes(S.WarningMinutes)); } private static void ScheduleNext(DateTime now) { var jitter = (Random.Shared.NextDouble() * 2 - 1) * S.JitterMinutes; NextScheduled = now.AddMinutes(Math.Max(1, S.IntervalMinutes + jitter)); } /// /invasion-next: set the timer by hand. public static void SetNext(DateTime when) => NextScheduled = when; // --- Start ----------------------------------------------------------------------------- /// Warn the world and start the clock on an invasion of the town. World thread. public static string Begin(Town town, string startedBy, TimeSpan warning) { if (Current is not null) return $"An invasion of {Current.Town.Name} is already running."; if (!S.Enabled) return "Invasions are switched off (Enabled false)."; var now = DateTime.UtcNow; var why = WhyNot(town, now, ignoreCooldownAndPlayers: true); if (why is not null) return $"{town.Name} cannot be invaded: {why}."; var run = new Run { Town = town, Set = S.WaveSets[town.WaveSet], Center = Placement.ParseMapCoords(town.Center)!.Value, StartedBy = startedBy, WarnedAt = now, AttackAt = now + warning, }; Current = run; var minutes = warning.TotalMinutes; var when = minutes >= 1.5 ? $"in {Math.Round(minutes):0} minutes" : minutes > 0 ? "in moments" : "now"; var text = $"{run.Set.Invaders} are massing outside {town.Name} ({town.Center})! They attack {when}. Defenders are paid if the town holds."; ToWorld(text); Discord.Post($":crossed_swords: {text}"); ModManager.Log($"[{Mod.Name}] invasion of {town.Name} begun by {startedBy}: set '{town.WaveSet}', siege {S.SiegeMinutes:0} min, attack at {run.AttackAt:HH:mm:ss} UTC, " + $"{PlayersNear(run.Center, town.TownRadius).Count} player(s) in town"); return $"Invasion of {town.Name} begun; the siege starts {when}."; } // --- The state machine ----------------------------------------------------------------- private static void Step(Run run, DateTime now) { if (!S.Enabled) { Fail(run, "Invasions were switched off", quiet: false); return; } if (run.Stage == Stage.Warning) { foreach (var m in S.WarningReminders.OrderByDescending(m => m)) { var left = (run.AttackAt - now).TotalMinutes; if (left <= m && left > 0 && (run.AttackAt - run.WarnedAt).TotalMinutes > m && run.RemindersSent.Add(m)) ToWorld($"{run.Set.Invaders} attack {run.Town.Name} in {(m >= 1 ? $"{m:0} minute{(m == 1 ? "" : "s")}" : "moments")}!"); } if (now >= run.AttackAt) StartSiege(run, now); return; } Watch(run, now); if (Current != run) return; if (run.BossKilled) { Win(run); return; } if (run.Stage == Stage.Siege) { if (now >= run.SiegeEndsAt) { SendBoss(run, now); return; } if (now >= run.NextBandAt) { run.NextBandAt = now.AddSeconds(Math.Max(5, S.BandEverySeconds)); SendBand(run, now); } if (now >= run.NextUpdateAt) { run.NextUpdateAt = now.AddSeconds(Math.Max(30, S.UpdateEverySeconds)); ToTown(run, $"{Span(run.SiegeEndsAt - now)} until their leader comes. {run.Killed} {run.Set.Invaders.ToLowerInvariant()} slain so far, {run.Standing} in town."); } return; } // Stage.Boss if (run.BossDeadline is { } deadline && now >= deadline) { Fail(run, $"{BossText(run)} was not killed in time", quiet: false); return; } if (!run.Invaders.Any(i => i.Boss && !i.Done)) Fail(run, $"{BossText(run)} slipped away", quiet: false); } private static string BossText(Run run) => string.IsNullOrEmpty(run.BossName) ? "their leader" : run.BossName; private static void StartSiege(Run run, DateTime now) { run.Stage = Stage.Siege; run.SiegeEndsAt = now.AddMinutes(S.SiegeMinutes); run.NextBandAt = now.AddSeconds(Math.Max(5, S.BandEverySeconds)); run.NextUpdateAt = now.AddSeconds(Math.Max(30, S.UpdateEverySeconds)); ToWorld($"{run.Set.Invaders} are attacking {run.Town.Name}!"); ToTown(run, $"Hold {run.Town.Name}! {run.Set.Invaders} will keep coming for {S.SiegeMinutes:0} minutes, then their leader. Kill the leader to save the town."); SendBand(run, now); } /// One band from one side of town, sized by the defenders in town; held back while MaxStanding stand. private static void SendBand(Run run, DateTime now) { if (run.Standing >= S.MaxStanding) { run.BandsHeldBack++; return; } var band = PickBand(run.Set); if (band is null) return; var here = PlayersNear(run.Center, run.Town.TownRadius).Count; var wcids = Expand(band.Spawns, here).Take(Math.Max(0, S.MaxStanding - run.Standing)).ToList(); run.Bands++; var angle = Random.Shared.NextDouble() * Math.PI * 2; var placed = Place(run, wcids, run.Bands, angle, now); ModManager.Log($"[{Mod.Name}] {run.Town.Name} band {run.Bands}: {placed}/{wcids.Count} placed from the {Direction(angle)}, {here} player(s) in town, {run.Standing} standing"); if (placed > 0) ToTown(run, $"{placed} {run.Set.Invaders.ToLowerInvariant()} come from the {Direction(angle)}."); } private static void SendBoss(Run run, DateTime now) { run.Stage = Stage.Boss; run.BossDeadline = now.AddMinutes(S.BossMinutes); var here = PlayersNear(run.Center, run.Town.TownRadius).Count; var wcids = Expand(run.Set.Boss, here).ToList(); var angle = Random.Shared.NextDouble() * Math.PI * 2; var placed = Place(run, wcids, 0, angle, now); ModManager.Log($"[{Mod.Name}] {run.Town.Name} boss wave: {placed}/{wcids.Count} placed from the {Direction(angle)}, {here} player(s) in town, {run.Standing} standing"); if (!run.Invaders.Any(i => i.Boss && !i.Done)) { Fail(run, "their leader could not find a way in (nothing could be placed - check the town's centre and radii)", quiet: false); return; } ToWorld($"{BossText(run)} leads the last assault on {run.Town.Name}!"); ToTown(run, $"{BossText(run)} comes from the {Direction(angle)} with {Math.Max(0, placed - 1)} more. Kill {BossText(run)} within {S.BossMinutes:0} minutes to save the town."); } private static Band? PickBand(WaveSet set) { var total = set.Bands.Sum(b => Math.Max(0, b.Weight)); if (total <= 0) return set.Bands.FirstOrDefault(); var roll = Random.Shared.NextDouble() * total; foreach (var b in set.Bands) { roll -= Math.Max(0, b.Weight); if (roll <= 0) return b; } return set.Bands[^1]; } /// Spawns -> one (wcid, boss) per creature: Count plus PerExtraPlayer per player past the first. Bosses first. private static IEnumerable<(uint Wcid, bool Boss)> Expand(List spawns, int playersInTown) { var extra = Math.Max(0, playersInTown - 1); foreach (var s in spawns.OrderByDescending(s => s.Boss)) { var n = s.Boss ? s.Count : s.Count + (int)Math.Floor(s.PerExtraPlayer * extra); for (var i = 0; i < n; i++) yield return (s.Wcid, s.Boss); } } /// Put creatures on the ground on one side of town and queue their march. Returns how many stood up. private static int Place(Run run, List<(uint Wcid, bool Boss)> wcids, int band, double angle, DateTime now) { var town = run.Town; var spread = Math.Max(0, town.BandSpreadDegrees) * Math.PI / 180; var placed = 0; foreach (var (wcid, boss) in wcids) { var spot = Placement.RingSpot(run.Center, town.SpawnMin, town.SpawnMax, tries: 12, angle: angle, spread: spread) ?? Placement.RingSpot(run.Center, town.SpawnMin, town.SpawnMax, tries: 20); if (spot is null) continue; Creature? creature = null; try { creature = WorldObjectFactory.CreateNewWorldObject(wcid) as Creature; if (creature is null) { ModManager.Log($"[{Mod.Name}] wcid {wcid} is not a creature (or does not exist); skipped", ModManager.LogLevel.Warn); continue; } creature.Location = spot; creature.Lifespan = (int)(S.InvaderLifespanMinutes * 60); if (!creature.EnterWorld()) { creature.Destroy(); continue; } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] spawning wcid {wcid} failed: {ex.Message}", ModManager.LogLevel.Warn); try { creature?.Destroy(); } catch { /* already gone */ } continue; } run.Invaders.Add(new Invader { Creature = creature, Guid = creature.Guid.Full, Name = creature.Name, Band = band, Boss = boss, March = Placement.RingSpot(run.Center, 0, town.MarchRadius, tries: 12), MarchAt = now.AddSeconds(S.MarchDelaySeconds), SpawnedAt = now, }); if (boss) run.BossName = creature.Name; placed++; } return placed; } /// Snapshot the damage, notice invaders that left the world, and send the fresh ones marching. private static void Watch(Run run, DateTime now) { // ACE puts a landblock with no player near it to sleep after a minute - no physics, no // monster thinking - and unloads it later, deleting what stands in it. An invasion // keeps the ground its invaders stand on awake until it ends, so the attack does not // freeze, or evaporate, when the town empties. var woken = new HashSet(); foreach (var inv in run.Invaders) { if (inv.Done) continue; var c = inv.Creature; if (c.IsDead) { MarkDead(run, inv); continue; } // A fresh spawn can sit in its landblock's pending additions, without a landblock, // until that block's next tick; only a long absence means it has gone. if (c.IsDestroyed || (c.CurrentLandblock is null && (now - inv.SpawnedAt).TotalSeconds > 10)) { inv.Gone = true; ModManager.Log($"[{Mod.Name}] {inv.Name} (0x{inv.Guid:X8}) left the world without dying"); continue; } if (c.CurrentLandblock is { } lb && woken.Add(lb)) lb.SetActive(); Snapshot(run, inv); // A second after spawning, once every client near has been sent the creature. if (!inv.ArrivalShown && (now - inv.SpawnedAt).TotalSeconds >= 1) { inv.ArrivalShown = true; Effects.Play(c, inv.Boss ? S.BossEffect : S.ArrivalEffect); } if (!inv.Marched && now >= inv.MarchAt) { inv.Marched = true; if (inv.March is not null) MarchOn(inv); } } } /// /// Make the town centre the invader's home and send it there. ACE's monster AI does the /// rest: an awake monster on its way home still looks for targets on every think, so it /// charges whoever it sees on the way, and after a fight it goes back to the centre - not /// to where it spawned. /// /// The pace: ACE's own MoveToHome, in the gait MarchRun picks and at MarchSpeed times its /// speed. ACE's monster movement parameters forbid walking (GetMovementParameters clears /// CanWalk), so a monster always runs at full speed; here the client is told the same move /// with the gait (run rate 0 clears CanRun for a walk) and the speed multiplier, and the /// server's physics gets the same gait and Speed, so both sides agree. The chase after a /// player, and the return after a fight, are ACE's own - full speed. /// private static void MarchOn(Invader inv) { try { var c = inv.Creature; if (c.PhysicsObj is null) return; var home = new Position(inv.March!); c.Home = home; c.IsAwake = true; var run = S.MarchRun; var speed = (float)Math.Clamp(S.MarchSpeed, 0.1, 3.0); if (run && Math.Abs(speed - 1f) < 0.001f) { c.MoveToHome(); return; } c.MonsterState = Creature.State.Return; c.AttackTarget = null; c.NextCancelTime = Timers.RunningTime + 5.0f; c.MoveTo(home, runRate: run ? c.RunRate : 0, setLoc: false, walkRunThreshold: run ? null : 1000f, speed: speed); var target = new ACE.Server.Physics.Common.Position(home); var mvp = c.GetMovementParameters(); mvp.CanWalk = !run; mvp.CanRun = run; mvp.Speed = speed; mvp.DistanceToObject = 0.6f; mvp.DesiredHeading = target.Frame.get_heading(); c.PhysicsObj.MoveToPosition(target, mvp); c.IsMoving = true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] march failed for {inv.Name} (0x{inv.Guid:X8}): {ex.Message}", ModManager.LogLevel.Warn); } } // --- Kills and damage ------------------------------------------------------------------ /// Patches.PostCreatureDie: an invader died. public static void OnCreatureDie(Creature creature) { var run = Current; if (run is null) return; var inv = run.Invaders.FirstOrDefault(i => i.Guid == creature.Guid.Full); if (inv is null || inv.Done) return; MarkDead(run, inv); } private static void MarkDead(Run run, Invader inv) { Snapshot(run, inv); inv.Dead = true; run.Killed++; if (inv.Boss) { run.BossKilled = true; ModManager.Log($"[{Mod.Name}] {inv.Name} killed at {run.Town.Name}"); } } /// Copy what ACE's damage history says about this invader into the defenders. private static void Snapshot(Run run, Invader inv) { try { var history = inv.Creature.DamageHistory; if (history is null) return; foreach (var info in history.TotalDamage.Values.ToList()) { uint guid; string name; if (info.IsPlayer) { guid = info.Guid.Full; name = info.Name; } else if (info.PetOwner is not null && info.PetOwner.TryGetTarget(out var owner) && owner is not null) { guid = owner.Guid.Full; name = owner.Name; } else { continue; } if (!run.Defenders.TryGetValue(guid, out var d)) run.Defenders[guid] = d = new Defender { Guid = guid, Name = name }; d.ByInvader.TryGetValue(inv.Guid, out var had); if (info.TotalDamage > had) d.ByInvader[inv.Guid] = info.TotalDamage; } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] damage snapshot failed for {inv.Name}: {ex.Message}", ModManager.LogLevel.Warn); } } // --- Endings --------------------------------------------------------------------------- private static void Win(Run run) { var defenders = run.Defenders.Values.Where(d => d.Total >= S.MinContributionDamage).OrderByDescending(d => d.Total).ToList(); var paid = new List(); var offline = new List(); foreach (var d in defenders) { var player = PlayerManager.GetOnlinePlayer(d.Guid); if (player is null) { offline.Add(d.Name); continue; } Reward(run, player, d); Effects.Play(player, S.DefenderEffect); paid.Add(player.Name); } var fireworks = Effects.Celebrate(run.Center); // The survivors withdraw with their leader dead. var withdrawn = DestroyRemaining(run); var top = defenders.FirstOrDefault(); var topText = top is null ? "" : $" Fiercest defender: {top.Name}."; var text = $"{run.Town.Name} holds! {run.Killed} {run.Set.Invaders.ToLowerInvariant()} slain{(run.BossName.Length > 0 ? $", {run.BossName} among them" : "")}. " + $"{defenders.Count} defender{(defenders.Count == 1 ? "" : "s")} rewarded.{topText}"; ToWorld(text); Discord.Post($":shield: {text}"); var below = run.Defenders.Values.Where(d => d.Total < S.MinContributionDamage).Select(d => $"{d.Name} {d.Total:0}").ToList(); ModManager.Log($"[{Mod.Name}] {run.Town.Name} held: {run.Killed} killed, {withdrawn} withdrew, {run.Bands} band(s), {run.BandsHeldBack} held back, {fireworks} firework display(s); paid {(paid.Count == 0 ? "nobody" : string.Join(", ", paid))}" + (offline.Count > 0 ? $"; offline, not paid: {string.Join(", ", offline)}" : "") + (below.Count > 0 ? $"; below {S.MinContributionDamage:0} damage: {string.Join(", ", below)}" : "")); End(run, $"{DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC {run.Town.Name} held - {run.Killed} killed, {defenders.Count} defender(s) paid"); } private static void Reward(Run run, Player player, Defender d) { try { player.GrantLevelProportionalXp(S.BonusPercentOfLevel, S.BonusMinXp, S.BonusMaxXp); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] bonus for {player.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } var tokenText = ""; if (S.TokenWcid > 0 && S.TokensPerDefence > 0) { try { var token = WorldObjectFactory.CreateNewWorldObject(S.TokenWcid); if (token is null) { ModManager.Log($"[{Mod.Name}] token wcid {S.TokenWcid} does not exist", ModManager.LogLevel.Warn); } else { if (S.TokensPerDefence > 1 && (token.MaxStackSize ?? 1) > 1) token.SetStackSize(Math.Min(S.TokensPerDefence, token.MaxStackSize ?? 1)); if (player.TryCreateInInventoryWithNetworking(token)) { tokenText = $" and {token.StackSize ?? 1} {token.Name}"; } else { token.Destroy(); tokenText = " - your pack was full, so no token"; ModManager.Log($"[{Mod.Name}] {player.Name}'s pack was full; token not given", ModManager.LogLevel.Warn); } } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] token for {player.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } } player.SendMessage($"You helped hold {run.Town.Name} ({d.Total:N0} damage to the invaders). You earn a defender's bonus{tokenText}.", ChatMessageType.Broadcast); } private static void Fail(Run run, string reason, bool quiet) { var withdrawn = DestroyRemaining(run); if (!quiet) { var text = run.Stage == Stage.Warning ? $"The invasion of {run.Town.Name} is called off: {reason}." : $"The {run.Set.Invaders.ToLowerInvariant()} withdraw from {run.Town.Name} - {reason}. The town was not saved; no defender's bonus is paid."; ToWorld(text); Discord.Post($":x: {text}"); } ModManager.Log($"[{Mod.Name}] invasion of {run.Town.Name} failed ({reason}): {run.Killed} killed, {withdrawn} withdrew, {run.Defenders.Count} player(s) had fought"); End(run, $"{DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC {run.Town.Name} not held - {reason}; {run.Killed} killed"); } /// /invasion-stop. public static string Stop(string by) { var run = Current; if (run is null) return "No invasion is running."; Fail(run, $"called off by {by}", quiet: false); return $"Invasion of {run.Town.Name} stopped."; } /// /invasion-smite: kill standing invaders, the boss last. World thread. public static string Smite(int? count) { var run = Current; if (run is null || run.Stage == Stage.Warning) return "No invaders are in town."; var targets = run.Invaders.Where(i => !i.Done && !i.Creature.IsDead && !i.Creature.IsDestroyed) .OrderBy(i => i.Boss).ToList(); if (count is { } n) targets = targets.Take(n).ToList(); foreach (var inv in targets) { try { // The creature is its own smiter, so no player is credited with the kill. inv.Creature.Smite(inv.Creature); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] smite of {inv.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } } return $"Smote {targets.Count} invader(s)."; } /// /invasion-skip: testing - end the warning, or the siege, now. World thread. public static string Skip() { var run = Current; if (run is null) return "No invasion is running."; var now = DateTime.UtcNow; switch (run.Stage) { case Stage.Warning: StartSiege(run, now); return $"The siege of {run.Town.Name} begins now."; case Stage.Siege: SendBoss(run, now); return $"The boss wave arrives at {run.Town.Name} now."; default: return "The boss is already in town."; } } private static void End(Run run, string result) { LastInvaded[run.Town.Name] = DateTime.UtcNow; LastResult = result; Current = null; ScheduleNext(DateTime.UtcNow); } /// Delete every invader still standing. World thread. Returns how many. private static int DestroyRemaining(Run run) { var n = 0; foreach (var inv in run.Invaders.Where(i => !i.Done)) { try { if (!inv.Creature.IsDestroyed && !inv.Creature.IsDead) { inv.Creature.Destroy(); n++; } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not remove {inv.Name} (0x{inv.Guid:X8}): {ex.Message}", ModManager.LogLevel.Warn); } inv.Gone = true; } return n; } /// Mod.Dispose: take the invaders away (on the world thread; their Lifespan is the backstop). public static void Shutdown() { Effects.Shutdown(); var run = Current; Current = null; if (run is null) return; try { WorldManager.EnqueueAction(new ActionEventDelegate(() => { var n = DestroyRemaining(run); ModManager.Log($"[{Mod.Name}] unloaded mid-invasion of {run.Town.Name}; {n} invader(s) removed"); })); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not queue the invaders' removal ({ex.Message}); their Lifespan of {S.InvaderLifespanMinutes:0} min will", ModManager.LogLevel.Warn); } } // --- Messages -------------------------------------------------------------------------- private static void ToWorld(string text) { if (S.AnnounceToWorld) PlayerManager.BroadcastToAll(new GameMessageSystemChat(text, ChatMessageType.WorldBroadcast)); else if (Current is { } run) ToTown(run, text); } /// Everyone within half again the town's radius. private static void ToTown(Run run, string text) { foreach (var p in PlayersNear(run.Center, run.Town.TownRadius * 1.5)) p.SendMessage(text, ChatMessageType.Broadcast); } private static readonly string[] Directions = { "north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west" }; /// Radians clockwise from north -> "north-east". private static string Direction(double angle) { var i = (int)Math.Round(angle / (Math.PI / 4)) % 8; return Directions[(i + 8) % 8]; } // --- Status ---------------------------------------------------------------------------- public static string Describe(bool detail) { var sb = new StringBuilder(); var now = DateTime.UtcNow; var run = Current; if (run is null) { sb.AppendLine("No town is under attack."); if (S.Enabled && S.AutoSchedule && NextScheduled != DateTime.MaxValue) { var wait = NextScheduled - now; sb.AppendLine(wait <= TimeSpan.Zero ? "An invasion could come at any moment." : $"The next invasion is expected in about {Span(wait)}."); } else { sb.AppendLine("No invasion is scheduled."); } } else { switch (run.Stage) { case Stage.Warning: sb.AppendLine($"{run.Set.Invaders} are massing outside {run.Town.Name} ({run.Town.Center}); they attack in {Span(run.AttackAt - now)}."); break; case Stage.Siege: sb.AppendLine($"{run.Town.Name} ({run.Town.Center}) is besieged: {Span(run.SiegeEndsAt - now)} until their leader comes; " + $"{run.Killed} slain so far, {run.Standing} in town."); break; case Stage.Boss: sb.AppendLine($"{BossText(run)} leads the last assault on {run.Town.Name} ({run.Town.Center}): {Span((run.BossDeadline ?? now) - now)} left to kill it; " + $"{run.Standing} invader(s) in town."); break; } } if (!detail) return sb.ToString(); sb.AppendLine($"Enabled {S.Enabled}, AutoSchedule {S.AutoSchedule}, every {S.IntervalMinutes:0}±{S.JitterMinutes:0} min, next {(NextScheduled == DateTime.MaxValue ? "-" : $"{NextScheduled:yyyy-MM-dd HH:mm:ss} UTC")}; world ticks seen {Ticks:N0}"); sb.AppendLine($"Siege {S.SiegeMinutes:0} min, a band every {S.BandEverySeconds:0} s, at most {S.MaxStanding} standing, boss {S.BossMinutes:0} min, march {(S.MarchRun ? "run" : "walk")} x{S.MarchSpeed:0.##}, town radius {S.Towns.FirstOrDefault()?.TownRadius:0} m"); sb.AppendLine($"Last: {LastResult}. Discord: {Discord.LastResult}. Firework displays up: {Effects.Showing}"); foreach (var t in S.Towns) { var why = WhyNot(t, now); var center = Placement.ParseMapCoords(t.Center); var here = center is null ? 0 : PlayersNear(center.Value, t.TownRadius).Count; sb.AppendLine($" {t.Name} [{t.WaveSet}] {t.Center}: {here} player(s) in town; {(why is null ? "can be chosen" : why)}"); } if (run is not null) { sb.AppendLine($"Running since {run.WarnedAt:HH:mm:ss} UTC, started by {run.StartedBy}; {run.Bands} band(s) sent, {run.BandsHeldBack} held back; " + $"{run.Invaders.Count} spawned, {run.Killed} killed, {run.Invaders.Count(i => i.Gone)} gone" + $"{(run.BossName.Length > 0 ? $"; boss {run.BossName} {(run.BossKilled ? "dead" : "alive")}" : "")}"); foreach (var d in run.Defenders.Values.OrderByDescending(d => d.Total).Take(10)) sb.AppendLine($" {d.Name}: {d.Total:N0} damage to {d.ByInvader.Count} invader(s){(d.Total < S.MinContributionDamage ? " (below the minimum)" : "")}"); // Where the standing invaders are and what they are doing - for testing placement and the march. foreach (var inv in run.Invaders.Where(i => !i.Done).Take(40)) { var c = inv.Creature; var at = Placement.Global(c.Location); sb.AppendLine($" {inv.Name}{(inv.Boss ? " (boss)" : "")} 0x{inv.Guid:X8} {(inv.Band == 0 ? "boss wave" : $"band {inv.Band}")}: " + (at is null ? "indoors?" : $"{Placement.MapText(at.Value)}, {Vector2.Distance(at.Value, run.Center):0} m from the centre") + $", {(inv.Marched ? "marching" : "waiting")}, {c.MonsterState}{(c.IsAwake ? " awake" : "")}{(c.AttackTarget is { } t ? $", fighting {t.Name}" : "")}, " + $"health {c.Health.Current}/{c.Health.MaxValue}, {c.Location?.ToLOCString()}"); } } return sb.ToString(); } private static string Span(TimeSpan t) { if (t < TimeSpan.Zero) t = TimeSpan.Zero; if (t.TotalHours >= 1) return $"{(int)t.TotalHours}h {t.Minutes:00}m"; if (t.TotalMinutes >= 1) return $"{(int)t.TotalMinutes}m {t.Seconds:00}s"; return $"{t.Seconds}s"; } }