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