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