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