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