namespace Aeshnidae.Expedition; /// What a cycle remembers, written to the cycle row as JSON. public sealed class CycleState { /// Each zone's meter, in points (Settings.MeterPointsPerLevel per level of a kill). public Dictionary Meters { get; set; } = new(); /// The highest milestone already told for each zone, so a restart never tells it twice. public Dictionary Told { get; set; } = new(); /// When each zone reached 100%. public Dictionary Reclaimed { get; set; } = new(); /// When each act was completed. public Dictionary ActsDone { get; set; } = new(); /// Act 2: Ember Pitch in each beacon's brazier. public Dictionary Pitch { get; set; } = new(); /// Act 2: when each beacon was lit. public Dictionary Lit { get; set; } = new(); /// Act 2: sieges lost at each beacon. public Dictionary SiegesLost { get; set; } = new(); /// Act 2: when each character last had pitch from a settler (character id). public Dictionary Handouts { get; set; } = new(); } /// /// The Old Ring of Tenkarrdun's running state and Act 1 (Ashfall). Design: the doc "The Old /// Ring of Tenkarrdun" (Tom, 2026-09-27); Act 1 built 2026-09-28. /// /// The cycle opens with the island overrun and every coastal zone at 0%. Every monster a player /// kills on the island copy (InstancesNoDat's region "aerlinthe", copy 1) adds its level times /// MeterPointsPerLevel to the meter of the zone it died in, and level times ContributionPerLevel /// to the killer's contribution (the top damager, or a pet's owner). Meters never decay: a zone /// only moves forward. At 25/50/75% everyone on the island is told; at 100% the zone is reclaimed /// - the whole server and #invasions are told, its monsters thin to a third (Thinning) and its /// settlers' camp appears (Camps). Act 1 ends when all three coastal zones are reclaimed. /// /// Kills arrive on the landblock threads in parallel; everything here takes one lock and does /// only arithmetic under it. Messages, the cull and the database writes are queued elsewhere. /// public static class Expedition { private static readonly object _lock = new(); public static int Cycle { get; private set; } public static int Act { get; private set; } = 1; public static DateTime StartedAt { get; private set; } = DateTime.UtcNow; public static CycleState State { get; private set; } = new(); private static readonly Dictionary _contribution = new(); private static readonly HashSet _dirtyContribution = new(); private static bool _dirtyCycle; public static string LoadStatus { get; private set; } = "not loaded"; /// Kills counted since the mod loaded, for the admin status. public static long KillsSinceLoad; // ------------------------------------------------------------ load / save /// Reads the open cycle (or starts cycle 1) and its contributions. Mod.Initialize. public static void Load() { try { ExpeditionDb.Initialize(); var open = ExpeditionDb.LoadOpenCycle(); lock (_lock) { if (open is { } row) { Cycle = row.Cycle; Act = row.Act; StartedAt = row.Started; State = JsonSerializer.Deserialize(row.State) ?? new CycleState(); } else { Act = 1; StartedAt = DateTime.UtcNow; State = new CycleState(); Cycle = ExpeditionDb.InsertCycle(Act, JsonSerializer.Serialize(State), StartedAt); ModManager.Log($"[{Mod.Name}] cycle {Cycle} begins: Act 1, Ashfall"); } _contribution.Clear(); foreach (var c in ExpeditionDb.LoadContributions(Cycle)) _contribution[c.CharacterId] = c; } LoadStatus = $"cycle {Cycle}, act {Act}, {_contribution.Count} contributor(s)"; } catch (Exception ex) { LoadStatus = $"database failed: {ex.Message} - counting in memory only, nothing is saved"; ModManager.Log($"[{Mod.Name}] {LoadStatus}", ModManager.LogLevel.Error); } } /// Writes whatever changed. Off the world thread (Patches.PostTick queues it), and at shutdown. public static void Flush() { if (!ExpeditionDb.Ready) return; int cycle, act; string state; List rows; lock (_lock) { if (!_dirtyCycle && _dirtyContribution.Count == 0) return; cycle = Cycle; act = Act; state = JsonSerializer.Serialize(State); rows = _dirtyContribution.Select(id => _contribution[id]).Select(c => new Contribution { CharacterId = c.CharacterId, Name = c.Name, Points = c.Points, Kills = c.Kills, }).ToList(); _dirtyCycle = false; _dirtyContribution.Clear(); } try { ExpeditionDb.SaveCycle(cycle, act, state, null); if (rows.Count > 0) ExpeditionDb.SaveContributions(cycle, rows); } catch (Exception ex) { // Put it back so the next flush tries again. lock (_lock) { _dirtyCycle = true; foreach (var r in rows) _dirtyContribution.Add(r.CharacterId); } ModManager.Log($"[{Mod.Name}] saving cycle {cycle} failed: {ex.Message}", ModManager.LogLevel.Warn); } } // ------------------------------------------------------------ reading public static double Meter(ZoneSetting zone) { lock (_lock) return State.Meters.TryGetValue(zone.Name, out var m) ? m : 0; } public static int Percent(ZoneSetting zone) => zone.MeterTarget <= 0 ? 0 : (int)Math.Floor(Math.Min(100, 100 * Meter(zone) / zone.MeterTarget)); public static bool IsReclaimed(ZoneSetting zone) { lock (_lock) return State.Reclaimed.ContainsKey(zone.Name); } /// Whether any zone is reclaimed - the one read the spawn hook makes for everyone else. public static volatile bool AnyReclaimed; public static Contribution? ContributionOf(uint characterId) { lock (_lock) return _contribution.TryGetValue(characterId, out var c) ? c : null; } public static List Top(int n) { lock (_lock) return _contribution.Values.OrderByDescending(c => c.Points).Take(n).ToList(); } public static int Contributors { get { lock (_lock) return _contribution.Count; } } // ------------------------------------------------------------ kills /// /// A creature died. Counts only on the island copy, only monsters (not players, pets or /// NPCs), only when a player (or a player's pet) did the most damage. /// public static void OnKill(Creature creature, DamageHistoryInfo? topDamager) { if (!Mod.Settings.Enabled || creature is Player || creature is CombatPet) return; var block = creature.CurrentLandblock; if (block is null || !Island.Landblocks.Contains((ushort)block.Id.Landblock) || !Island.IsIsland(block)) return; if (topDamager?.TryGetPetOwnerOrAttacker() is not Player killer) return; var level = Math.Max(1, creature.Level ?? 1); var zone = Zones.Of((ushort)block.Id.Landblock); var told = new List<(ZoneSetting Zone, int Milestone)>(); var actDone = 0; lock (_lock) { KillsSinceLoad++; if (!_contribution.TryGetValue(killer.Guid.Full, out var c)) _contribution[killer.Guid.Full] = c = new Contribution { CharacterId = killer.Guid.Full, Name = killer.Name }; c.Name = killer.Name; c.Points += level * Mod.Settings.ContributionPerLevel; c.Kills++; _dirtyContribution.Add(c.CharacterId); if (zone is not null && zone.MeterTarget > 0 && zone.Act == Act && !State.Reclaimed.ContainsKey(zone.Name)) { var before = State.Meters.TryGetValue(zone.Name, out var m) ? m : 0; var after = Math.Min(zone.MeterTarget, before + level * Mod.Settings.MeterPointsPerLevel); State.Meters[zone.Name] = after; _dirtyCycle = true; actDone = Advance(zone, after, told); } } foreach (var (z, milestone) in told) Announce(z, milestone); if (actDone > 0) AnnounceActDone(actDone); } /// /// Under the lock: records milestones crossed at this meter, the reclaim at 100%, and the /// act's end when every zone of it is reclaimed. Returns the act just completed, or 0. /// private static int Advance(ZoneSetting zone, double meter, List<(ZoneSetting, int)> told) { var percent = (int)Math.Floor(100 * meter / zone.MeterTarget); var already = State.Told.TryGetValue(zone.Name, out var t) ? t : 0; foreach (var milestone in (Mod.Settings.Milestones ?? new List()).Where(p => p > already && p <= percent).OrderBy(p => p)) { told.Add((zone, milestone)); State.Told[zone.Name] = milestone; } if (percent >= 100 && !State.Reclaimed.ContainsKey(zone.Name)) { State.Reclaimed[zone.Name] = DateTime.UtcNow; AnyReclaimed = true; _dirtyCycle = true; if (!told.Any(x => x.Item2 >= 100)) told.Add((zone, 100)); } if (Zones.OfAct(Act).All(z => State.Reclaimed.ContainsKey(z.Name)) && !State.ActsDone.ContainsKey(Act)) { var done = Act; State.ActsDone[done] = DateTime.UtcNow; Act = done + 1; _dirtyCycle = true; return done; } return 0; } // ------------------------------------------------------------ Act 2: the beacons public static double PitchIn(string beacon) { lock (_lock) return State.Pitch.TryGetValue(beacon, out var p) ? p : 0; } public static bool IsLit(string beacon) { lock (_lock) return State.Lit.ContainsKey(beacon); } public static int SiegesLost(string beacon) { lock (_lock) return State.SiegesLost.TryGetValue(beacon, out var n) ? n : 0; } /// Contribution that is not a kill: pitch handed in, damage in a siege. public static void AddContribution(uint characterId, string name, double points) { if (points <= 0) return; lock (_lock) { if (!_contribution.TryGetValue(characterId, out var c)) _contribution[characterId] = c = new Contribution { CharacterId = characterId, Name = name }; c.Name = name; c.Points += points; _dirtyContribution.Add(characterId); } } /// /// Puts up to `count` Ember Pitch in a beacon's brazier and credits the giver /// (ContributionPerPitch each). Never past PitchPerBeacon; nothing once the beacon burns or /// outside Act 2. Returns how many it took. /// public static int AddPitch(BeaconSetting beacon, Player giver, int count) { var target = Mod.Settings.Act2.PitchPerBeacon; int taken; bool filled; lock (_lock) { if (Act != 2 || State.Lit.ContainsKey(beacon.Name)) return 0; var had = State.Pitch.TryGetValue(beacon.Name, out var p) ? p : 0; taken = (int)Math.Max(0, Math.Min(count, Math.Floor(target - had))); if (taken <= 0) return 0; State.Pitch[beacon.Name] = had + taken; filled = had + taken >= target; _dirtyCycle = true; } AddContribution(giver.Guid.Full, giver.Name, taken * Mod.Settings.Act2.ContributionPerPitch); if (filled) { TellIsland($"The {beacon.Name}'s brazier is full. Its Lightkeeper waits for the word to light it."); Discord.Post($":fire: The **{beacon.Name}**'s brazier on Aerlinthe is full (cycle {Cycle}). The lighting can begin."); ModManager.Log($"[{Mod.Name}] cycle {Cycle}: {beacon.Name} brazier full (last {taken} from {giver.Name})"); } return taken; } /// A siege lost: `share` of the brazier's pitch burns away. Returns how much burned. public static double Burn(BeaconSetting beacon, double share) { lock (_lock) { var had = State.Pitch.TryGetValue(beacon.Name, out var p) ? p : 0; var left = Math.Floor(had * (1 - Math.Clamp(share, 0, 1))); State.Pitch[beacon.Name] = left; State.SiegesLost[beacon.Name] = (State.SiegesLost.TryGetValue(beacon.Name, out var n) ? n : 0) + 1; _dirtyCycle = true; return had - left; } } /// A siege won: the beacon burns. When every beacon burns, Act 2 is complete. False if it already burned. public static bool Light(BeaconSetting beacon) { var done = 0; lock (_lock) { if (State.Lit.ContainsKey(beacon.Name)) return false; State.Lit[beacon.Name] = DateTime.UtcNow; _dirtyCycle = true; var all = (Mod.Settings.Act2.Beacons ?? new List()).All(b => State.Lit.ContainsKey(b.Name)); if (Act == 2 && all && !State.ActsDone.ContainsKey(2)) { State.ActsDone[2] = DateTime.UtcNow; Act = 3; done = 2; } } if (done > 0) AnnounceActDone(done); return true; } /// A settler's pitch: true (and stamped) when this character may have it now, else how long until it may. public static bool TryHandout(uint characterId, double hours, out TimeSpan wait) { lock (_lock) { var now = DateTime.UtcNow; if (State.Handouts.TryGetValue(characterId, out var last) && now - last < TimeSpan.FromHours(hours)) { wait = last.AddHours(hours) - now; return false; } State.Handouts[characterId] = now; _dirtyCycle = true; wait = TimeSpan.Zero; return true; } } /// Takes a stamp back (the pitch could not be put in the pack). public static void ForgetHandout(uint characterId) { lock (_lock) { State.Handouts.Remove(characterId); _dirtyCycle = true; } } /// Testing: a brazier's pitch, set outright (the beacon stays as it is). public static string SetPitch(BeaconSetting beacon, double value) { lock (_lock) { State.Pitch[beacon.Name] = Math.Clamp(Math.Floor(value), 0, Mod.Settings.Act2.PitchPerBeacon); _dirtyCycle = true; } return $"{beacon.Name}: {PitchIn(beacon.Name):0}/{Mod.Settings.Act2.PitchPerBeacon:0} pitch"; } /// Testing: puts a beacon out again (and Act 2 back in play if it had ended). public static string Unlight(BeaconSetting beacon) { lock (_lock) { State.Lit.Remove(beacon.Name); if (Act == 3 && State.ActsDone.Remove(2)) Act = 2; _dirtyCycle = true; } WorldManager.EnqueueAction(new ActionEventDelegate(Camps.Check)); return $"{beacon.Name} is dark again; act {Act}"; } /// Testing: jumps to an act (meters, beacons and contribution are left as they are). public static string SetAct(int act) { lock (_lock) { Act = Math.Clamp(act, 1, 4); _dirtyCycle = true; } WorldManager.EnqueueAction(new ActionEventDelegate(Camps.Check)); ModManager.Log($"[{Mod.Name}] cycle {Cycle}: act set to {Act} by hand"); return $"cycle {Cycle} is at act {Act} now"; } // ------------------------------------------------------------ telling people private static void Announce(ZoneSetting zone, int milestone) { if (milestone >= 100) { var text = $"{zone.Name} of Aerlinthe has been reclaimed from the ash. Its monsters thin, and settlers have made camp there."; WorldManager.EnqueueAction(new ActionEventDelegate(() => { PlayerManager.BroadcastToAll(new GameMessageSystemChat(text, ChatMessageType.Broadcast)); Thinning.Cull(zone); Camps.Check(); })); Discord.Post($":sunrise_over_mountains: **{zone.Name}** of Aerlinthe has been reclaimed (cycle {Cycle}). Settlers make camp; the monsters thin."); ModManager.Log($"[{Mod.Name}] cycle {Cycle}: {zone.Name} reclaimed"); return; } var words = milestone switch { 25 => "a quarter", 50 => "half", 75 => "three quarters", _ => $"{milestone}%", }; TellIsland($"{zone.Name} is {words} reclaimed."); ModManager.Log($"[{Mod.Name}] cycle {Cycle}: {zone.Name} at {milestone}%"); } private static void AnnounceActDone(int act) { var text = act switch { 1 => "The shores of Aerlinthe are reclaimed. The lighthouses at the harbour mouth stand dark, waiting for fire.", 2 => "Both lights burn over Raina's Harbor. On the crater rim of Tenkarrdun, something under the ash has begun to stir.", _ => $"Act {act} of the Old Ring of Tenkarrdun is complete.", }; WorldManager.EnqueueAction(new ActionEventDelegate(() => PlayerManager.BroadcastToAll(new GameMessageSystemChat(text, ChatMessageType.Broadcast)))); Discord.Post($":fire: **Act {act} complete** on Aerlinthe (cycle {Cycle}). {text}"); ModManager.Log($"[{Mod.Name}] cycle {Cycle}: act {act} complete, act {act + 1} opens"); } private static readonly ConcurrentDictionary _toldOnArrival = new(); /// /// The line a player reads on arriving on the island: the act and its zones, so someone who /// walks through the portal knows the quest is running and that their kills count. Once per /// arrival - Player.OnTeleportComplete re-runs itself while a landblock loads, so a player is /// not told twice within ArrivalLineMinutes. /// public static void OnArrival(Player player) { if (!Mod.Settings.Enabled || !Mod.Settings.ArrivalLine || player?.Session is null) return; if (!Island.IsIsland(player.CurrentLandblock)) return; var now = DateTime.UtcNow; if (_toldOnArrival.TryGetValue(player.Guid.Full, out var last) && now - last < TimeSpan.FromMinutes(Mod.Settings.ArrivalLineMinutes)) return; _toldOnArrival[player.Guid.Full] = now; var act = Act; var zones = Zones.OfAct(act).ToList(); var line = act switch { 1 => "The Old Ring of Tenkarrdun, Act 1: the shores are being reclaimed from the ash. " + string.Join(", ", zones.Select(z => $"{z.Name} {(IsReclaimed(z) ? "reclaimed" : $"{Percent(z)}%")}")) + ". Every kill here counts. /expedition", 2 => "The Old Ring of Tenkarrdun, Act 2: the shores are held - light the lighthouses at the harbour mouth. " + string.Join(", ", (Mod.Settings.Act2.Beacons ?? new List()).Select(b => IsLit(b.Name) ? $"{b.Name} burning" : $"{b.Name} {PitchIn(b.Name):0}/{Mod.Settings.Act2.PitchPerBeacon:0} pitch")) + ". Fire elementals carry Ember Pitch; the Lightkeepers take it. /expedition", _ => $"The Old Ring of Tenkarrdun, Act {act}. /expedition", }; player.SendMessage(line, ChatMessageType.Broadcast); } /// A line to everyone standing on the island copy. public static void TellIsland(string text) { WorldManager.EnqueueAction(new ActionEventDelegate(() => { foreach (var p in Island.Players()) p.SendMessage(text, ChatMessageType.Broadcast); })); } // ------------------------------------------------------------ admin /// Sets a zone's meter to a percentage (testing), telling and reclaiming as a kill would. public static string SetPercent(ZoneSetting zone, double percent) { if (zone.MeterTarget <= 0) return $"{zone.Name} has no meter."; var told = new List<(ZoneSetting Zone, int Milestone)>(); var actDone = 0; var inPlay = false; lock (_lock) { inPlay = zone.Act == Act; var value = Math.Clamp(percent, 0, 100) / 100 * zone.MeterTarget; State.Meters[zone.Name] = value; _dirtyCycle = true; if (zone.Act == Act) actDone = Advance(zone, value, told); } foreach (var (z, milestone) in told) Announce(z, milestone); if (actDone > 0) AnnounceActDone(actDone); return $"{zone.Name} set to {Percent(zone)}% ({Meter(zone):0.#} of {zone.MeterTarget:0} points){(!inPlay ? $" - not this act's zone, so nothing was told" : "")}"; } /// Ends this cycle and starts the next from nothing (testing; later, the reset after Act 4). public static string NewCycle() { Flush(); int finished; lock (_lock) { finished = Cycle; try { if (ExpeditionDb.Ready) { ExpeditionDb.SaveCycle(Cycle, Act, JsonSerializer.Serialize(State), DateTime.UtcNow); StartedAt = DateTime.UtcNow; Act = 1; State = new CycleState(); Cycle = ExpeditionDb.InsertCycle(Act, JsonSerializer.Serialize(State), StartedAt); } else { Cycle++; Act = 1; State = new CycleState(); StartedAt = DateTime.UtcNow; } } catch (Exception ex) { return $"could not start a new cycle: {ex.Message}"; } _contribution.Clear(); _dirtyContribution.Clear(); AnyReclaimed = false; } WorldManager.EnqueueAction(new ActionEventDelegate(Camps.Check)); ModManager.Log($"[{Mod.Name}] cycle {finished} ended by hand; cycle {Cycle} begins: Act 1, Ashfall"); return $"cycle {finished} ended; cycle {Cycle} begins at Act 1 with every zone at 0%"; } /// After Load: whether anything is reclaimed, for the spawn hook's fast path. public static void RefreshFlags() { lock (_lock) AnyReclaimed = State.Reclaimed.Count > 0; } }