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