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";
}
}