namespace Aeshnidae.InstancesNoDat; /// A creature (or anything) placed by a variant, at a spot from /loc or /lbexport. public class PlacedObject { public uint Wcid { get; set; } /// Full cell id, e.g. 0x01E10123, as a hex string. 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; } public bool TryParseCell(out uint cell) { var text = (Cell ?? "").Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase); return uint.TryParse(text, System.Globalization.NumberStyles.HexNumber, null, out cell); } } public class Swap { public uint From { get; set; } public uint To { get; set; } } /// /// One way a dungeon's copy can be filled. The walls are the client's; everything /// placed inside is ours to change per copy. /// public class VariantSetting { public string Name { get; set; } = ""; /// For the random rule: relative chance. 0 is never picked at random (a boss variant reached only by a quest). public int Weight { get; set; } = 1; /// For the tier rule: the lowest enlightenment tier this variant is for; the highest MinTier at or below the copy's tier wins. public int MinTier { get; set; } /// Creature (or generator) wcid to spawn instead of another, wherever the original would have spawned - direct placements and generators alike. public List Swaps { get; set; } = new(); /// Placed wcids to leave out of this variant entirely. public List Removes { get; set; } = new(); /// Extra placements - a boss, a chest - that only this variant has. public List Adds { get; set; } = new(); /// /// This copy is NOT tier-scaled, whatever its owner's awakenings /// (Scaling.IsScaled answers false, so Scaling.TierFor answers 0 and every sibling /// mod that asks - Aeshnidae.AwakenedRewards - sees a stock copy). /// /// For a filling whose difficulty is already chosen rather than derived: a challenge /// run draws its roster from a score band, and the whole argument for the band /// (Aeshnidae.Challenge, ops/build-creature-index.md) is that multiplying an /// arbitrary retail creature's hand-written numbers is what the index exists to /// avoid. A copy whose cast was picked for its difficulty must not then be /// multiplied by a second difficulty dial, and its loot must not be stamped /// "Awakened N" for a tier it was never built at. /// public bool Unscaled { get; set; } /// /// Every monster spawned into this copy leaves NOTHING - no corpse and nothing on the /// floor. Scaling.LeaveNothing says how, and why ACE's PropertyBool.NoCorpse on its own /// is not enough (it drops the loot loose instead of into a corpse). /// /// A filling can be a run rather than a farm: get in, kill, get out, with the payout /// in one place at the end instead of in forty corpses on the way. Players never see /// the property - they see that nothing drops - and it is set on the creature in the /// copy, never on the weenie, so the real dungeon's monsters are untouched (the same /// one-creature-one-copy argument as Scaling's, stated at the top of Scaling.cs). /// Experience and luminance for the kill are untouched. /// public bool NoCorpse { get; set; } } /// /// Which monsters a copy is filled with. /// /// A copy of a dungeon has always spawned exactly what the real one does - the same /// placed rows, the same generators - with only the numbers scaled. A variant is a /// different filling for the same walls: swap this creature for that one, leave /// these out, add a boss at that spot. A dungeon lists its variants in Settings and /// a rule for choosing one when a copy opens: at random by weight, by the owner's /// enlightenment tier, by the day of the week, or a fixed one. An admin can force /// the next copy they open to a named variant, for testing and for quests. /// /// Two hooks make it real, both keyed on the copy in Context.Ambient: /// /// - The placed rows. Landblock.CreateWorldObjects reads them through /// GetCachedInstancesByLandblock, on the copy's spawn thread; the ambient is set /// for the duration and the list handed back is a fresh one with the swaps, /// removals and additions applied. The cached list is never touched - it is the /// real dungeon's, and every other copy's. /// - Generators. Most dungeon monsters come from generator weenies, which spawn /// through WorldObjectFactory.CreateNewWorldObject(wcid) during the copy's tick, /// when the ambient is already the copy. The wcid is swapped on the way in. /// /// Neither hook fires for ACE's own landblocks: no ambient, no variant, stock /// behaviour. /// public static class Variants { private static readonly ConcurrentDictionary<(ushort Landblock, int Copy), VariantSetting> _chosen = new(); /// A named variant an admin wants for the next copy they cause to open: player guid -> variant name. private static readonly ConcurrentDictionary _forced = new(); /// /// A sibling mod's chance to decide a copy's whole filling, per copy, at the moment it /// opens: (landblock, copy, owner guid) -> a VariantSetting as JSON, or null to leave /// the settings file's own choice alone. /// /// Set by reflection on this plain static field, like Arrival.OnArrival - the signature /// is nothing but host and runtime types, so no InstancesNoDat type crosses a load /// context and a hot reload of either mod cannot leave a stale cast behind. JSON rather /// than the object because VariantSetting IS one of this mod's types; the provider /// writes the same shape the settings file holds, and it is read here with this mod's /// own reader, so the two can never disagree about the schema. /// /// Why a hook at all: a variant in Settings.json is a fixed cast. Aeshnidae.Challenge /// draws a different, difficulty-matched cast for every run, which is a different list /// of Adds each time - not something a settings file can hold. Everything after this /// point is unchanged machinery: the returned variant is recorded for the copy and /// ApplyToRows, SwapWcid and Describe treat it exactly as a configured one. /// public static Func? Filling; public static IReadOnlyList Of(ushort landblock) => Mod.VariantEntry(landblock) is { } entry ? entry.Variants ?? new List() : new List(); public static VariantSetting? ChosenFor(ushort landblock, int copy) => _chosen.TryGetValue((landblock, copy), out var v) ? v : null; public static void Forget(ushort landblock, int copy) => _chosen.TryRemove((landblock, copy), out _); public static void ForgetAll() => _chosen.Clear(); public static void Force(Player player, string? name) { if (string.IsNullOrWhiteSpace(name)) _forced.TryRemove(player.Guid.Full, out _); else _forced[player.Guid.Full] = name; } public static string? ForcedFor(Player player) => _forced.TryGetValue(player.Guid.Full, out var n) ? n : null; /// /// Pick the variant for a copy about to open. Called from Scaling.Remember, which /// runs before the copy is created, so the choice is on record before the first /// row is read. A dungeon with no variants gets none - stock filling. /// public static void Choose(ushort landblock, int copy, Player player) { try { // An awakened world is the real Dereth, harder: no variant (the island's quest variant // included) ever applies to one of its copies. if (copy <= 0 || Worlds.IsWorldCopy(copy) || Enclaves.IsEnclaveCopy(copy)) return; // A sibling mod's filling, first and regardless of what the settings file says // about this landblock - a challenge shell has no configured variants at all, // so this has to come before the "no variants, stock filling" exit below. if (FromProvider(landblock, copy, player) is { } json && Supply(landblock, copy, json, player.Name)) return; if (Mod.VariantEntry(landblock) is not { } entry) return; var variants = entry.Variants ?? new List(); if (variants.Count == 0) return; VariantSetting? pick = null; if (_forced.TryRemove(player.Guid.Full, out var forcedName)) pick = variants.FirstOrDefault(v => v.Name.Equals(forcedName, StringComparison.OrdinalIgnoreCase)); pick ??= (entry.VariantRule ?? "random").Trim().ToLowerInvariant() switch { "fixed" => variants.FirstOrDefault(v => v.Name.Equals(entry.FixedVariant, StringComparison.OrdinalIgnoreCase)) ?? variants[0], "tier" => ByTier(variants, Scaling.Counted(landblock, copy)), "day" => variants[(int)(DateTime.UtcNow.DayOfYear % variants.Count)], _ => ByWeight(variants), }; if (pick is null) return; _chosen[(landblock, copy)] = pick; ModManager.Log($"[{Mod.Name}] copy {copy} of {landblock:X4} opens as variant '{pick.Name}' for {player.Name}"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] variant choice failed for {landblock:X4}: {ex.Message}", ModManager.LogLevel.Warn); } } /// The provider's JSON for this copy, or null. Nothing is parsed here. private static string? FromProvider(ushort landblock, int copy, Player player) { var provider = Filling; if (provider is null) return null; try { var json = provider(landblock, copy, player.Guid.Full); return string.IsNullOrWhiteSpace(json) ? null : json; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] a filling provider threw for {landblock:X4} copy {copy} ({ex.Message}); the copy opens stock", ModManager.LogLevel.Warn); return null; } } /// /// Records a variant for a copy from JSON, as the Filling hook's answer or by hand. /// Returns false when the JSON is not a variant, and then the copy opens stock. /// /// Public because it is also the only way to open a copy with a chosen filling WITHOUT /// A PLAYER: supply the filling, then InstanceWorld.GetOrCreate, and the copy spawns /// for real with its rows, its removals, its NoCorpse and its Unscaled flag. Every /// other route in needs somebody logged in, which a console-only test server does not /// have (Aeshnidae.Challenge's challenge-admin open, 2026-10-13). Call it BEFORE the /// copy exists - a copy already spawned keeps the rows it was built from. /// /// The JSON is read with this mod's own reader, so a provider writing the shape it sees /// in Settings.json is right by construction and a field added here is a field ignored /// there rather than a cast that fails. /// public static bool Supply(ushort landblock, int copy, string json, string why = "") { if (copy <= 0 || string.IsNullOrWhiteSpace(json)) return false; try { var variant = JsonSerializer.Deserialize(json, ProviderJson); if (variant is null) return false; if (string.IsNullOrWhiteSpace(variant.Name)) variant.Name = "supplied"; _chosen[(landblock, copy)] = variant; ModManager.Log($"[{Mod.Name}] copy {copy} of {landblock:X4} opens as supplied variant '{variant.Name}'" + $"{(string.IsNullOrWhiteSpace(why) ? "" : $" for {why}")} " + $"({variant.Adds.Count} placement(s), {variant.Removes.Count} removal(s)" + $"{(variant.Unscaled ? ", unscaled" : "")}{(variant.NoCorpse ? ", nothing dropped" : "")})"); return true; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] supplied filling for {landblock:X4} copy {copy} was rejected ({ex.Message}); the copy opens stock", ModManager.LogLevel.Warn); return false; } } private static readonly JsonSerializerOptions ProviderJson = new() { PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, }; private static VariantSetting? ByTier(List variants, int tier) => variants.Where(v => v.MinTier <= tier).OrderByDescending(v => v.MinTier).FirstOrDefault() ?? variants.OrderBy(v => v.MinTier).First(); private static VariantSetting? ByWeight(List variants) { var eligible = variants.Where(v => v.Weight > 0).ToList(); if (eligible.Count == 0) return null; var roll = ThreadSafeRandom.Next(0, eligible.Sum(v => v.Weight) - 1); foreach (var v in eligible) { roll -= v.Weight; if (roll < 0) return v; } return eligible[^1]; } // ------------------------------------------------------------ application /// The copy whose rows are being read or whose generator is spawning, with its variant - or null. private static (ushort Landblock, int Copy, VariantSetting Variant)? Current() { var block = Context.Ambient; if (block is null || !InstanceWorld.IsOurs(block)) return null; var copy = InstanceWorld.CopyOf(block); var landblock = (ushort)block.Id.Landblock; return _chosen.TryGetValue((landblock, copy), out var v) ? (landblock, copy, v) : null; } /// Once per (copy, wcid): a line saying the swap happened, so a tier-0 copy can be checked from the log. private static readonly HashSet<(ushort, int, uint)> _traced = new(); public static void TraceSwap(uint from, uint to) { var current = Current(); if (current is null) return; var key = (current.Value.Landblock, current.Value.Copy, from); lock (_traced) { if (!_traced.Add(key)) return; } Mod.Trace($"copy {current.Value.Copy} of {current.Value.Landblock:X4} ('{current.Value.Variant.Name}'): spawning {to} in place of {from}"); } /// A generator's wcid, swapped if the copy's variant says so. public static uint SwapWcid(uint wcid) { var current = Current(); if (current is null) return wcid; return SwapFor(current.Value.Variant, wcid); } /// /// The wcid a variant puts in place of this one. A Swap with From 0 is a wildcard: /// every MONSTER (a Creature that is attackable or has a targeting tactic - ACE's own /// Creature.IsMonster test) not named by another swap becomes its To. NPCs, vendors, /// markers and everything that is not a creature are left alone. For stocking a whole /// area with one creature (the Aerlinthe region test, 2026-09-25). /// private static uint SwapFor(VariantSetting variant, uint wcid) { var swap = variant.Swaps.FirstOrDefault(s => s.From == wcid); if (swap is not null) return swap.To; var wildcard = variant.Swaps.FirstOrDefault(s => s.From == 0); if (wildcard is null || wildcard.To == wcid || !IsMonster(wcid)) return wcid; return wildcard.To; } private static readonly ConcurrentDictionary _monsters = new(); private static bool IsMonster(uint wcid) => _monsters.GetOrAdd(wcid, id => { var weenie = DatabaseManager.World.GetCachedWeenie(id); if (weenie is null || weenie.WeenieType != WeenieType.Creature) return false; return (ACE.Entity.Models.WeenieExtensions.GetProperty(weenie, PropertyBool.Attackable) ?? false) || (ACE.Entity.Models.WeenieExtensions.GetProperty(weenie, PropertyInt.TargetingTactic) ?? 0) != 0; }); /// /// The placed rows for the copy: the real dungeon's, minus removals, with swaps /// applied, plus this variant's additions. Always a new list; the source is /// never modified. /// public static List ApplyToRows(List source) { var current = Current(); if (current is null || source is null) return source!; var (landblock, _, variant) = current.Value; var result = new List(source.Count + variant.Adds.Count); var used = new HashSet(source.Select(r => r.Guid)); foreach (var row in source) { if (variant.Removes.Contains(row.WeenieClassId)) continue; var to = SwapFor(variant, row.WeenieClassId); if (to == row.WeenieClassId) { result.Add(row); continue; } // A copy of the row with the new wcid; guid, position and links stay, so // anything linked to the original (a generator's children) still resolves. result.Add(new LandblockInstance { Guid = row.Guid, Landblock = row.Landblock, WeenieClassId = to, ObjCellId = row.ObjCellId, OriginX = row.OriginX, OriginY = row.OriginY, OriginZ = row.OriginZ, AnglesW = row.AnglesW, AnglesX = row.AnglesX, AnglesY = row.AnglesY, AnglesZ = row.AnglesZ, IsLinkChild = row.IsLinkChild, LastModified = row.LastModified, LandblockInstanceLink = row.LandblockInstanceLink, }); } // Additions get static guids from the top of the landblock's range, above // anything the real dungeon uses. var next = 0x70000000u | ((uint)landblock << 12) | 0xF00u; foreach (var add in variant.Adds) { if (!add.TryParseCell(out var cell)) { ModManager.Log($"[{Mod.Name}] variant '{variant.Name}': '{add.Cell}' is not a cell id; placement skipped", ModManager.LogLevel.Warn); continue; } // A region's variant is shared by all its landblocks; each placement belongs // to the landblock its cell names. (For a one-landblock entry every cell is // its own, so nothing changes there.) if ((cell >> 16) != landblock) continue; while (used.Contains(next)) next++; used.Add(next); result.Add(new LandblockInstance { Guid = next, Landblock = landblock, WeenieClassId = add.Wcid, ObjCellId = cell, OriginX = add.X, OriginY = add.Y, OriginZ = add.Z, AnglesW = add.QW, AnglesX = add.QX, AnglesY = add.QY, AnglesZ = add.QZ, IsLinkChild = false, LastModified = DateTime.UtcNow, }); } return result; } /// For /inst and the entry message. public static string Describe(ushort landblock, int copy) { var v = ChosenFor(landblock, copy); return v is null ? "" : $"Variant: {v.Name}"; } }