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