namespace Aeshnidae.Challenge; /// One place in the shell where a creature stands in the retail dungeon. public class SpawnSpot { public uint 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; } /// What retail puts here. Kept for the log, never spawned in a challenge copy. public uint RetailWcid { get; set; } /// Straight-line metres from the portal's arrival point - the only "depth" available without walking it. public double FromEntry { get; set; } public string CellHex => $"{Cell:X8}"; } /// /// What a shell gives a run: the spots creatures stand in, and the placed wcids a challenge /// copy must leave out. /// /// Read from the world database once per shell, not per run, and only from the REAL /// landblock's rows - the same rows ACE itself spawns from. A challenge copy then throws /// almost all of them away (Removes) and puts its own cast back at the same positions /// (Adds), which is how the Citadel Below was built; the difference is only that the cast /// is drawn rather than written down. /// /// What is removed, and why each: /// /// every creature the retail cast is the thing being replaced. /// every generator a link generator would keep putting retail monsters back for the /// whole run (the Old Mine has one on a three-minute cycle). /// every chest a run's payout is one chest at the end. Retail's five chests in /// the Old Mine would pay before the boss and make the boss optional. /// every locked door a level-1 character cannot pick an "excellent" lock and has no key, /// so a locked door in a 10-minute run is a wall. The doorway is open /// in the copy; the real dungeon's doors are untouched. /// /// What is kept: the exit portal, unlocked doors, and the scenery. Leaving is retail's own /// portal, which is why a shell with one was preferred. /// public class ShellPlan { public ushort Landblock { get; set; } public List Spots { get; set; } = new(); public List Removes { get; set; } = new(); /// Where the entry portal lands, from its weenie's Destination - the origin distances are measured from here. public Position? Entry { get; set; } /// The spot a boss goes in: the one furthest from the entry. public SpawnSpot? BossSpot => Spots.Count == 0 ? null : Spots[^1]; public string Describe() => $"{Landblock:X4}: {Spots.Count} spawn spot(s), {Removes.Count} placed wcid(s) removed, " + $"entry {(Entry is null ? "unknown" : $"{Entry.Cell:X8} {Entry.PositionX:0.#} {Entry.PositionY:0.#} {Entry.PositionZ:0.#}")}, " + $"boss spot {(BossSpot is null ? "none" : $"{BossSpot.CellHex} at {BossSpot.FromEntry:0} m")}"; } public static class Shell { private static readonly ConcurrentDictionary _plans = new(); public static ShellPlan? PlanFor(ChallengeShell shell) { if (!shell.TryParse(out var landblock)) return null; return _plans.TryGetValue(landblock, out var plan) ? plan : null; } public static void Forget() => _plans.Clear(); /// /// Reads every configured shell out of the world database. Called at load and from /// challenge-admin reload, on whatever thread asked - never on a player's teleport, so /// the drawing path can never wait on the database. /// public static int LoadAll() { _plans.Clear(); foreach (var shell in Mod.Settings.Shells ?? new List()) { if (!shell.TryParse(out var landblock)) { ModManager.Log($"[{Mod.Name}] '{shell.Landblock}' is not a hex landblock id", ModManager.LogLevel.Warn); continue; } if (Build(shell, landblock) is { } plan) { _plans[landblock] = plan; ModManager.Log($"[{Mod.Name}] shell '{shell.Name}' {plan.Describe()}"); } } return _plans.Count; } private static ShellPlan? Build(ChallengeShell shell, ushort landblock) { try { // The real landblock's rows. No copy ambient is set on this thread, so // InstancesNoDat's variant hook leaves the list alone and this is retail's. var rows = DatabaseManager.World.GetCachedInstancesByLandblock(landblock); if (rows is null || rows.Count == 0) { ModManager.Log($"[{Mod.Name}] {landblock:X4} has no placed rows - is that the right landblock?", ModManager.LogLevel.Warn); return null; } var entry = EntryOf(shell); var plan = new ShellPlan { Landblock = landblock, Entry = entry }; var removes = new HashSet(shell.AlsoRemove ?? new List()); foreach (var row in rows) { var weenie = DatabaseManager.World.GetCachedWeenie(row.WeenieClassId); if (weenie is null) continue; var isCreature = weenie.WeenieType == WeenieType.Creature; // Chest only, not Container: a Chest pays loot, a Container is as often a // bucket or a bookshelf, and removing scenery that pays nothing buys // nothing. Anything a shell turns out to need is named in AlsoRemove. var isChest = weenie.WeenieType == WeenieType.Chest; var isGenerator = weenie.PropertiesGenerator is { Count: > 0 }; var isLockedDoor = weenie.WeenieType == WeenieType.Door && (ACE.Entity.Models.WeenieExtensions.GetProperty(weenie, PropertyBool.Locked) ?? false); if (isCreature || isChest || isGenerator || isLockedDoor) removes.Add(row.WeenieClassId); if (!isCreature) continue; plan.Spots.Add(new SpawnSpot { Cell = row.ObjCellId, X = row.OriginX, Y = row.OriginY, Z = row.OriginZ, QW = row.AnglesW, QX = row.AnglesX, QY = row.AnglesY, QZ = row.AnglesZ, RetailWcid = row.WeenieClassId, FromEntry = entry is null ? 0 : Distance(entry, row), }); } plan.Removes = removes.OrderBy(w => w).ToList(); // Nearest the entry first, so "the first N spots" is "the ones you meet first" // and the last is the far end of the dungeon. Straight-line distance, which is // a proxy for depth and nothing more - it cannot know the corridors. For the // Old Mine it puts the boss where retail's own mini-boss stands, which is the // sanity check that the proxy is not nonsense. plan.Spots = plan.Spots.OrderBy(s => s.FromEntry).ToList(); return plan; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] could not read shell {landblock:X4}: {ex.Message}", ModManager.LogLevel.Warn); return null; } } /// The entry portal's Destination, read off its weenie. private static Position? EntryOf(ChallengeShell shell) { try { if (shell.EntryPortalWcid == 0) return null; var portal = DatabaseManager.World.GetCachedWeenie(shell.EntryPortalWcid); if (portal is null) { ModManager.Log($"[{Mod.Name}] portal {shell.EntryPortalWcid} is not in the world database yet " + $"- shell '{shell.Name}' will measure depth from the first placed row instead", ModManager.LogLevel.Warn); return null; } return ACE.Entity.Models.WeenieExtensions.GetPosition(portal, PositionType.Destination); } catch { return null; } } private static double Distance(Position entry, LandblockInstance row) { var dx = entry.PositionX - row.OriginX; var dy = entry.PositionY - row.OriginY; var dz = entry.PositionZ - row.OriginZ; return Math.Sqrt(dx * dx + dy * dy + dz * dz); } }