using System.Numerics; namespace Aeshnidae.Expedition; /// /// Act 2 - Light the beacons (the design doc, 2026-09-27; built 2026-10-01). The two Empyrean /// lighthouses at the harbour mouth (Settings.Act2.Beacons) are relit with Ember Pitch: /// /// Fuel island fire elementals carry pitch in their corpses (PitchDrops, OnTreasure) and /// each camp's settler spares a little once a day (OnUse); both only while Act 2 /// runs and a beacon is still dark. /// Braziers pitch is handed to a beacon's Lightkeeper (OnGive) - the NPC, because giving to /// an NPC is what the retail client does - and counted for the whole server. /// Lighting a full brazier's Lightkeeper, used, starts the siege (Siege.Begin); won, the /// beacon burns (Expedition.Light), lost, half its pitch burns away. /// The light a lit beacon's flame and portal to the crater rim are camp objects with /// WhenLit (Camps); its light lifts health and stamina on reclaimed ground within /// LightRadius (Tick). /// /// The Lightkeeper, brazier, flame and portal are Settings.Camps entries with a Beacon: the camp /// reconciler keeps them standing in the island copy (Wants decides which). /// public static class Beacons { private static Act2Settings S => Mod.Settings.Act2; public static long PitchDropped, PitchGiven, Handouts, RegenTicks; public static List All => S.Beacons ?? new List(); public static BeaconSetting? Named(string? name) => All.FirstOrDefault(b => b.Name.Equals(name?.Trim() ?? "", StringComparison.OrdinalIgnoreCase)) ?? All.FirstOrDefault(b => b.Name.StartsWith(name?.Trim() ?? "\u0000", StringComparison.OrdinalIgnoreCase)); public static BeaconSetting? OfLightkeeper(uint wcid) => All.FirstOrDefault(b => b.Lightkeeper == wcid); public static Vector2? Center(BeaconSetting beacon) => Placement.ParseMapCoords(beacon.Center); /// Act 2 is being played: the braziers take pitch and fuel drops. public static bool InPlay => Mod.Settings.Enabled && Expedition.Act == 2; /// Some beacon is still dark - pitch is still worth having. public static bool AnyDark => All.Any(b => !Expedition.IsLit(b.Name)); /// Camps: whether a beacon's object should stand. From Act 2 on; WhenLit ones only once it burns. public static bool Wants(CampObject spec) { if (Named(spec.Beacon) is not { } beacon || Expedition.Act < 2) return false; return !spec.WhenLit || Expedition.IsLit(beacon.Name); } // ------------------------------------------------------------ fuel /// /// Patches.PostGenerateTreasure: a creature's corpse has its loot. An island fire elemental's /// corpse gets its Ember Pitch too, while Act 2 needs it. /// public static void OnTreasure(Creature creature, Corpse? corpse) { if (corpse is null || !InPlay || !AnyDark || creature is Player) return; var drop = (S.PitchDrops ?? new List()).FirstOrDefault(d => d.Name.Equals(creature.Name, StringComparison.OrdinalIgnoreCase)); if (drop is null || Random.Shared.NextDouble() >= drop.Chance) return; if (!Island.IsIsland(creature.CurrentLandblock)) return; var count = Random.Shared.Next(Math.Max(0, drop.Min), Math.Max(drop.Min, drop.Max) + 1); if (count <= 0) return; var pitch = WorldObjectFactory.CreateNewWorldObject(S.PitchWcid); if (pitch is null) return; pitch.SetStackSize(count); if (corpse.TryAddToInventory(pitch)) Interlocked.Add(ref PitchDropped, count); else pitch.Destroy(); } // ------------------------------------------------------------ the Lightkeepers private static readonly MethodInfo? RemoveItemForGive = AccessTools.Method(typeof(Player), "RemoveItemForGive"); /// /// Patches.PreGiveObjectToNPC: Ember Pitch given to a Lightkeeper goes in that beacon's /// brazier - as much as it still needs; the rest stays in the pack. Returns true when this /// handled the give (ACE's own NPC give is then skipped). /// public static bool OnGive(Player player, WorldObject target, WorldObject item, Container? foundIn, Container? rootOwner, bool wasEquipped, int amount) { if (OfLightkeeper(target.WeenieClassId) is not { } beacon || item.WeenieClassId != S.PitchWcid || !Mod.Settings.Enabled) return false; string? refusal = null; var need = (int)Math.Floor(S.PitchPerBeacon - Expedition.PitchIn(beacon.Name)); if (Expedition.Act != 2) refusal = Expedition.Act < 2 ? "The shores must be held before the lights can burn." : "The light already burns. Keep your pitch."; else if (Expedition.IsLit(beacon.Name)) refusal = $"The {beacon.Name} already burns. Take your pitch to the other Lightkeeper."; else if (Siege.Current is { } run && run.Beacon == beacon) refusal = "Not now - hold the light!"; else if (need <= 0) refusal = "The brazier is full. Speak with me when you are ready to light it."; if (refusal is not null) { Refuse(player, item); Tell(target, player, refusal); return true; } var take = Math.Min(Math.Max(1, amount), Math.Min(need, item.StackSize ?? 1)); var args = new object?[] { item, foundIn, wasEquipped, rootOwner, take, null, false }; if (RemoveItemForGive is null || RemoveItemForGive.Invoke(player, args) is not true || args[5] is not WorldObject given) { Refuse(player, item); return true; } if (ReferenceEquals(item, given)) player.Session.Network.EnqueueSend(new GameEventItemServerSaysContainId(player.Session, item, target)); var taken = Expedition.AddPitch(beacon, player, take); given.Destroy(); Interlocked.Add(ref PitchGiven, taken); player.Session.Network.EnqueueSend(new GameMessageSystemChat($"You give {target.Name} {take:N0} Ember Pitch.", ChatMessageType.Broadcast)); target.EnqueueBroadcast(new GameMessageSound(target.Guid, Sound.ReceiveItem)); var now = Expedition.PitchIn(beacon.Name); Tell(target, player, now >= S.PitchPerBeacon ? $"That fills it. The {beacon.Name}'s brazier holds all {S.PitchPerBeacon:0}. Tell me when you are ready, and we light it - and every wraith on this island will come for the flame." : $"Into the brazier it goes. {now:0} of {S.PitchPerBeacon:0}."); return true; } /// /// Patches.PostActivate: a player used an NPC. A Lightkeeper tells the brazier's state, or /// - when it is full - starts the lighting; a settler spares pitch once a day in Act 2. /// public static void OnUse(Player player, WorldObject target) { if (!Mod.Settings.Enabled) return; if (OfLightkeeper(target.WeenieClassId) is { } beacon) { UseLightkeeper(player, target, beacon); return; } if ((S.SettlerWcids ?? new List()).Contains(target.WeenieClassId) && InPlay && AnyDark && S.SettlerPitch > 0) Handout(player, target); } private static void UseLightkeeper(Player player, WorldObject keeper, BeaconSetting beacon) { if (Expedition.IsLit(beacon.Name)) { Tell(keeper, player, $"The {beacon.Name} burns. Its light keeps the ground the settlers hold, and the way to the crater rim stands open beside the brazier."); return; } if (Expedition.Act != 2) return; if (Siege.Current is { } run) { Tell(keeper, player, run.Beacon == beacon ? "Hold the light!" : $"The {run.Beacon.Name} is under siege - help them first."); return; } var pitch = Expedition.PitchIn(beacon.Name); if (pitch < S.PitchPerBeacon) { Tell(keeper, player, $"The {beacon.Name} has been dark since the ash came. Its brazier holds {pitch:0} of the {S.PitchPerBeacon:0} Ember Pitch it needs. " + "The fire elementals of this island carry it, and the settlers spare a little. Bring me what you find."); return; } Tell(keeper, player, "It is full. Stand ready - when the flame takes, everything on this island that hates the light will come for it."); var result = Siege.Begin(beacon, player.Name); ModManager.Log($"[{Mod.Name}] {player.Name} asked {keeper.Name} to light the {beacon.Name}: {result}"); } private static void Handout(Player player, WorldObject settler) { if (!Expedition.TryHandout(player.Guid.Full, S.SettlerHours, out var wait)) { Tell(settler, player, $"I have given you what I can spare. Come back in {Span(wait)}."); return; } var pitch = WorldObjectFactory.CreateNewWorldObject(S.PitchWcid); if (pitch is null) { Expedition.ForgetHandout(player.Guid.Full); return; } pitch.SetStackSize(S.SettlerPitch); if (!player.TryCreateInInventoryWithNetworking(pitch)) { pitch.Destroy(); Expedition.ForgetHandout(player.Guid.Full); Tell(settler, player, "Your pack is too full to carry the pitch I set aside. Make room and ask again."); return; } Interlocked.Increment(ref Handouts); Tell(settler, player, $"We rendered this from the embers around camp - {S.SettlerPitch} Ember Pitch. Take it to a Lightkeeper at the harbour mouth. I will have more tomorrow."); } // ------------------------------------------------------------ the light private static DateTime _lastRegen = DateTime.MinValue; /// /// Patches.PostTick: every RegenEverySeconds, a lit beacon's light lifts health and stamina /// by RegenPercent of their maximum for every player on the island standing on reclaimed /// ground within LightRadius of it. /// public static void Tick() { if (!Mod.Settings.Enabled || S.RegenPercent <= 0 || DateTime.UtcNow - _lastRegen < TimeSpan.FromSeconds(Math.Max(1, S.RegenEverySeconds))) return; _lastRegen = DateTime.UtcNow; var lit = All.Where(b => Expedition.IsLit(b.Name)).Select(Center).Where(c => c is not null).Select(c => c!.Value).ToList(); if (lit.Count == 0) return; foreach (var player in Island.Players()) { if (player.IsDead || player.Location is not { } loc) continue; var zone = Zones.Of((ushort)loc.LandblockId.Landblock); if (zone is null || !Expedition.IsReclaimed(zone)) continue; if (!lit.Any(c => Placement.Distance(loc, c) <= S.LightRadius)) continue; var chain = new ActionChain(); chain.AddAction(player, () => Warm(player)); chain.EnqueueChain(); } } private static void Warm(Player player) { if (player.IsDead) return; var share = S.RegenPercent / 100.0; var any = false; foreach (var vital in new[] { player.Health, player.Stamina }) { if (vital.Current >= vital.MaxValue) continue; var add = (int)Math.Max(1, Math.Round(vital.MaxValue * share)); player.UpdateVitalDelta(vital, add); any = true; } if (any) Interlocked.Increment(ref RegenTicks); } // ------------------------------------------------------------ helpers private static void Refuse(Player player, WorldObject item) => player.Session.Network.EnqueueSend(new GameEventInventoryServerSaveFailed(player.Session, item.Guid.Full)); public static void Tell(WorldObject speaker, Player player, string text) => player.Session.Network.EnqueueSend(new GameEventTell(speaker, text, player, ChatMessageType.Tell)); private static string Span(TimeSpan t) => t.TotalHours >= 1 ? $"{(int)t.TotalHours} hour{((int)t.TotalHours == 1 ? "" : "s")}" : $"{Math.Max(1, (int)Math.Ceiling(t.TotalMinutes))} minutes"; }