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