using System.Numerics;
using Timers = ACE.Server.Entity.Timers;
namespace Aeshnidae.Expedition;
public enum SiegeStage
{
/// The Lightkeeper has been asked; the flame takes at StartAt.
Countdown,
/// Bands keep coming until SiegeEndsAt.
Siege,
/// Their leader is here; it must die by LeaderDeadline.
Leader,
}
public sealed class SiegeInvader
{
public required Creature Creature { get; init; }
public uint Guid { get; init; }
public string Name { get; init; } = "";
public bool Leader { get; init; }
public bool Dead { get; set; }
public bool Gone { get; set; }
public bool Done => Dead || Gone;
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; }
}
public sealed class SiegeDefender
{
public uint Guid { get; init; }
public string Name { get; set; } = "";
public Dictionary ByInvader { get; } = new();
public float Total => ByInvader.Values.Sum();
}
public sealed class SiegeRun
{
public required BeaconSetting Beacon { get; init; }
public Vector2 Center { get; init; }
public string StartedBy { get; init; } = "";
public SiegeStage Stage { get; set; } = SiegeStage.Countdown;
public DateTime StartAt { get; init; }
public DateTime SiegeEndsAt { get; set; }
public DateTime NextBandAt { get; set; }
public DateTime NextUpdateAt { get; set; }
public DateTime? LeaderDeadline { get; set; }
public int Bands { get; set; }
public int HeldBack { get; set; }
public List Invaders { get; } = new();
public Dictionary Defenders { get; } = new();
public int Killed { get; set; }
public bool LeaderKilled { get; set; }
public string LeaderName { get; set; } = "";
public int Standing => Invaders.Count(i => !i.Done);
}
///
/// A beacon siege: Aeshnidae.Invasions' engine (its Invasions.cs), copied - cross-mod types are
/// unsafe here - and aimed at a lighthouse on the island copy (the design doc's Act 2: "8 minutes
/// of wraiths and wisps drawn to the flame, then their leader. Hold it and the beacon is lit").
///
/// The differences from a town invasion: there is no schedule (the Lightkeeper starts it); every
/// invader is put into the island copy, never the real Aerlinthe (Island.EnterIntoCopy, as the
/// camps are), on a landblock of the copy that is open; StrengthPercent raises the invaders' six
/// attributes and health; winning lights the beacon and losing burns half its pitch; and every
/// defender's damage is contribution (ContributionPer100Damage), won or lost.
///
/// World thread only: Patches.PostTick drives Tick; commands only queue (Enqueue).
///
public static class Siege
{
public static SiegeRun? Current { get; private set; }
public static string LastResult { get; private set; } = "none since the mod loaded";
private static SiegeSettings S => Mod.Settings.Act2.Siege;
private static DateTime _lastStep = DateTime.MinValue;
private static readonly ConcurrentQueue _pending = new();
public static void Enqueue(Action action) => _pending.Enqueue(action);
/// Patches.PostTick: queued work, then one step a second.
public static void Tick()
{
while (_pending.TryDequeue(out var action))
{
try { action(); }
catch (Exception ex) { ModManager.Log($"[{Mod.Name}] siege action failed: {ex.Message}", ModManager.LogLevel.Warn); }
}
var run = Current;
if (run is null)
return;
var now = DateTime.UtcNow;
if (now - _lastStep < TimeSpan.FromSeconds(1))
return;
_lastStep = now;
try
{
Step(run, now);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] siege step failed: {ex}", ModManager.LogLevel.Error);
}
}
/// The Lightkeeper's word (or the console): the countdown starts. World thread.
public static string Begin(BeaconSetting beacon, string startedBy)
{
if (Current is not null)
return $"the {Current.Beacon.Name} is already under siege";
if (Expedition.Act != 2)
return $"it is act {Expedition.Act}, not Act 2";
if (Expedition.IsLit(beacon.Name))
return $"the {beacon.Name} already burns";
if (Beacons.Center(beacon) is not { } center)
return $"the {beacon.Name}'s Center '{beacon.Center}' is not map coordinates";
var now = DateTime.UtcNow;
Current = new SiegeRun
{
Beacon = beacon,
Center = center,
StartedBy = startedBy,
StartAt = now.AddSeconds(Math.Max(0, S.CountdownSeconds)),
};
Expedition.TellIsland($"The Lightkeeper lights the {beacon.Name}! In {S.CountdownSeconds:0} seconds the flame takes - hold it for {S.SiegeMinutes:0} minutes against what comes, then kill their leader.");
Discord.Post($":fire: The **{beacon.Name}** is being lit on Aerlinthe (cycle {Expedition.Cycle}) - the siege begins.");
ModManager.Log($"[{Mod.Name}] siege of the {beacon.Name} begins ({startedBy}); flame at {Current.StartAt:HH:mm:ss} UTC");
return $"the {beacon.Name}: countdown {S.CountdownSeconds:0}s";
}
private static void Step(SiegeRun run, DateTime now)
{
if (!Mod.Settings.Enabled || Expedition.Act != 2)
{
Fail(run, "the act changed", burn: false);
return;
}
if (run.Stage == SiegeStage.Countdown)
{
if (now < run.StartAt)
return;
run.Stage = SiegeStage.Siege;
run.SiegeEndsAt = now.AddMinutes(S.SiegeMinutes);
run.NextBandAt = now;
run.NextUpdateAt = now.AddSeconds(Math.Max(30, S.UpdateEverySeconds));
ToNear(run, $"The {run.Beacon.Name} takes the flame - and the island answers. Hold the light!");
}
Watch(run, now);
if (Current != run)
return;
if (run.LeaderKilled)
{
Win(run);
return;
}
if (run.Stage == SiegeStage.Siege)
{
if (now >= run.SiegeEndsAt)
{
SendLeader(run, now);
return;
}
if (now >= run.NextBandAt)
{
run.NextBandAt = now.AddSeconds(Math.Max(5, S.BandEverySeconds));
SendBand(run);
}
if (now >= run.NextUpdateAt)
{
run.NextUpdateAt = now.AddSeconds(Math.Max(30, S.UpdateEverySeconds));
ToNear(run, $"{Span(run.SiegeEndsAt - now)} until their leader comes. {run.Killed} slain, {run.Standing} at the light.");
}
return;
}
if (run.LeaderDeadline is { } deadline && now >= deadline)
{
Fail(run, $"{LeaderText(run)} was not killed in time", burn: true);
return;
}
if (!run.Invaders.Any(i => i.Leader && !i.Done))
Fail(run, $"{LeaderText(run)} slipped away", burn: true);
}
private static string LeaderText(SiegeRun run) => string.IsNullOrEmpty(run.LeaderName) ? "their leader" : run.LeaderName;
private static void SendBand(SiegeRun run)
{
if (run.Standing >= S.MaxStanding)
{
run.HeldBack++;
return;
}
var band = PickBand();
if (band is null)
return;
var here = PlayersNear(run).Count;
var spawns = 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, spawns, angle, DateTime.UtcNow);
ModManager.Log($"[{Mod.Name}] {run.Beacon.Name} band {run.Bands}: {placed}/{spawns.Count} placed, {here} player(s) near, {run.Standing} standing");
if (placed > 0)
ToNear(run, $"{placed} come for the flame from the {Direction(angle)}.");
}
private static void SendLeader(SiegeRun run, DateTime now)
{
run.Stage = SiegeStage.Leader;
run.LeaderDeadline = now.AddMinutes(S.LeaderMinutes);
var here = PlayersNear(run).Count;
var spawns = Expand(S.Leader ?? new List(), here).ToList();
var angle = Random.Shared.NextDouble() * Math.PI * 2;
var placed = Place(run, spawns, angle, now);
ModManager.Log($"[{Mod.Name}] {run.Beacon.Name} leader wave: {placed}/{spawns.Count} placed, {here} player(s) near");
if (!run.Invaders.Any(i => i.Leader && !i.Done))
{
Fail(run, "their leader could not reach the light (nothing could be placed)", burn: false);
return;
}
ToNear(run, $"{LeaderText(run)} comes from the {Direction(angle)}! Kill it within {S.LeaderMinutes:0} minutes and the {run.Beacon.Name} burns.");
}
private static SiegeBand? PickBand()
{
var bands = S.Bands ?? new List();
var total = bands.Sum(b => Math.Max(0, b.Weight));
if (total <= 0)
return bands.FirstOrDefault();
var roll = Random.Shared.NextDouble() * total;
foreach (var b in bands)
{
roll -= Math.Max(0, b.Weight);
if (roll <= 0)
return b;
}
return bands[^1];
}
private static IEnumerable<(uint Wcid, bool Leader)> Expand(List spawns, int players)
{
var extra = Math.Max(0, players - 1);
foreach (var s in spawns.OrderByDescending(s => s.Leader))
{
var n = s.Leader ? s.Count : s.Count + (int)Math.Floor(s.PerExtraPlayer * extra);
for (var i = 0; i < n; i++)
yield return (s.Wcid, s.Leader);
}
}
/// Puts creatures on the ground on one side of the light, in the island copy, and queues their march.
private static int Place(SiegeRun run, List<(uint Wcid, bool Leader)> spawns, double angle, DateTime now)
{
var spread = Math.Max(0, S.BandSpreadDegrees) * Math.PI / 180;
var placed = 0;
foreach (var (wcid, leader) in spawns)
{
var spot = IslandSpot(run.Center, S.SpawnMin, S.SpawnMax, tries: 12, angle: angle, spread: spread)
?? IslandSpot(run.Center, S.SpawnMin, S.SpawnMax, tries: 20);
if (spot is null)
continue;
// The spot's landblock in the island copy; not open (nobody near) = not there.
var block = Island.Find((ushort)spot.LandblockId.Landblock);
if (block is null)
continue;
Creature? creature = null;
try
{
creature = WorldObjectFactory.CreateNewWorldObject(wcid) as Creature;
if (creature is null)
{
ModManager.Log($"[{Mod.Name}] siege: wcid {wcid} is not a creature (or does not exist); skipped", ModManager.LogLevel.Warn);
continue;
}
Strengthen(creature);
creature.Location = spot;
creature.Lifespan = (int)(S.LifespanMinutes * 60);
if (!Island.EnterIntoCopy(creature, block))
{
creature.Destroy();
continue;
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] siege: spawning wcid {wcid} failed: {ex.Message}", ModManager.LogLevel.Warn);
try { creature?.Destroy(); } catch { /* already gone */ }
continue;
}
run.Invaders.Add(new SiegeInvader
{
Creature = creature,
Guid = creature.Guid.Full,
Name = creature.Name,
Leader = leader,
March = IslandSpot(run.Center, 0, S.MarchRadius, tries: 12),
MarchAt = now.AddSeconds(S.MarchDelaySeconds),
SpawnedAt = now,
});
if (leader)
run.LeaderName = creature.Name;
placed++;
}
return placed;
}
///
/// Placement.RingSpot for the island copy. The ground test reads the physics landscape, and
/// ACE's physics looks a landblock up through LandblockManager - on the world thread, with no
/// copy in hand, that LOADS THE REAL landblock (staging, 2026-10-01: the first band brought
/// the real B8E8, B8E9 and B7E8 up, their retail spawns and all). Here a spot is only tried
/// on a landblock open in the island copy, and tested with that copy as the ambient, so the
/// lookup answers the copy.
///
private static Position? IslandSpot(Vector2 center, double min, double max, int tries = 20, double? angle = null, double spread = Math.PI)
{
for (var i = 0; i < tries; i++)
{
var a = angle.HasValue
? angle.Value + (Random.Shared.NextDouble() * 2 - 1) * spread
: Random.Shared.NextDouble() * Math.PI * 2;
var r = min + Random.Shared.NextDouble() * Math.Max(0, max - min);
var spot = new Vector2(center.X + (float)(Math.Sin(a) * r), center.Y + (float)(Math.Cos(a) * r));
var heading = (float)Math.Atan2(center.X - spot.X, center.Y - spot.Y);
if (spot.X < 0 || spot.Y < 0)
continue;
var landblock = (ushort)(((int)(spot.X / Position.BlockLength) << 8) | (int)(spot.Y / Position.BlockLength));
if (Island.Find(landblock) is not { } block)
continue;
Position? pos = null;
Island.WithAmbient(block, () => pos = Placement.GroundAt(spot, heading));
if (pos is not null)
return pos;
}
return null;
}
///
/// StrengthPercent on the six attributes and the written-in part of health - InstancesNoDat's
/// rule for a scaled copy (Scaling.cs), so the appraisal window shows what is fought.
///
private static void Strengthen(Creature creature)
{
var p = S.StrengthPercent;
if (p <= 0)
return;
var factor = 1 + p / 100.0;
foreach (var attribute in creature.Attributes.Values)
attribute.StartingValue = (uint)Math.Round(attribute.StartingValue * factor);
creature.Health.StartingValue = (uint)Math.Round(creature.Health.StartingValue * factor);
creature.SetMaxVitals();
}
private static void Watch(SiegeRun run, DateTime now)
{
// An awake landblock keeps its monsters thinking; the siege keeps its invaders' ground
// awake until it ends (Invasions does the same for a town).
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;
}
if (c.IsDestroyed || (c.CurrentLandblock is null && (now - inv.SpawnedAt).TotalSeconds > 10))
{
inv.Gone = true;
continue;
}
if (c.CurrentLandblock is { } lb && woken.Add(lb))
lb.SetActive();
Snapshot(run, inv);
if (!inv.ArrivalShown && (now - inv.SpawnedAt).TotalSeconds >= 1)
{
inv.ArrivalShown = true;
Play(c, inv.Leader ? S.LeaderEffect : S.ArrivalEffect);
}
if (!inv.Marched && now >= inv.MarchAt)
{
inv.Marched = true;
// In the copy's context: a move's physics looks the landblock up, and from the
// world thread with no copy in hand that loads the real one (staging, 2026-10-01:
// the real BAE8, BAE9 and BBE8 came up two seconds after a band - the march delay).
if (inv.March is not null && c.CurrentLandblock is { } home)
Island.WithAmbient(home, () => MarchOn(inv));
}
}
}
/// The light becomes the invader's home and it is sent there - Invasions' MarchOn, unchanged.
private static void MarchOn(SiegeInvader 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}] siege march failed for {inv.Name}: {ex.Message}", ModManager.LogLevel.Warn);
}
}
/// Patches.PostCreatureDie: an invader died.
public static void OnCreatureDie(Creature creature)
{
var run = Current;
var inv = run?.Invaders.FirstOrDefault(i => i.Guid == creature.Guid.Full);
if (run is null || inv is null || inv.Done)
return;
MarkDead(run, inv);
}
private static void MarkDead(SiegeRun run, SiegeInvader inv)
{
Snapshot(run, inv);
inv.Dead = true;
run.Killed++;
if (inv.Leader)
run.LeaderKilled = true;
}
private static void Snapshot(SiegeRun run, SiegeInvader inv)
{
try
{
foreach (var info in inv.Creature.DamageHistory?.TotalDamage.Values.ToList() ?? new())
{
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 SiegeDefender { 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}] siege damage snapshot failed for {inv.Name}: {ex.Message}", ModManager.LogLevel.Warn);
}
}
// ------------------------------------------------------------ endings
private static void Win(SiegeRun run)
{
var credited = Credit(run);
var holders = run.Defenders.Values.Where(d => d.Total >= S.MinDefenderDamage).OrderByDescending(d => d.Total).ToList();
foreach (var d in holders)
if (PlayerManager.GetOnlinePlayer(d.Guid) is { } p)
Play(p, S.DefenderEffect);
var withdrawn = DestroyRemaining(run);
Expedition.Light(run.Beacon);
var top = holders.FirstOrDefault();
var text = $"The {run.Beacon.Name} burns over Raina's Harbor! {run.Killed} slain{(run.LeaderName.Length > 0 ? $", {run.LeaderName} among them" : "")}; " +
$"{holders.Count} held the light.{(top is null ? "" : $" Fiercest: {top.Name}.")}";
PlayerManager.BroadcastToAll(new GameMessageSystemChat(text, ChatMessageType.Broadcast));
Discord.Post($":fire: {text} (cycle {Expedition.Cycle})");
ModManager.Log($"[{Mod.Name}] {run.Beacon.Name} lit: {run.Killed} killed, {withdrawn} withdrew, {run.Bands} band(s), {run.HeldBack} held back; contribution to {credited}");
End(run, $"{DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC {run.Beacon.Name} lit - {run.Killed} killed, {holders.Count} held the light");
}
private static void Fail(SiegeRun run, string reason, bool burn)
{
var credited = Credit(run);
var withdrawn = DestroyRemaining(run);
var burned = burn ? Expedition.Burn(run.Beacon, Mod.Settings.Act2.FailBurnShare) : 0;
var text = $"The {run.Beacon.Name} gutters out - {reason}." +
(burned > 0 ? $" {burned:0} Ember Pitch burned away; {Expedition.PitchIn(run.Beacon.Name):0} remain in the brazier." : " The brazier is still full.");
Expedition.TellIsland(text);
if (burn)
Discord.Post($":dash: {text} (cycle {Expedition.Cycle})");
ModManager.Log($"[{Mod.Name}] {run.Beacon.Name} siege lost: {reason}; {run.Killed} killed, {withdrawn} withdrew, {burned:0} pitch burned; contribution to {credited}");
End(run, $"{DateTime.UtcNow:yyyy-MM-dd HH:mm} UTC {run.Beacon.Name} lost - {reason}");
}
/// Every defender's damage as contribution, win or lose. Returns how many were credited.
private static int Credit(SiegeRun run)
{
var per = Mod.Settings.Act2.ContributionPer100Damage;
var n = 0;
foreach (var d in run.Defenders.Values)
{
var points = d.Total / 100.0 * per;
if (points <= 0)
continue;
Expedition.AddContribution(d.Guid, d.Name, points);
n++;
}
return n;
}
private static void End(SiegeRun run, string result)
{
LastResult = result;
Current = null;
WorldManager.EnqueueAction(new ActionEventDelegate(Camps.Check));
}
private static int DestroyRemaining(SiegeRun run)
{
var n = 0;
foreach (var inv in run.Invaders.Where(i => !i.Done))
{
try
{
var c = inv.Creature;
if (!c.IsDestroyed && !c.IsDead)
{
if (c.CurrentLandblock is { } block)
Island.WithAmbient(block, () => c.Destroy());
else
c.Destroy();
n++;
}
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] could not remove {inv.Name}: {ex.Message}", ModManager.LogLevel.Warn);
}
inv.Gone = true;
}
return n;
}
/// Console: ends the siege now, burning nothing.
public static string Stop(string by)
{
if (Current is not { } run)
return "no siege is running";
Fail(run, $"stopped by {by}", burn: false);
return $"the {run.Beacon.Name} siege stopped; nothing burned";
}
/// Console: skips the bands and sends the leader now (testing).
public static string Skip()
{
if (Current is not { } run)
return "no siege is running";
if (run.Stage == SiegeStage.Leader)
return "the leader is already here";
run.Stage = SiegeStage.Siege;
run.SiegeEndsAt = DateTime.UtcNow;
return "the leader comes on the next step";
}
/// Console: kills invaders (the leader last), crediting nobody - testing a win without players.
public static string Smite(int? count)
{
if (Current is not { } run || run.Stage == SiegeStage.Countdown)
return "no invaders are at the light";
var targets = run.Invaders.Where(i => !i.Done && !i.Creature.IsDead && !i.Creature.IsDestroyed).OrderBy(i => i.Leader).ToList();
if (count is { } n)
targets = targets.Take(n).ToList();
// On each creature's own landblock (the copy's tick), as a player's kill would be - from
// the world thread a corpse is made outside the copy and lands in the real landblock.
foreach (var inv in targets)
{
var c = inv.Creature;
var chain = new ActionChain();
chain.AddAction(c, () =>
{
try { if (!c.IsDead && !c.IsDestroyed) c.Smite(c); }
catch (Exception ex) { ModManager.Log($"[{Mod.Name}] smite of {c.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); }
});
chain.EnqueueChain();
}
return $"smote {targets.Count} invader(s)";
}
/// Mod.Dispose: takes the invaders away (their Lifespan is the backstop).
public static void Shutdown()
{
var run = Current;
if (run is null)
return;
Current = null;
var n = DestroyRemaining(run);
ModManager.Log($"[{Mod.Name}] siege of the {run.Beacon.Name} ended by the mod unloading; {n} invader(s) removed, nothing burned");
}
// ------------------------------------------------------------ helpers
private static List PlayersNear(SiegeRun run) =>
Island.Players().Where(p => Placement.Distance(p.Location, run.Center) <= S.PlayerRadius).ToList();
/// The players near the light hear the siege; everyone else on the island gets the start and the end.
private static void ToNear(SiegeRun run, string text)
{
foreach (var p in PlayersNear(run))
p.SendMessage(text, ChatMessageType.Broadcast);
}
private static void Play(WorldObject wo, string? effect)
{
if (string.IsNullOrWhiteSpace(effect) || !Enum.TryParse(effect.Trim(), true, out var script) || wo.IsDestroyed)
return;
try { wo.EnqueueBroadcast(new GameMessageScript(wo.Guid, script)); }
catch { /* an effect is never worth an exception */ }
}
private static readonly string[] Directions = { "north", "north-east", "east", "south-east", "south", "south-west", "west", "north-west" };
private static string Direction(double angle)
{
var deg = (angle * 180 / Math.PI % 360 + 360) % 360;
return Directions[(int)Math.Round(deg / 45) % 8];
}
private static string Span(TimeSpan t) =>
t.TotalMinutes >= 1 ? $"{(int)Math.Ceiling(t.TotalMinutes)} minute{((int)Math.Ceiling(t.TotalMinutes) == 1 ? "" : "s")}" : $"{Math.Max(0, (int)t.TotalSeconds)} seconds";
public static string Describe()
{
if (Current is not { } run)
return $"no siege; last: {LastResult}";
var now = DateTime.UtcNow;
return run.Stage switch
{
SiegeStage.Countdown => $"the {run.Beacon.Name}: the flame takes in {Span(run.StartAt - now)}",
SiegeStage.Siege => $"the {run.Beacon.Name} under siege: {Span(run.SiegeEndsAt - now)} until their leader; {run.Killed} slain, {run.Standing} standing, {run.Bands} band(s), {run.HeldBack} held back",
_ => $"the {run.Beacon.Name}: {LeaderText(run)} is here, {Span((run.LeaderDeadline ?? now) - now)} left; {run.Killed} slain, {run.Standing} standing",
};
}
}