using ACE.Server.Factories; using ShardBiota = ACE.Database.Models.Shard.Biota; namespace Aeshnidae.InstancesNoDat; /// /// Shared copies keep what is left in them, as a retail landblock does (Tom, 2026-09-27: /// "modify server-wide instances to behave the same as retail landblocks. Retaining items"). /// /// A real landblock saves its persistent objects - player corpses, items a player dropped: /// ACE's IsDynamicThatShouldPersistToShard - to the shard database every five minutes and /// when it unloads, and loads them back by landblock id when it loads (SpawnDynamicShardObjects). /// Copies did neither (Patches.PreSaveDB, PreSpawnDynamicShardObjects), because a copy shares /// its landblock's id: what it saved would reappear in the real landblock. That is right for a /// personal copy, which is thrown away. A Shared copy - the Hub of Awakening, the Trials Hub, /// the Aerlinthe island - is one place for everyone that is meant to last, so it now saves and /// loads like ACE, with a label: /// /// Tag (PropertyInt 9044 = N) on a saved object means "this lies in shared copy N of the /// landblock in its location", not in the real landblock. A Shared landblock has one /// shared copy, number 1 (InstanceWorld.SharedCopy), and one more per layer /// (InstancedLandblock.Layers, 2026-09-28): the tag's value says which, so a corpse on the /// PK island never turns up on the other one. Rows saved before layers carry 1. /// /// Save: a shared copy tags and saves its persistent objects where ACE would save a real /// landblock's - the timed save, the copy (or one landblock of a region) closing, and the /// server shutting down. /// Load: a shared copy loads only tagged objects; a real landblock skips them. /// /// The tag lives only in the database, while the object lies on the copy's ground: it is /// taken off the object in memory as it is loaded, so a tagged item a player picks up is /// saved into their pack WITHOUT it and can never vanish from a real landblock later. /// public static class Keep { public const PropertyInt Tag = (PropertyInt)9044; public static bool On => Mod.Settings.Enabled && Mod.Settings.KeepSharedCopies; /// Saved and loaded since the mod loaded, for /inst status and the log. public static int Saved, Loaded, SkippedInMaster; /// A shared copy of a landblock (its one, or a layer), by key - for a copy already taken out of the table (Close). public static bool IsSharedKey(ushort landblock, int copy) => Mod.IsSharedCopy(landblock, copy); /// One of our copies, and a shared one of its landblock (its one, or a layer). public static bool IsShared(Landblock? block) => block is not null && InstanceWorld.IsOurs(block) && Mod.IsSharedCopy((ushort)block.Id.Landblock, InstanceWorld.CopyOf(block)); /// The shared copy a saved object lies in (its tag), or 0 for the real landblock. private static int TagOf(ShardBiota biota) => biota.BiotaPropertiesInt?.FirstOrDefault(p => p.Type == (ushort)Tag)?.Value ?? 0; /// /// Tag and save every persistent object of a shared copy, as ACE's Landblock.SaveDB saves /// a real landblock's (containers with their contents). World thread. Returns how many. /// `copy` is the copy's number when the caller knows it and the table no longer does - a /// copy closing is taken out of the table first, and CopyOf would then answer 0, which is /// the real landblock's tag. /// public static int Save(Landblock block, string why, bool? shared = null, int copy = 0) { if (!On || !(shared ?? IsShared(block))) return 0; var biotas = new System.Collections.ObjectModel.Collection<(ACE.Entity.Models.Biota biota, ReaderWriterLockSlim rwLock)>(); var tagged = 0; if (copy <= 0) copy = InstanceWorld.CopyOf(block); if (copy <= 0) { ModManager.Log($"[{Mod.Name}] keep: {why}: no copy number for a shared copy of {block.Id.Landblock:X4} - nothing saved", ModManager.LogLevel.Warn); return 0; } foreach (var wo in InstanceWorld.ObjectsOf(block)) { if (wo is Player) continue; try { if (!wo.IsDynamicThatShouldPersistToShard()) continue; if (wo.GetProperty(Tag) != copy) wo.SetProperty(Tag, copy); // also marks ChangesDetected, so it is written Collect(wo, biotas); tagged++; } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] keep: could not save {wo.Name} (0x{wo.Guid.Full:X8}) in the shared copy of {block.Id.Landblock:X4}: {ex.Message}", ModManager.LogLevel.Warn); } } if (biotas.Count > 0) DatabaseManager.Shard.SaveBiotasInParallel(biotas, null); Saved += tagged; if (tagged > 0) Mod.Trace($"keep: {why}: {tagged} object(s) of the shared copy of {block.Id.Landblock:X4} saved ({biotas.Count} row set(s) written)"); return tagged; } /// ACE's Landblock.AddWorldObjectToBiotasSaveCollection, verbatim in effect. private static void Collect(WorldObject wo, System.Collections.ObjectModel.Collection<(ACE.Entity.Models.Biota biota, ReaderWriterLockSlim rwLock)> biotas) { if (wo.ChangesDetected) { wo.SaveBiotaToDatabase(false); biotas.Add((wo.Biota, wo.BiotaDatabaseLock)); } if (wo is Container container) { foreach (var item in container.Inventory.Values) Collect(item, biotas); } } /// /// Replaces ACE's SpawnDynamicShardObjects for any landblock while Keep is on: a shared /// copy loads the objects of its landblock id tagged with its copy number, a real /// landblock the untagged ones. /// Runs on Init's background task, as ACE's does; the adds are queued on the landblock. /// Returns false for a personal copy (loads nothing, as before). /// public static bool SpawnDynamics(Landblock block) { var ours = InstanceWorld.IsOurs(block); var shared = ours && IsShared(block); if (ours && !shared) return false; var dynamics = DatabaseManager.Shard.BaseDatabase.GetDynamicObjectsByLandblock(block.Id.Landblock); var copy = shared ? InstanceWorld.CopyOf(block) : 0; var wanted = dynamics.Where(b => TagOf(b) == copy).ToList(); if (!shared) SkippedInMaster += dynamics.Count - wanted.Count; if (wanted.Count == 0) return true; var objects = WorldObjectFactory.CreateWorldObjects(wanted); if (shared) { // Off the object in memory: from here it is simply a thing on the ground. The // next save of this copy tags it again; a player who picks it up saves it clean. foreach (var wo in objects) wo.RemoveProperty(Tag); Loaded += objects.Count; Mod.Trace($"keep: the shared copy of {block.Id.Landblock:X4} loads {objects.Count} kept object(s)"); } block.EnqueueAction(new ActionEventDelegate(() => { foreach (var wo in objects) block.AddWorldObject(wo); })); return true; } /// /// At shutdown ACE unloads (and so saves) every landblock it lists - never our copies. /// Queued to the world thread when ACE starts that unload; the shutdown waits for the /// landblocks and then for the database queue, so these saves are written. /// public static void SaveAllAtShutdown() { if (!On) return; WorldManager.EnqueueAction(new ActionEventDelegate(() => { var n = 0; foreach (var block in InstanceWorld.OpenCopies()) { try { n += Save(block, "shutdown"); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] keep: shutdown save of {block.Id.Landblock:X4} failed: {ex.Message}", ModManager.LogLevel.Warn); } } ModManager.Log($"[{Mod.Name}] keep: shutdown - {n} object(s) of shared copies saved"); })); } }