namespace Aeshnidae.InstancesNoDat; /// /// Layers: more shared copies of a Shared landblock or region, each reached by its own /// portal (InstancedLandblock.Layers, SharedLayer). Added 2026-09-28 for a player-killer /// Aerlinthe beside the Aerlinthe instance (Tom: same characters, not scaled, a bright red /// portal) - the test of how PK plays before any bigger PK world. /// /// Routing into a layer is the caller's: Aeshnidae.OverworldCopies opens a region's layer /// copy for a portal named in the layer's OnlyVia (Mod.RegionCopyVia). A layer of a dungeon /// (an Instanced entry) is not routed to yet. Everything else a layer is follows from its /// copy number: Keep saves and loads its corpses and dropped items under that number, and a /// death in it loses items as in the entry's shared copy (Mod.IsSharedCopy). /// /// This file is the PlayerKiller rule. Standing in a PK layer, a character is a player /// killer the way ACE makes one: PlayerKillerStatus PK, and PkLevel PK - so ACE's own rules /// all apply unchanged: the red name, who may attack whom, the 20-second pk_timer that bars /// portals, recalls and logging off after a fight, a PK death's dispels and item drops, and /// the respite (pk_respite_timer) after dying to another player, which PkLevel PK is what /// brings them back from. What they were before is kept on the character (PropertyInt 9045) /// and put back the moment they are anywhere else. The check runs when a teleport completes, /// at login, and every heartbeat (about 5 s), so it holds after a crash, a relog or a reload: /// a character saved as a player killer is put back as soon as it is found outside a layer. /// [HarmonyPatch] public static class Layers { /// /// On a character while a PK layer holds it: what it was before, PlayerKillerStatus in /// the low 16 bits and PkLevelModifier above them. Absent means the layer rule is not /// in force on this character. /// public const PropertyInt Saved = (PropertyInt)9045; /// Whether any layer is PlayerKiller, so the heartbeat check is one read for everyone else. public static bool AnyPk { get; private set; } /// Made player killer and put back since the mod loaded, for inst-layers. public static int Made, Restored; /// After the settings are read (Mod.ReloadInstanced). public static void Refresh() { AnyPk = (Mod.Settings.Outdoor ?? new()).Concat(Mod.Settings.Instanced ?? new()) .Any(e => e.Layers?.Any(l => l.PlayerKiller) == true); } /// The layer this landblock is, or null: one of our copies, whose copy number is a layer of its entry. public static SharedLayer? Of(Landblock? block) => block is not null && InstanceWorld.IsOurs(block) ? Mod.LayerOf((ushort)block.Id.Landblock, InstanceWorld.CopyOf(block)) : null; /// /// Makes the character what its place says: a player killer in a PK layer, and what it /// was before anywhere else. Idempotent; cheap when neither applies. /// public static void Check(Player? player, string why) { if (player is null) return; var saved = player.GetProperty(Saved); if (!AnyPk && saved is null) return; try { var layer = Mod.Settings.Enabled ? Of(player.CurrentLandblock) : null; if (layer is { PlayerKiller: true } && !player.IsOlthoiPlayer && player.PlayerKillerStatus != PlayerKillerStatus.Free) MakePk(player, layer, saved, why); else if (saved is { } was) Restore(player, was, why); } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] layers: PK check for {player.Name} ({why}) failed: {ex.Message}", ModManager.LogLevel.Warn); } } private static void MakePk(Player player, SharedLayer layer, int? saved, string why) { if (saved is null) { player.SetProperty(Saved, ((int)player.PlayerKillerStatus & 0xFFFF) | (player.PkLevelModifier << 16)); player.SendMessage(string.IsNullOrWhiteSpace(layer.ArrivalMessage) ? "You are a Player Killer while you are here: anyone here may attack you, and you them. " + "Portals, recalls and logging off wait 20 seconds after a fight, and dying to another player drops items. " + "You are yourself again the moment you leave." : layer.ArrivalMessage); Made++; ModManager.Log($"[{Mod.Name}] layers: {player.Name} is a player killer in layer '{layer.Name}' ({why}; was {player.PlayerKillerStatus}, PkLevel {player.PkLevel})"); } // PkLevel is what ACE's respite brings a player killer back to (Player.PK_DeathTick). if (player.PkLevel != PKLevel.PK) player.PkLevel = PKLevel.PK; // In the respite after dying to another player, ACE turns them back itself. if (player.MinimumTimeSincePk is null && player.PlayerKillerStatus != PlayerKillerStatus.PK) { player.PlayerKillerStatus = PlayerKillerStatus.PK; player.EnqueueBroadcast(new GameMessagePublicUpdatePropertyInt(player, PropertyInt.PlayerKillerStatus, (int)player.PlayerKillerStatus)); } } private static void Restore(Player player, int saved, string why) { var status = (PlayerKillerStatus)(saved & 0xFFFF); var level = saved >> 16; var before = player.PlayerKillerStatus; player.RemoveProperty(Saved); player.PkLevelModifier = level; // Died on the island and in ACE's respite: someone who was a player killer before // comes back from it the retail way (their PkLevel is PK again); anyone else is // simply what they were. if (player.MinimumTimeSincePk is not null && (PKLevel)level == PKLevel.PK) { // leave the respite running } else { player.MinimumTimeSincePk = null; player.PlayerKillerStatus = status == PlayerKillerStatus.Undef ? PlayerKillerStatus.NPK : status; } if (player.PlayerKillerStatus != before) player.EnqueueBroadcast(new GameMessagePublicUpdatePropertyInt(player, PropertyInt.PlayerKillerStatus, (int)player.PlayerKillerStatus)); if (status != PlayerKillerStatus.PK) player.SendMessage("You are no longer a Player Killer."); Restored++; ModManager.Log($"[{Mod.Name}] layers: {player.Name} put back to {player.PlayerKillerStatus}, PkLevel {player.PkLevel} ({why})"); } /// Everyone online put back (the mod unloading, or switched off). public static void RestoreAllOnline(string why) { try { foreach (var player in PlayerManager.GetAllOnline()) { if (player?.GetProperty(Saved) is { } saved) Restore(player, saved, why); } } catch (Exception ex) { ModManager.Log($"[{Mod.Name}] layers: restoring player killers failed: {ex.Message}", ModManager.LogLevel.Warn); } } [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.OnTeleportComplete))] public static void PostTeleportComplete(Player __instance) => Check(__instance, "teleport"); [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.PlayerEnterWorld))] public static void PostEnterWorld(Player __instance) { // Not placed yet: the heartbeat checks a few seconds on, where they stand. if (__instance.CurrentLandblock is not null) Check(__instance, "login"); } [HarmonyPostfix] [HarmonyPatch(typeof(Player), nameof(Player.Heartbeat))] public static void PostHeartbeat(Player __instance) => Check(__instance, "heartbeat"); [CommandHandler("inst-layers", AccessLevel.Admin, CommandHandlerFlag.None, 0, "Layers: the extra shared copies (a PK island), who is in each, and their player-killer status. Console-safe.", "/inst-layers")] public static void HandleLayers(Session session, params string[] parameters) { var sb = new StringBuilder(); sb.AppendLine($"Layers: PK rule {(AnyPk ? "in use" : "unused")}; since load {Made} made player killer, {Restored} put back"); foreach (var entry in (Mod.Settings.Outdoor ?? new()).Concat(Mod.Settings.Instanced ?? new())) { foreach (var layer in entry.Layers ?? new()) { var name = string.IsNullOrWhiteSpace(entry.Name) ? entry.Landblock : entry.Name; var open = InstanceWorld.All.Where(a => a.Key.Copy == layer.Copy && entry.AllLandblocks().Contains(a.Key.Landblock)).ToList(); var players = open.SelectMany(a => InstanceWorld.PlayersIn(a.Block)).Distinct().ToList(); sb.AppendLine($" {name} layer '{layer.Name}': copy {layer.Copy}, portals [{string.Join(", ", layer.OnlyVia)}]" + $"{(layer.PlayerKiller ? ", PLAYER KILLER" : "")}; {open.Count} landblock(s) open, {players.Count} player(s)"); foreach (var p in players) sb.AppendLine($" {p.Name}: {p.PlayerKillerStatus}, PkLevel {p.PkLevel}, respite {(p.MinimumTimeSincePk is { } t ? $"{t:0}s" : "none")}, " + $"pk_timer {(p.PKTimerActive ? "ACTIVE" : "off")}, saved {(p.GetProperty(Saved) is { } s ? $"{(PlayerKillerStatus)(s & 0xFFFF)}/{s >> 16}" : "none")}"); } } var stray = PlayerManager.GetAllOnline().Where(p => p.GetProperty(Saved) is not null && Of(p.CurrentLandblock) is not { PlayerKiller: true }).ToList(); if (stray.Count > 0) sb.AppendLine($" outside a PK layer but still held (put back on the next heartbeat): {string.Join(", ", stray.Select(p => p.Name))}"); foreach (var line in sb.ToString().Split('\n')) { if (string.IsNullOrWhiteSpace(line)) continue; if (session?.Player is null) ModManager.Log(line.TrimEnd('\r')); else session.Player.SendMessage(line.TrimEnd('\r')); } } }