namespace Aeshnidae.Expedition;
///
/// The settlers' camps (Settings.Camps): what a reclaimed zone gains - a Settler with the
/// zone's story, a campfire, a portal home to Holtburg.
///
/// Kept by a reconciler rather than placed once, because the island is not a place that holds
/// still: its landblocks load around players and unload five minutes after they leave, and the
/// whole copy closes when it empties. Every Settings.CampCheckSeconds, on the world thread, each
/// camp object is made to match the zone: reclaimed and its landblock open in the island copy -
/// it stands there (adopted if it already does, spawned if not); anything else - it is gone.
/// A landblock that opens later gets its camp within one check.
///
/// Camp objects carry PropertyInt 9046 (Marker, the object's index in Settings.Camps + 1), which
/// is how a reloaded mod finds the ones already standing, and how Patches keeps them out of the
/// shard database: InstancesNoDat keeps a shared copy's dynamic objects (Keep), and a camp saved
/// there would come back beside the one this spawns.
///
public static class Camps
{
public const PropertyInt Marker = (PropertyInt)9046;
private static readonly Dictionary _standing = new();
private static readonly Dictionary _failedAt = new();
private static DateTime _lastCheck = DateTime.MinValue;
public static int Spawned, Removed, Failed;
/// Patches.PostTick: at most once per CampCheckSeconds.
public static void MaybeCheck()
{
if (DateTime.UtcNow - _lastCheck < TimeSpan.FromSeconds(Math.Max(1, Mod.Settings.CampCheckSeconds)))
return;
_lastCheck = DateTime.UtcNow;
Check();
}
/// One reconcile pass. World thread.
public static void Check()
{
var camps = Mod.Settings.Camps ?? new List();
for (var i = 0; i < camps.Count; i++)
{
try
{
Reconcile(i, camps[i]);
}
catch (Exception ex)
{
ModManager.Log($"[{Mod.Name}] camp object {i} ({camps[i].Label}) failed: {ex.Message}", ModManager.LogLevel.Warn);
}
}
}
private static void Reconcile(int index, CampObject spec)
{
if (!TryCell(spec.Cell, out var cell))
return;
var landblock = (ushort)(cell >> 16);
// A beacon's object (Act 2) stands by the beacon's rule; a camp's by its zone's reclaim.
var wanted = Mod.Settings.Enabled && (!string.IsNullOrWhiteSpace(spec.Beacon)
? Beacons.Wants(spec)
: Zones.Named(spec.Zone) is { } zone && Expedition.IsReclaimed(zone));
var block = Island.Find(landblock);
_standing.TryGetValue(index, out var standing);
// Gone with its landblock (unloaded, or the copy closed): forget it.
if (standing is not null && (standing.IsDestroyed || !ReferenceEquals(standing.CurrentLandblock, block)))
{
_standing.Remove(index);
standing = null;
}
if (!wanted)
{
if (standing is not null)
{
Remove(standing, block);
_standing.Remove(index);
}
// One the tracker never knew (a reload between the zone's reset and now).
if (block is not null)
foreach (var stray in Marked(block, index))
Remove(stray, block);
return;
}
if (block is null)
return;
// Exactly one: the one standing, or one already there from before a reload of this mod
// (adopted). Any other with the same mark is a duplicate - a camp object that reached the
// database while this mod was not there to stop it, loaded back by InstancesNoDat's Keep.
var marked = Marked(block, index).ToList();
var keep = standing ?? marked.FirstOrDefault();
foreach (var extra in marked.Where(o => !ReferenceEquals(o, keep)))
Remove(extra, block);
if (keep is not null)
{
_standing[index] = keep;
return;
}
if (_failedAt.TryGetValue(index, out var failed) && DateTime.UtcNow - failed < TimeSpan.FromMinutes(1))
return;
var wo = WorldObjectFactory.CreateNewWorldObject(spec.Wcid);
if (wo is null)
{
_failedAt[index] = DateTime.UtcNow;
Failed++;
ModManager.Log($"[{Mod.Name}] camp: no weenie {spec.Wcid} ({spec.Label})", ModManager.LogLevel.Warn);
return;
}
wo.Location = new Position(cell, spec.X, spec.Y, spec.Z, 0, 0, spec.Qz, spec.W);
wo.SetProperty(Marker, index + 1);
if (!Island.EnterIntoCopy(wo, block))
{
wo.Destroy();
_failedAt[index] = DateTime.UtcNow;
Failed++;
ModManager.Log($"[{Mod.Name}] camp: {spec.Label} (wcid {spec.Wcid}) could not be placed at {spec.Cell} [{spec.X} {spec.Y} {spec.Z}] - retrying in a minute", ModManager.LogLevel.Warn);
return;
}
_standing[index] = wo;
_failedAt.Remove(index);
Spawned++;
ModManager.Log($"[{Mod.Name}] camp: {spec.Label} stands in the island copy at {wo.Location.ToLOCString()} ({(string.IsNullOrWhiteSpace(spec.Beacon) ? $"{spec.Zone} reclaimed" : $"{spec.Beacon}{(spec.WhenLit ? " burning" : ", Act 2")}")})");
}
private static IEnumerable Marked(Landblock block, int index) =>
block.GetAllWorldObjectsForDiagnostics().Where(o => o.GetProperty(Marker) == index + 1 && !o.IsDestroyed).ToList();
private static void Remove(WorldObject wo, Landblock? block)
{
if (block is not null)
Island.WithAmbient(block, () => wo.Destroy());
else
wo.Destroy();
Removed++;
}
/// Every camp object standing now, for the admin status.
public static List<(int Index, CampObject Spec, WorldObject? Standing)> Report()
{
var camps = Mod.Settings.Camps ?? new List();
return camps.Select((c, i) => (i, c, _standing.TryGetValue(i, out var wo) && !wo.IsDestroyed ? wo : null)).ToList();
}
/// Takes down every camp object (the mod unloading). World thread.
public static void RemoveAll()
{
foreach (var (index, wo) in _standing.ToList())
{
if (!wo.IsDestroyed)
Remove(wo, wo.CurrentLandblock);
_standing.Remove(index);
}
}
private static bool TryCell(string text, out uint cell) =>
uint.TryParse((text ?? "").Trim().Replace("0x", "", StringComparison.OrdinalIgnoreCase),
System.Globalization.NumberStyles.HexNumber, null, out cell);
}