using System.Diagnostics; namespace Aeshnidae.Trials; /// One run in progress. public sealed class Run { public required Player Player { get; init; } public required ushort Landblock { get; init; } public required int Copy { get; init; } public required Landblock Block { get; init; } public required int Tier { get; init; } public required string Name { get; init; } public required bool Staff { get; init; } public DateTime StartedUtc { get; } = DateTime.UtcNow; private readonly long _startStamp = Stopwatch.GetTimestamp(); public double Elapsed => Stopwatch.GetElapsedTime(_startStamp).TotalSeconds; /// Guids of the creatures still to kill. World-thread only. public HashSet Alive { get; } = new(); public bool Counted { get; set; } public bool CountedLate { get; set; } public int Initial { get; set; } public int Kills { get; set; } public int Gone { get; set; } public bool Ended { get; set; } public DateTime LastCheckUtc { get; set; } = DateTime.MinValue; public int Strikes { get; set; } } /// /// The runs: when one starts, what it counts, how it ends. /// /// A run starts at InstancesNoDat's arrival moment () when the /// copy is FRESH - created (Patches.PostLandblockInit stamps every copy) within /// Settings.FreshSeconds of the crossing and never crossed into before - the player /// came in by a teleport into this landblock (a portal; a login or a walk in on foot /// is not a Trial), and the cooldown has passed. The creatures are counted a few /// seconds later, once the copy's CreateWorldObjects has completed, off the copy's /// own object table; kills take guids out of the set, late spawns put them in. Empty /// set, run complete. Everything else - leaving, dying, logging out, another player /// arriving in the copy, the copy closing - voids it. /// /// All of it runs on the world thread: the arrival, the kills, the teleports and the /// tick that drives the count and the watchdog are all there, so the sets need no /// locking. The database is only touched when a run ends. /// public static class Runs { private static readonly List _runs = new(); private static readonly object _lock = new(); /// Copy creation stamps - every InstancesNoDat copy, when its Landblock.Init ran. private static readonly ConditionalWeakTable> _created = new(); /// Copies that have had a player cross into them, fresh or not. private static readonly ConditionalWeakTable _touched = new(); private static readonly object Marker = new(); /// The last Player.Teleport into a landblock, per player - a portal was used. private static readonly ConcurrentDictionary _teleportedInto = new(); private static readonly TimeSpan TeleportLifetime = TimeSpan.FromSeconds(30); /// (character, landblock) -> when their last attempt started (UTC). Seeded from the table on first use. private static readonly ConcurrentDictionary<(uint, ushort), DateTime> _lastAttempt = new(); public static int Active { get { lock (_lock) return _runs.Count(r => !r.Ended); } } public static List Snapshot() { lock (_lock) return _runs.Where(r => !r.Ended).ToList(); } // ------------------------------------------------------------ stamps public static void CopyCreated(Landblock block) { _created.AddOrUpdate(block, new StrongBox(DateTime.UtcNow)); } public static void TeleportingInto(Player player, ushort landblock) => _teleportedInto[player.Guid.Full] = (landblock, DateTime.UtcNow); private static bool CameByTeleport(Player player, ushort landblock) => _teleportedInto.TryGetValue(player.Guid.Full, out var t) && t.Landblock == landblock && DateTime.UtcNow - t.When <= TeleportLifetime; public static void ForgetAll() { lock (_lock) _runs.Clear(); _teleportedInto.Clear(); _lastAttempt.Clear(); } // ------------------------------------------------------------ lookups public static Run? ByPlayer(Player? player) { if (player is null) return null; lock (_lock) return _runs.FirstOrDefault(r => !r.Ended && ReferenceEquals(r.Player, player)); } public static Run? ByPlayerGuid(uint guid) { lock (_lock) return _runs.FirstOrDefault(r => !r.Ended && r.Player.Guid.Full == guid); } public static Run? ByBlock(Landblock? block) { if (block is null) return null; lock (_lock) return _runs.FirstOrDefault(r => !r.Ended && ReferenceEquals(r.Block, block)); } /// /// Whether this landblock is a Trial copy: a copy of a listed Trial, and for a Course /// trial one opened through its course portal. Whether a run is on does not matter. /// public static bool IsTrialCopy(Landblock? block) { if (block is null || !Mod.Trials.TryGetValue((ushort)block.Id.Landblock, out var trial)) return false; var copy = Instances.CopyOf(block); return copy > 0 && (!trial.Course || Instances.IsCourseCopy((ushort)block.Id.Landblock, copy)); } // ------------------------------------------------------------ the arrival /// InstancesNoDat's Arrival.OnArrival: (player, landblock, copy, tier). public static void OnArrival(Player player, ushort landblock, int copy, int tier) { try { var s = Mod.Settings; if (!s.Enabled || player?.Session is null || copy <= 0) return; var block = Instances.Find(landblock, copy); if (block is null) return; // Only a copy that is a course is closed early once spent: a Course trial's // ordinary copies are somebody's farm and keep InstancesNoDat's own grace. if (Mod.Trials.TryGetValue(landblock, out var listed) && (!listed.Course || Instances.IsCourseCopy(landblock, copy))) _trialCopies[(landblock, copy)] = new TrialCopy(block); // Somebody arriving in a copy with a run on who is not the runner: a staff // teleport in, an inst-send. The run is no longer a solo run. var running = ByBlock(block); if (running is not null && !ReferenceEquals(running.Player, player)) { Void(running, $"{player.Name} entered the copy"); return; } // The runner crossing into a different copy of the same landblock (an // inst-tele by staff): the course changed under them. var mine = ByPlayer(player); if (mine is not null && !ReferenceEquals(mine.Block, block)) { Void(mine, "moved to another copy"); return; } if (mine is not null) return; // already running here if (!Mod.Trials.TryGetValue(landblock, out var trial)) return; // A Course trial times only copies opened through its course portal. The player's // ordinary copy of the same dungeon is not a course: no clock and no message. if (trial.Course && !Instances.IsCourseCopy(landblock, copy)) return; var name = string.IsNullOrWhiteSpace(trial.Name) ? Instances.NameFor(landblock) : trial.Name; var fresh = _created.TryGetValue(block, out var created) && (DateTime.UtcNow - created.Value).TotalSeconds <= s.FreshSeconds && !_touched.TryGetValue(block, out _); _touched.AddOrUpdate(block, Marker); if (!CameByTeleport(player, landblock)) { Tell(player, $"Trial: {name} is timed only when a portal opens a fresh copy - this copy was reached another way, so it is not timed."); return; } if (!fresh) { Tell(player, $"Trial: this copy of {name} is not fresh, so it is not timed. Speak to the Trial Keeper to reset it; the next step through the door is then a timed run."); return; } var wait = CooldownLeft(player, landblock); if (wait > TimeSpan.Zero) { Tell(player, $"Trial: your next timed attempt at {name} is in {Math.Ceiling(wait.TotalMinutes):0} min (one attempt per {s.CooldownMinutes:0} minutes), or speak to the Trial Keeper to reset it now. This copy is a normal copy."); return; } if (Instances.ScopeOf(landblock) != "Personal") { Tell(player, $"Trial: {name} is not a personal dungeon here, so it cannot be timed."); return; } var staff = (player.Session.AccessLevel) > AccessLevel.Player; var run = new Run { Player = player, Landblock = landblock, Copy = copy, Block = block, Tier = Math.Max(tier, Instances.TierOf(landblock, copy)), Name = name, Staff = staff, }; lock (_lock) _runs.Add(run); _lastAttempt[(player.Guid.Full, landblock)] = run.StartedUtc; Tell(player, $"Trial: {name}, tier {run.Tier} - clock running. Kill everything; leaving, dying or a teleport voids the run." + (staff ? " Staff: timed and told, not recorded." : "")); Log($"{player.Name} started {name} ({landblock:X4} copy {copy}, tier {run.Tier}){(staff ? " [staff, unrecorded]" : "")}"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] arrival handling failed: {ex}", ModManager.LogLevel.Error); } } /// How long until this character may start another attempt here; zero when free. public static TimeSpan CooldownLeft(Player player, ushort landblock) { var key = (player.Guid.Full, landblock); var last = _lastAttempt.GetOrAdd(key, k => { try { return TrialDb.Ready ? TrialDb.LastAttempt(k.Item1, k.Item2) ?? DateTime.MinValue : DateTime.MinValue; } catch { return DateTime.MinValue; } }); var until = last.AddMinutes(Mod.Settings.CooldownMinutes); var left = until - DateTime.UtcNow; return left > TimeSpan.Zero ? left : TimeSpan.Zero; } // ------------------------------------------------------------ the Keeper's reset private static readonly ConcurrentQueue<(uint Player, uint Keeper)> _resets = new(); /// /// The Trial Keeper was asked (his Use emote's Goto, Patches.PreExecuteEmote). Queued, not /// done here: an emote runs inside its own landblock's tick, which ACE may run beside other /// landblocks', and a copy may only be closed from the world tick. /// public static void RequestReset(Player player, WorldObject keeper) => _resets.Enqueue((player.Guid.Full, keeper.Guid.Full)); /// Every world tick, before the runs: carry out the queued resets. public static void ProcessResets() { while (_resets.TryDequeue(out var request)) { var player = PlayerManager.GetOnlinePlayer(request.Player); if (player is null) continue; try { Reset(player, player.CurrentLandblock?.GetObject(request.Keeper)); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] reset failed for {player.Name}: {ex}", ModManager.LogLevel.Error); } } } /// /// For every Trial: the player's own copy is closed (unless someone is standing in it), so /// the next step through the door opens a fresh one, and their cooldown is cleared. A run /// still going is voided first - though a runner talking to the Keeper has already left it. /// The cooldown clear lives in memory: after a restart the last attempt is read from the /// table again, and the Keeper clears it again. /// private static void Reset(Player player, WorldObject? keeper) { var mine = ByPlayer(player); if (mine is not null) Void(mine, "reset at the Trial Keeper"); var reset = new List(); var occupied = new List(); foreach (var (landblock, trial) in Mod.Trials) { var name = string.IsNullOrWhiteSpace(trial.Name) ? Instances.NameFor(landblock) : trial.Name; // A Course trial's course copy only - never the ordinary copy the player farms. var copy = trial.Course ? Instances.CourseCopyFor(player, landblock) : Instances.CopyFor(player, landblock); if (copy > 0) { var block = Instances.Find(landblock, copy); if (block is not null && Instances.PlayersIn(block).Count > 0) { occupied.Add(name); continue; } Instances.Close(landblock, copy); } _lastAttempt[(player.Guid.Full, landblock)] = DateTime.MinValue; reset.Add(name); } var text = reset.Count == 0 ? "There is no Trial here to reset." : reset.Count == 1 ? $"{reset[0]} is reset for you: a fresh course and no wait. Step through the portal when you are ready." : reset.Count == Mod.Trials.Count ? $"Every Trial is reset for you ({reset.Count}): a fresh course and no wait. Step through a portal when you are ready." : $"These Trials are reset for you: {string.Join(", ", reset)} - a fresh course and no wait. Step through a portal when you are ready."; if (occupied.Count > 0) text += $" Someone is still inside your {string.Join(" and ", occupied)}, so it cannot be reset yet."; if (keeper is not null) player.Session?.Network.EnqueueSend(new ACE.Server.Network.GameEvent.Events.GameEventTell(keeper, text, player, ChatMessageType.Tell)); else Tell(player, $"Trial: {text}"); Log($"{player.Name}: reset at the Trial Keeper ({(reset.Count > 0 ? string.Join(", ", reset) : "nothing")}{(occupied.Count > 0 ? $"; occupied: {string.Join(", ", occupied)}" : "")})"); } // ------------------------------------------------------------ the tick // ------------------------------------------------------------ spent copies private sealed class TrialCopy(Landblock block) { public Landblock Block { get; } = block; public DateTime? EmptySince { get; set; } } /// Every copy of a Trial landblock a player has crossed into, until it closes. private static readonly ConcurrentDictionary<(ushort Landblock, int Copy), TrialCopy> _trialCopies = new(); private static DateTime _lastSpentCheck; /// /// Once a second: a Trial copy with nobody in it for Settings.CloseEmptyAfterSeconds is /// closed (see there). A copy that closed some other way (InstancesNoDat's own sweep, a /// reset, an admin) is just forgotten. World thread: called from the world tick. /// public static void CloseSpentCopies() { var after = Mod.Settings.CloseEmptyAfterSeconds; var now = DateTime.UtcNow; if (after < 0 || _trialCopies.IsEmpty || (now - _lastSpentCheck).TotalSeconds < 1) return; _lastSpentCheck = now; foreach (var (key, entry) in _trialCopies) { try { if (!ReferenceEquals(Instances.Find(key.Landblock, key.Copy), entry.Block)) { _trialCopies.TryRemove(key, out _); continue; } if (Instances.PlayersIn(entry.Block).Count > 0) { entry.EmptySince = null; continue; } entry.EmptySince ??= now; if ((now - entry.EmptySince.Value).TotalSeconds < after) continue; _trialCopies.TryRemove(key, out _); var closed = Instances.Close(key.Landblock, key.Copy); Log($"closed {Instances.NameFor(key.Landblock)} copy {key.Copy}: empty {after:0} s, spent{(closed ? "" : " (already gone)")}"); } catch (Exception ex) { _trialCopies.TryRemove(key, out _); ModManager.Log($"[{Mod.Name}] closing spent copy {key.Copy} of {key.Landblock:X4} failed: {ex.Message}", ModManager.LogLevel.Warn); } } } /// Every world tick: the count when it is time, the watchdog once a second. public static void Tick() { List runs; lock (_lock) { if (_runs.Count == 0) return; runs = _runs.Where(r => !r.Ended).ToList(); _runs.RemoveAll(r => r.Ended); } var s = Mod.Settings; var now = DateTime.UtcNow; foreach (var run in runs) { try { if (!run.Counted && run.Elapsed >= s.CountAfterSeconds) { if (run.Block.CreateWorldObjectsCompleted) Count(run, late: false); else if (run.Elapsed >= s.CountDeadlineSeconds) Count(run, late: true); } if ((now - run.LastCheckUtc).TotalSeconds < 1) continue; run.LastCheckUtc = now; var runner = run.Player; if (runner.Session == null) { Void(run, "logged out"); continue; } if (!Instances.IsOurs(run.Block)) { Void(run, "the copy closed"); continue; } // Three misses in a row: a teleport passes through a moment with no // landblock, and one look is not proof of leaving. if (!ReferenceEquals(runner.CurrentLandblock, run.Block)) { if (++run.Strikes >= 3) Void(run, "left the copy"); } else { run.Strikes = 0; } if (!run.Ended && run.Counted && run.Alive.Count == 0) Complete(run); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] tick failed for {run.Player.Name}: {ex}", ModManager.LogLevel.Error); } } } // ------------------------------------------------------------ counting /// A creature that has to die for the run to be complete. public static bool Qualifies(WorldObject? wo) => wo is Creature c && wo is not Player && wo is not Pet && wo is not Vendor && c.Attackable && !c.IsDead; /// /// Landblock.AddWorldObject parks a new object in pendingAdditions until the block's /// next tick moves it into worldObjects, and a generator that spawned on this very /// tick has its creatures there and nowhere else. The count reads both lists, so a /// spawn a few milliseconds old is not missed and then never added (OnAdded only /// adds after the count). /// private static readonly AccessTools.FieldRef> _pendingOf = AccessTools.FieldRefAccess>("pendingAdditions"); private static void Count(Run run, bool late) { run.Counted = true; run.CountedLate = late; foreach (var wo in run.Block.GetAllWorldObjectsForDiagnostics()) { if (Qualifies(wo)) run.Alive.Add(wo.Guid.Full); } try { foreach (var wo in _pendingOf(run.Block).Values.ToList()) { if (Qualifies(wo)) run.Alive.Add(wo.Guid.Full); } } catch (Exception ex) { Log($"pending additions could not be read for the count: {ex.Message}"); } run.Initial = run.Alive.Count + run.Kills; if (run.Alive.Count == 0 && run.Kills == 0) { Void(run, "nothing to fight"); return; } Tell(run.Player, $"{run.Name}: {run.Initial} things breathing{(run.Kills > 0 ? $" ({run.Kills} already dead)" : "")}. Clock running."); Log($"{run.Player.Name}: counted {run.Initial} in {run.Name} copy {run.Copy} at {run.Elapsed:0.0}s{(late ? " (CreateWorldObjects not complete - counted at the deadline)" : "")}"); if (run.Alive.Count == 0) Complete(run); } /// Creature.Die in a copy with a run on. public static void OnKill(Creature creature) { var run = ByBlock(creature.CurrentLandblock); if (run is null || creature is Player or Pet or Vendor) return; if (!run.Counted) { // Killed before the count: the count will not see it alive (it is dead // by then), so it is counted here and folded into the total at the count. run.Kills++; return; } if (!run.Alive.Remove(creature.Guid.Full)) return; run.Kills++; var left = run.Alive.Count; if (left == 0) { Complete(run); return; } var s = Mod.Settings; if (left <= s.ProgressLast || (s.ProgressEvery > 0 && run.Kills % s.ProgressEvery == 0)) Tell(run.Player, $"{run.Name}: {left} left."); } /// WorldObject.Destroy of something still counted: gone without dying (a generator destroyed it, say). public static void OnGone(WorldObject wo) { var run = ByBlock(wo.CurrentLandblock); if (run is null || !run.Counted || !run.Alive.Remove(wo.Guid.Full)) return; run.Gone++; Log($"{run.Player.Name}: {wo.Name} left {run.Name} copy {run.Copy} without dying ({run.Alive.Count} left)"); if (run.Alive.Count == 0) Complete(run); } /// Landblock.AddWorldObject into a copy with a counted run: a late spawn joins the count. public static void OnAdded(Landblock block, WorldObject wo) { var run = ByBlock(block); if (run is null || !run.Counted || !Qualifies(wo)) return; if (!run.Alive.Add(wo.Guid.Full)) return; run.Initial++; Tell(run.Player, $"{run.Name}: one more appeared - {run.Alive.Count} left."); Log($"{run.Player.Name}: {wo.Name} spawned into {run.Name} copy {run.Copy} after the count ({run.Alive.Count} left)"); } /// Whether a generator in this landblock should hold its respawns. public static bool HoldsRegeneration(Landblock? block) => Mod.Settings.StopRegeneration && block is not null && ByBlock(block) is not null; // ------------------------------------------------------------ endings public static void Void(Run run, string reason) { if (run.Ended) return; run.Ended = true; var seconds = run.Elapsed; Tell(run.Player, $"Trial voided: {reason}. {run.Name} carries on as a normal copy."); Log($"{run.Player.Name}: {run.Name} copy {run.Copy} voided after {RunRow.Format(seconds)} ({reason}), {run.Kills} kills"); if (run.Staff || !TrialDb.Ready) return; try { TrialDb.InsertRun(new RunRow { CharacterId = run.Player.Guid.Full, CharacterName = run.Player.Name, Landblock = run.Landblock, Tier = run.Tier, Seconds = (float)seconds, Kills = run.Kills, Status = "void", Reason = reason, StartedAt = run.StartedUtc, FinishedAt = DateTime.UtcNow, Week = Weeks.KeyOf(DateTime.Now, Mod.Settings), }); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not record the voided run: {ex.Message}", ModManager.LogLevel.Warn); } } private static void Complete(Run run) { if (run.Ended) return; run.Ended = true; var seconds = run.Elapsed; var time = RunRow.Format(seconds); var player = run.Player; var s = Mod.Settings; Log($"{player.Name}: cleared {run.Name} copy {run.Copy} (tier {run.Tier}) in {time} - {run.Kills} kills, {run.Gone} gone, {run.Initial} counted{(run.CountedLate ? ", counted late" : "")}{(run.Staff ? " [staff, unrecorded]" : "")}"); if (run.Staff) { Tell(player, $"{run.Name} (tier {run.Tier}) cleared in {time} - {run.Kills} kills. Not recorded: staff runs are timed and told only."); return; } if (!TrialDb.Ready) { Tell(player, $"{run.Name} (tier {run.Tier}) cleared in {time} - {run.Kills} kills. The records table is not open; this run was not recorded."); return; } try { var previousBest = TrialDb.PersonalBest(player.Guid.Full, run.Landblock, run.Tier); var serverBest = TrialDb.ServerBest(run.Landblock, run.Tier); var personalBest = previousBest is null || seconds < previousBest.Seconds; var serverRecord = serverBest is null || seconds < serverBest.Seconds; TrialDb.InsertRun(new RunRow { CharacterId = player.Guid.Full, CharacterName = player.Name, Landblock = run.Landblock, Tier = run.Tier, Seconds = (float)seconds, Kills = run.Kills, Status = "complete", Reason = "", StartedAt = run.StartedUtc, FinishedAt = DateTime.UtcNow, Week = Weeks.KeyOf(DateTime.Now, s), }); var line = new StringBuilder($"{run.Name} (tier {run.Tier}) cleared in {time} - {run.Kills} kills"); if (serverRecord) line.Append(serverBest is null ? " - the first clear on the server: a server record" : $" - a new server record (previous {serverBest.Time} by {serverBest.CharacterName})"); else if (personalBest) line.Append(previousBest is null ? " - your first clear" : $" - your best (previous {previousBest.Time})") .Append($" - server best {serverBest!.Time} by {serverBest.CharacterName}"); else line.Append($" - your best is {previousBest!.Time} - server best {serverBest!.Time} by {serverBest.CharacterName}"); line.Append('.'); Tell(player, line.ToString()); var multiplier = serverRecord ? s.ServerRecordMultiplier : personalBest ? s.PersonalBestMultiplier : 1.0; var radiance = (long)Math.Round(s.RadiancePerClear * multiplier); if (radiance > 0) { if (Bank.AwardRadiance(player, radiance)) Tell(player, $"{radiance:N0} Radiance for the clear{(multiplier > 1 ? $" (x{multiplier:0.#} for the {(serverRecord ? "record" : "personal best")})" : "")}."); else Log($"no Radiance paid to {player.Name}: Bank {Bank.Status}"); } Feed.Write(); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not record {player.Name}'s clear: {ex}", ModManager.LogLevel.Error); Tell(player, $"{run.Name} cleared in {time} - but the run could not be recorded ({ex.Message})."); } } /// trial-void: an admin ends a run by hand. public static bool VoidFor(Player player, string by) { var run = ByPlayer(player); if (run is null) return false; Void(run, $"voided by {by}"); return true; } // ------------------------------------------------------------ helpers private static void Tell(Player? player, string text) { try { player?.SendMessage(text); } catch { /* the player may be leaving */ } } private static void Log(string text) { if (Mod.Settings.LogRuns) ModManager.Log($"[{Mod.Name}] {text}"); } }