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