namespace Aeshnidae.InstancesNoDat;
///
/// Answers the only question the whole design turns on: when something asks for
/// "the landblock at this position", which copy does it get?
///
/// A position cannot answer it. Two copies of a dungeon have identical landblock ids
/// and identical cell ids, so Position is not enough to tell them apart - that is the
/// price of keeping the source id, and it is a price worth paying because it is what
/// makes the client need nothing.
///
/// So the answer comes from two places instead, in this order:
///
/// 1. AMBIENT - the copy currently being ticked. Physics reaches for landblocks
/// through static helpers that take a cell id and no requester, so there is
/// nothing to thread context through. A thread-local set around each copy's
/// tick covers it. This is not a hack grafted on: ACE already keeps
/// LandblockManager.CurrentMultiThreadedTickingLandblockGroup as a ThreadLocal
/// for the same class of problem, so it runs with the grain.
///
/// 2. ASSIGNMENT - which copy a given player belongs in. Consulted when someone
/// enters fresh from outside, which is the one moment a copy has to be chosen
/// rather than inferred.
///
/// Everything else needs neither, because an object already inside a copy holds a
/// direct CurrentLandblock reference to it.
///
public static class Context
{
private static readonly ThreadLocal _ambient = new();
/// The copy being ticked on this thread, if any.
public static Landblock? Ambient
{
get => _ambient.Value;
set => _ambient.Value = value;
}
///
/// (group key, landblock) -> copy. The key is a player guid, a fellowship leader's
/// guid or an allegiance monarch's guid depending on the dungeon's scope, so
/// "who shares this" is a question of which key is used and not a separate
/// mechanism per case.
///
private static readonly ConcurrentDictionary<(uint Group, ushort Landblock), int> _assigned = new();
public static int AssignedCount => _assigned.Count;
///
/// The key that owns a copy for this player, given the dungeon's scope.
///
/// Falls back to the player's own guid when they have no group - a soloist in a
/// fellowship dungeon gets their own copy rather than being refused, and someone
/// unsworn gets their own rather than sharing a stranger's hall.
///
public static uint GroupKey(Player player, InstanceScope scope) => scope switch
{
InstanceScope.Fellowship => player.Fellowship?.FellowshipLeaderGuid ?? player.Guid.Full,
InstanceScope.Allegiance => player.Allegiance?.MonarchId ?? player.Guid.Full,
_ => player.Guid.Full,
};
/// A human-readable description of who a copy belongs to, for messages.
public static string GroupDescription(Player player, InstanceScope scope) => scope switch
{
InstanceScope.Fellowship => player.Fellowship is not null
? $"your fellowship ({player.Fellowship.FellowshipName})"
: "you alone - you are not in a fellowship",
InstanceScope.Allegiance => player.Allegiance is not null
? "your allegiance"
: "you alone - you are not sworn to an allegiance",
_ => "you",
};
public static void Assign(Player player, ushort landblock, int copy, InstanceScope scope)
{
var key = GroupKey(player, scope);
if (copy <= 0)
_assigned.TryRemove((key, landblock), out _);
else
_assigned[(key, landblock)] = copy;
}
///
/// Players who have asked, once, to arrive on the master landblock rather than be
/// handed a copy.
///
/// An instanced dungeon copies you on entry by design, which is exactly wrong for
/// the three cases that mean to reach the original: /inst master, /inst leave,
/// and an admin teleporting to someone who is standing in the master. Clearing the
/// assignment is not enough on its own - the teleport that follows sees no
/// assignment, concludes you are arriving fresh, and issues a new copy.
///
/// Keyed by guid rather than held in a thread-local because the teleport is queued:
/// it runs on the world thread some ticks later, and a thread-local set now would
/// either be gone by then or leak into an unrelated player's arrival.
///
private static readonly ConcurrentDictionary _forced = new();
/// How long an unfulfilled request to reach a particular copy stays live.
private static readonly TimeSpan ForcedLifetime = TimeSpan.FromSeconds(60);
///
/// "Put me in this exact copy of this landblock, whatever the usual rules say."
/// Copy 0 means the master.
///
/// Needed because Resolve's first rule is that an object already in a copy stays in
/// it, and that rule is right for every case except the handful where somebody has
/// deliberately named a destination: /inst master, /inst leave, /inst enter n,
/// /inst goto, and an admin following a player with /teleto. Without this, all of
/// those silently leave you exactly where you were standing - the teleport runs, the
/// position is identical, and stay-put wins.
///
public static void Force(Player player, ushort landblock, int copy) =>
_forced[player.Guid.Full] = (landblock, copy, DateTime.UtcNow);
///
/// Peeked, not consumed. A teleport asks the routing question twice - once in
/// Player.Teleport and again in the physics relocation - and it is the SECOND
/// question that decides where the player lands. Consuming on the first left the
/// relocation with nothing to go on.
///
/// Expires on its own so a teleport that never completes cannot leave a player
/// permanently pinned.
///
public static bool HasForced(Player player, ushort landblock, out int copy)
{
copy = 0;
if (!_forced.TryGetValue(player.Guid.Full, out var forced))
return false;
if (DateTime.UtcNow - forced.When > ForcedLifetime)
{
_forced.TryRemove(player.Guid.Full, out _);
return false;
}
if (forced.Landblock != landblock)
return false;
copy = forced.Copy;
return true;
}
public static void ClearForced(Player player) => _forced.TryRemove(player.Guid.Full, out _);
/// Drops every assignment pointing at a copy that has been closed.
public static void ForgetCopy(ushort landblock, int copy)
{
foreach (var (key, value) in _assigned.ToList())
{
if (key.Landblock == landblock && value == copy)
_assigned.TryRemove(key, out _);
}
}
public static void ForgetAll() => _assigned.Clear();
/// The copy this player's group owns for a landblock, or 0 for ACE's own.
public static int CopyFor(Player player, ushort landblock)
{
var scope = Mod.ScopeFor(landblock);
return _assigned.TryGetValue((GroupKey(player, scope), landblock), out var copy) ? copy : 0;
}
///
/// The copy a world object should resolve to for a target landblock.
///
/// Order matters. An object already standing in a copy of the same landblock stays
/// where it is - that single rule is what keeps movement, combat and broadcast
/// correct without any of them knowing instances exist. Only after that does a
/// player's assignment get a say, and only then the ambient tick.
///
public static int Resolve(WorldObject? wo, ushort landblock)
{
if (wo is not null)
{
// Before the stay-put rule, because the whole point of asking for the master
// is to stop staying put. Cleared once the player is actually out of a copy,
// which is what makes this self-terminating rather than sticky.
if (wo is Player heading && HasForced(heading, landblock, out var forced))
{
// Cleared once they are actually there, which is what makes this
// self-terminating rather than a pin that outlives its purpose.
//
// "There" cannot be CopyOf == forced, because CopyOf answers 0 for the
// master AND for a closed copy nobody owns. A player still standing in
// the block they were being moved OUT of therefore looked like they had
// arrived, the flag cleared a step early, and the next question routed
// them somewhere else entirely.
var arrived = forced > 0
? InstanceWorld.CopyOf(heading.CurrentLandblock) == forced
&& InstanceWorld.IsOurs(heading.CurrentLandblock)
: !InstanceWorld.IsOurs(heading.CurrentLandblock)
&& !InstanceWorld.IsOrphaned(heading.CurrentLandblock);
if (arrived)
ClearForced(heading);
return forced;
}
var current = InstanceWorld.CopyOf(wo.CurrentLandblock);
if (current > 0 && wo.CurrentLandblock!.Id.LandblockX == (byte)(landblock >> 8)
&& wo.CurrentLandblock!.Id.LandblockY == (byte)(landblock & 0xFF))
return current;
if (wo is Player player)
{
var assigned = CopyFor(player, landblock);
if (assigned > 0)
return assigned;
}
// A projectile is created before it belongs anywhere: no landblock of its
// own, and not a player, so nothing else here can place it. Its shooter can.
// Without this, an arrow loosed inside a copy is born in the master and flies
// through a dungeon nobody is standing in.
var source = InstanceWorld.CopyOf(wo.ProjectileSource?.CurrentLandblock);
if (source > 0 && wo.ProjectileSource!.CurrentLandblock!.Id.LandblockX == (byte)(landblock >> 8)
&& wo.ProjectileSource!.CurrentLandblock!.Id.LandblockY == (byte)(landblock & 0xFF))
return source;
}
var ambient = InstanceWorld.CopyOf(Ambient);
if (ambient > 0 && Ambient!.Id.LandblockX == (byte)(landblock >> 8)
&& Ambient!.Id.LandblockY == (byte)(landblock & 0xFF))
return ambient;
return 0;
}
}