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