namespace Aeshnidae.Challenge; /// One challenge run: what was drawn for a copy, and whether its boss is dead. public class Run { public ushort Landblock { get; set; } public int Copy { get; set; } public uint OwnerGuid { get; set; } public string OwnerName { get; set; } = ""; public int Resolve { get; set; } public DateTime Opened { get; set; } = DateTime.UtcNow; public ChallengeShell Shell { get; set; } = new(); public DrawnRoster Roster { get; set; } = new(); public uint BossWcid { get; set; } public SpawnSpot? BossSpot { get; set; } /// Set the moment the boss dies, before anything is paid, so a double death pays once. public bool BossDead { get; set; } public bool ChestOpened { get; set; } /// Set once the boss has been found standing in the copy, or once a repair has been tried. public bool BossChecked { get; set; } /// The reward multiplier the draw earned - the band actually drawn, not the Resolve asked for. public double RewardMultiplier { get; set; } = 1; } /// /// The run: drawing a copy's filling when it opens, telling the player what they drew, /// and turning the boss's death into a chest and a payout. /// /// Nothing here patches ACE except one postfix on Creature.Die (Patches.cs). The filling /// is handed to InstancesNoDat through its own hook, the monsters are its Adds, the /// corpses are its NoCorpse flag and the exemption from item scaling is its Unscaled flag. /// This mod's whole job is deciding what goes in. /// public static class Runs { private static readonly ConcurrentDictionary<(ushort Landblock, int Copy), Run> _runs = new(); /// Resolve: PropertyInt 9053 on the character, the level in force (9054 is seals paid for). public const PropertyInt ResolveProperty = (PropertyInt)9053; public static IReadOnlyCollection Open => _runs.Values.ToList(); public static Run? RunFor(ushort landblock, int copy) => _runs.TryGetValue((landblock, copy), out var run) ? run : null; public static void Clear() => _runs.Clear(); public static bool Forget(ushort landblock, int copy) => _runs.TryRemove((landblock, copy), out _); /// /// The Variants.Filling hook. Called by InstancesNoDat from Scaling.Remember, before /// the copy exists and before its first row is read, on the thread that is opening it. /// /// Returns a VariantSetting as JSON, or null to leave the copy alone - and null is the /// answer for every landblock that is not a challenge shell, which is every landblock /// on the server but one. That test is a dictionary lookup and comes first, because /// this runs on every copy anybody opens. /// public static string? FillingFor(ushort landblock, int copy, uint ownerGuid) { if (!Mod.Settings.Enabled) return null; var shell = Mod.Settings.ShellFor(landblock); if (shell is null) return null; var owner = PlayerManager.GetOnlinePlayer(ownerGuid); var resolve = Math.Max(0, owner?.GetProperty(ResolveProperty) ?? 0); return Fill(shell, copy, resolve, owner?.Name ?? $"guid {ownerGuid}", ownerGuid); } /// /// The same draw with the Resolve given rather than read off a character - for /// challenge-admin, which has to work with nobody logged in. A copy of 0 or less draws /// and prints without recording a run. /// public static string? FillingForResolve(ChallengeShell shell, int copy, int resolve) => Fill(shell, copy, Math.Max(0, resolve), $"challenge-admin (Resolve {resolve})", 0); private static string? Fill(ChallengeShell shell, int copy, int resolve, string ownerName, uint ownerGuid) { try { if (!shell.TryParse(out var landblock)) return null; var plan = Shell.PlanFor(shell); if (plan is null || plan.Spots.Count == 0) { ModManager.Log($"[{Mod.Name}] {shell.Name} has no spawn spots loaded; copy {copy} opens stock", ModManager.LogLevel.Warn); return null; } var roster = Roster.Draw(shell, resolve); if (roster?.Boss is null || roster.Species.Count == 0) { ModManager.Log($"[{Mod.Name}] no roster could be drawn for {shell.Name} at Resolve {resolve}; copy {copy} opens stock", ModManager.LogLevel.Warn); return null; } var run = new Run { Landblock = landblock, Copy = copy, OwnerGuid = ownerGuid, OwnerName = ownerName, Resolve = resolve, Shell = shell, Roster = roster, BossWcid = roster.Boss.Wcid, BossSpot = plan.BossSpot, RewardMultiplier = MultiplierFor(shell, roster), }; if (copy > 0) _runs[(landblock, copy)] = run; var variant = BuildVariant(shell, plan, roster); if (Mod.Settings.LogRosters) ModManager.Log($"[{Mod.Name}] {shell.Name} copy {copy} for {run.OwnerName} at Resolve {resolve}: " + $"{variant.Adds.Count} placements, boss {roster.Boss.Name} ({roster.Boss.Wcid}) at " + $"{plan.BossSpot?.CellHex} {plan.BossSpot?.FromEntry:0} m in, reward x{run.RewardMultiplier:0.##}"); return JsonSerializer.Serialize(variant, VariantJson); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] drawing a filling for {shell.Landblock} copy {copy} failed: {ex}", ModManager.LogLevel.Error); return null; } } /// /// The variant: retail's cast out, the drawn cast in at retail's own spots, no corpses, /// and not tier-scaled. /// /// Spots are taken nearest-the-entry-first after skipping EntryClearSpots, and spread /// evenly over however many the shell has, so a short run still reaches the far end /// rather than crowding the first room. The last spot is always the boss's and is never /// given a minion. /// private static ProvidedVariant BuildVariant(ChallengeShell shell, ShellPlan plan, DrawnRoster roster) { var variant = new ProvidedVariant { Name = $"challenge-{shell.Landblock}-r{roster.Target:0.###}", Weight = 0, Unscaled = true, NoCorpse = true, Removes = plan.Removes.ToList(), }; var boss = plan.BossSpot; // Everything except the entry-clear spots and the boss's own spot. var skip = Math.Clamp(shell.EntryClearSpots, 0, Math.Max(0, plan.Spots.Count - 2)); var usable = plan.Spots .Skip(skip) .Where(s => !ReferenceEquals(s, boss)) .ToList(); var want = Math.Clamp(shell.Spawns, 1, usable.Count); // Evenly spaced through the ordered list, so the cast is spread from the entry to // the far room instead of bunched wherever the random numbers fell. var chosen = new List(); for (var i = 0; i < want; i++) { var at = (int)Math.Round(i * (usable.Count - 1) / (double)Math.Max(1, want - 1)); chosen.Add(usable[Math.Clamp(at, 0, usable.Count - 1)]); } foreach (var spot in chosen.Distinct()) { var species = roster.Species[ThreadSafeRandom.Next(0, roster.Species.Count - 1)]; variant.Adds.Add(Placement(species.Wcid, spot)); } if (boss is not null) variant.Adds.Add(Placement(roster.Boss!.Wcid, boss)); return variant; } private static ProvidedPlacement Placement(uint wcid, SpawnSpot spot) => new() { Wcid = wcid, Cell = spot.CellHex, X = spot.X, Y = spot.Y, Z = spot.Z, QW = spot.QW, QX = spot.QX, QY = spot.QY, QZ = spot.QZ, }; /// /// What the draw earned: the mean score of the cast against the shell's base score. /// Resolve is not in this formula at all - that is the point. A high Resolve that drew /// the soft end of its band pays the soft end's reward. /// private static double MultiplierFor(ChallengeShell shell, DrawnRoster roster) { if (shell.BaseScore <= 0 || roster.MeanScore <= 0) return 1; var ratio = Math.Pow(roster.MeanScore / shell.BaseScore, shell.RewardScorePower); if (shell.MaxRewardMultiplier > 0) ratio = Math.Min(ratio, shell.MaxRewardMultiplier); return Math.Max(0.1, ratio); } // ------------------------------------------------------------ the arrival line /// /// InstancesNoDat's Arrival.OnArrival. It says nothing itself about an Unscaled copy, /// so this is the only line a player gets - what they drew and what they are looking /// for. No numbers from the index: a score is a developer's tool, and "five kinds of /// thing and something worse at the bottom" is what a player needs. /// public static void OnArrival(Player player, ushort landblock, int copy, int tier) { try { if (RunFor(landblock, copy) is not { } run) return; player.SendMessage($"{run.Shell.Name}: a challenge run at Resolve {run.Resolve}. " + $"Nothing here leaves a corpse - the reward is a chest, and the chest is behind {run.Roster.Boss?.Name ?? "whatever is at the bottom"}."); if (run.BossDead) player.SendMessage("The chest is already open ground: your boss is dead."); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] arrival line failed: {ex.Message}", ModManager.LogLevel.Warn); } } // ------------------------------------------------------------ the boss /// /// Patches.PostCreatureDie: something died. If it was a run's boss, the chest appears /// and the run pays out. /// /// The cheap tests come first - this runs on every creature death on the server. /// public static void OnCreatureDie(Creature creature) { var block = creature.CurrentLandblock; if (block is null) return; var landblock = (ushort)block.Id.Landblock; if (Mod.Settings.ShellFor(landblock) is null) return; var copy = Instances.CopyOf(block); if (copy <= 0 || RunFor(landblock, copy) is not { } run) return; if (creature.WeenieClassId != run.BossWcid || run.BossDead) return; run.BossDead = true; var players = Instances.PlayersIn(block).ToList(); ModManager.Log($"[{Mod.Name}] {run.Shell.Name} copy {copy} ({run.OwnerName}, Resolve {run.Resolve}): " + $"boss {creature.Name} ({creature.WeenieClassId}) is dead after " + $"{(DateTime.UtcNow - run.Opened).TotalMinutes:0.#} min; paying {players.Count} player(s)"); SpawnChest(run, creature, block); Pay(run, players); } /// /// The chest, at the boss's spot rather than wherever the boss died - a boss pulled /// back up a corridor should not move the payout to the corridor. Falls back to the /// body's own position if the spot is somehow gone. /// private static void SpawnChest(Run run, Creature boss, Landblock block) { if (run.Shell.RewardChestWcid == 0) return; WorldObject? chest = null; try { chest = WorldObjectFactory.CreateNewWorldObject(run.Shell.RewardChestWcid); if (chest is null) { ModManager.Log($"[{Mod.Name}] chest wcid {run.Shell.RewardChestWcid} does not exist; no reward placed", ModManager.LogLevel.Warn); return; } var spot = run.BossSpot; chest.Location = spot is not null ? new Position(spot.Cell, spot.X, spot.Y, spot.Z, spot.QX, spot.QY, spot.QZ, spot.QW) : new Position(boss.Location); if (!Instances.EnterIntoCopy(chest, block)) { ModManager.Log($"[{Mod.Name}] the reward chest could not enter copy {run.Copy} of {run.Landblock:X4}", ModManager.LogLevel.Warn); chest.Destroy(); return; } ModManager.Log($"[{Mod.Name}] reward chest {chest.Name} placed in copy {run.Copy} of {run.Landblock:X4}"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] placing the reward chest failed: {ex.Message}", ModManager.LogLevel.Warn); try { chest?.Destroy(); } catch { /* already gone */ } } } /// /// The payout. XpType.Quest, so the server's experience rate applies exactly as it /// does to every other scripted award on this server (xp_modifier 0.5, so a player /// receives half of BossXp x the draw's multiplier). ShareType.None because a personal /// copy has already taken the player out of their fellowship. /// private static void Pay(Run run, List players) { var raw = (long)Math.Round(run.Shell.BossXp * run.RewardMultiplier); foreach (var player in players) { try { if (raw > 0) player.EarnXP(raw, XpType.Quest, ShareType.None); player.SendMessage($"The {run.Shell.Name} is clear. The chest is yours."); player.SendMessage($"Your run drew a band worth x{run.RewardMultiplier:0.##} of the base reward."); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] paying {player.Name} failed: {ex.Message}", ModManager.LogLevel.Warn); } } } // ------------------------------------------------------------ housekeeping private static DateTime _swept = DateTime.MinValue; /// /// Drops the record of any run whose copy InstancesNoDat no longer holds. Every 30 /// seconds from the world tick - a closed copy is the only way a run ends, and there is /// no hook for it, so this is a sweep rather than an event. /// /// The grace period is not politeness, it is correctness. A run is recorded by /// FillingFor, which InstancesNoDat calls BEFORE the copy exists: the whole point of /// Scaling.Remember running first is that the spawn happens on a background task. So /// for a moment every new run has no copy to find, and a sweep in that moment deletes /// the record of the run that is being opened - which it did, on the first open on /// staging (2026-10-13), logging "copy 1 closed (abandoned, 0 min)" a tick before the /// copy opened. /// private const double GraceSeconds = 60; public static void Sweep() { if (_runs.IsEmpty || (DateTime.UtcNow - _swept).TotalSeconds < 30) return; _swept = DateTime.UtcNow; foreach (var key in _runs.Keys.ToList()) { if (!_runs.TryGetValue(key, out var run)) continue; if (Instances.Find(key.Landblock, key.Copy) is { } open) EnsureBoss(run, open); if ((DateTime.UtcNow - run.Opened).TotalSeconds < GraceSeconds) continue; if (Instances.Find(key.Landblock, key.Copy) is null) { _runs.TryRemove(key, out var gone); if (gone is not null) ModManager.Log($"[{Mod.Name}] {gone.Shell.Name} copy {key.Copy} closed " + $"({(gone.BossDead ? "cleared" : "abandoned")}, {(DateTime.UtcNow - gone.Opened).TotalMinutes:0.#} min)"); } } } /// /// Makes sure the run HAS a boss, and puts one back if it does not. /// /// Why this is needed, found on staging 2026-10-13: a drawn creature's physics does not /// always fit a spot retail sized for something else. A Controlled Frost placed where a /// Zombie Revenant stood logged /// "AddWorldObjectInternal: couldn't spawn ... Controlled Frost ... at 0x01900129" /// and the copy opened with 18 of its 19 placements. For a minion that is harmless - the /// run is one monster shorter. For the BOSS it is fatal: no boss, no death, no chest, and /// a run that cannot be finished with nothing on screen to say why. /// /// So: once, a few seconds after the copy opens, look for the boss. If it is not there, /// try its spot again (a placement can lose a race with the minion sharing its cell), and /// failing that put it where the deepest surviving creature is standing - a position /// something has already proved a body fits. If even that fails the run is logged as /// broken rather than left silently unfinishable. /// private static void EnsureBoss(Run run, Landblock block) { if (run.BossChecked || run.BossDead || run.BossWcid == 0) return; // Give the background spawn pass time to finish before concluding anything. if ((DateTime.UtcNow - run.Opened).TotalSeconds < 10) return; try { var creatures = block.GetAllWorldObjectsForDiagnostics() .OfType() .Where(c => c is not Player) .ToList(); if (creatures.Any(c => c.WeenieClassId == run.BossWcid)) { run.BossChecked = true; return; } run.BossChecked = true; var spot = run.BossSpot; var fallback = creatures .Where(c => c.Location is not null) .OrderByDescending(c => spot is null ? 0 : Near(c, spot)) .FirstOrDefault(); foreach (var where in new[] { spot is null ? null : new Position(spot.Cell, spot.X, spot.Y, spot.Z, spot.QX, spot.QY, spot.QZ, spot.QW), fallback?.Location is null ? null : new Position(fallback.Location), }) { if (where is null) continue; var boss = WorldObjectFactory.CreateNewWorldObject(run.BossWcid); if (boss is null) break; boss.Location = where; if (Instances.EnterIntoCopy(boss, block)) { ModManager.Log($"[{Mod.Name}] {run.Shell.Name} copy {run.Copy}: the boss ({run.Roster.Boss?.Name}) " + $"had not spawned and was placed again at {where.Cell:X8} " + $"{where.PositionX:0.#} {where.PositionY:0.#} {where.PositionZ:0.#}", ModManager.LogLevel.Warn); return; } boss.Destroy(); } ModManager.Log($"[{Mod.Name}] {run.Shell.Name} copy {run.Copy} HAS NO BOSS: {run.Roster.Boss?.Name} " + $"(wcid {run.BossWcid}) would not spawn at its spot or beside a creature that did. " + $"This run cannot be cleared - the chest never appears.", ModManager.LogLevel.Error); } catch (Exception ex) { run.BossChecked = true; ModManager.Log($"[{Mod.Name}] boss check for copy {run.Copy} of {run.Landblock:X4} failed: {ex.Message}", ModManager.LogLevel.Warn); } } /// Negative squared distance from a spot, for "closest to the boss's spot". private static double Near(Creature creature, SpawnSpot spot) { var location = creature.Location; if (location is null) return double.MinValue; var dx = location.PositionX - spot.X; var dy = location.PositionY - spot.Y; var dz = location.PositionZ - spot.Z; return -(dx * dx + dy * dy + dz * dz); } private static readonly JsonSerializerOptions VariantJson = new() { WriteIndented = false }; } /// /// The shape of InstancesNoDat's VariantSetting, written out here because its type lives /// in another load context. The hook takes JSON for exactly that reason: this is the whole /// contract, and InstancesNoDat reads it with its own reader, so a field added there is a /// field ignored here rather than a cast that fails. /// public class ProvidedVariant { public string Name { get; set; } = ""; public int Weight { get; set; } public int MinTier { get; set; } public List Swaps { get; set; } = new(); public List Removes { get; set; } = new(); public List Adds { get; set; } = new(); public bool Unscaled { get; set; } public bool NoCorpse { get; set; } } public class ProvidedPlacement { public uint Wcid { get; set; } public string Cell { get; set; } = ""; public float X { get; set; } public float Y { get; set; } public float Z { get; set; } public float QW { get; set; } = 1; public float QX { get; set; } public float QY { get; set; } public float QZ { get; set; } }