using System;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.App.Streaming;
///
/// Pure data type describing the set of landblocks currently considered
/// "visible" by the streaming system. Given a center landblock (x, y) and
/// a radius, builds the set of landblock IDs in the (2r+1)×(2r+1) window.
///
public sealed class StreamingRegion
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
public int NearRadius { get; }
public int FarRadius { get; }
// Strictly the (2r+1)×(2r+1) window (clamped to world bounds).
private readonly HashSet _visible = new();
// Everything currently loaded: window + hysteresis-retained landblocks.
private readonly HashSet _resident = new();
// Two-tier residence tracking: maps each resident LB to its current tier.
private readonly Dictionary _tierResidence = new();
// Set true after MarkResidentFromBootstrap. The two-tier RecenterTo
// requires this state to be seeded; calling RecenterTo before the
// bootstrap silently emits the entire window as fresh loads (no demotes,
// no unloads, since _tierResidence is empty), which is a correctness
// hazard. The flag converts that into a loud InvalidOperationException.
private bool _bootstrapped;
///
/// Landblock IDs in the current visible window in the AC 8.8 coordinate
/// form: (lbX << 24) | (lbY << 16) | 0xFFFF. The trailing
/// 0xFFFF selects the LandBlock dat (terrain heightmap); the
/// matching LandBlockInfo (static-object metadata) is at 0xFFFE
/// on the same coordinates and is resolved internally by
/// LandblockLoader.
///
///
/// This set is strictly the (2r+1)×(2r+1) window; it does NOT include
/// hysteresis-retained landblocks outside the window. Use
/// to enumerate everything actually loaded.
///
///
public IReadOnlyCollection Visible => _visible;
///
/// Every landblock currently loaded: the current visible window plus any
/// landblocks being held in memory by the hysteresis buffer (not yet past
/// the Radius + 2 unload threshold).
///
public IReadOnlyCollection Resident => _resident;
public StreamingRegion(int centerX, int centerY, int nearRadius, int farRadius)
{
NearRadius = nearRadius;
FarRadius = farRadius;
Radius = farRadius; // outer ring drives Resident bookkeeping
Recenter(centerX, centerY);
}
public StreamingRegion(int cx, int cy, int radius) : this(cx, cy, radius, radius) { }
private void Recenter(int cx, int cy)
{
CenterX = cx;
CenterY = cy;
_visible.Clear();
for (int dx = -Radius; dx <= Radius; dx++)
{
for (int dy = -Radius; dy <= Radius; dy++)
{
int nx = cx + dx;
int ny = cy + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF)
continue;
_visible.Add(EncodeLandblockId(nx, ny));
}
}
// On first construction, resident == visible.
_resident.UnionWith(_visible);
}
///
/// Encode a landblock at (lbX, lbY) into the AC dat id form. Always uses
/// the 0xFFFF terminator (LandBlock = terrain). The earlier
/// version of this method used 0xFFFE by mistake — that's the
/// LandBlockInfo id, and asking LandblockLoader.Load to read a
/// LandBlock at the LandBlockInfo coords corrupts the dat reader's
/// buffer position, returning a half-populated LandBlock.Height[]
/// array which renders as wildly distorted "ball with spikes" terrain.
///
internal static uint EncodeLandblockId(int lbX, int lbY)
=> ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFFu;
///
/// First-tick bootstrap: emit ToLoadNear for every LB in the inner ring,
/// ToLoadFar for every LB in the outer ring (between near and far). Used
/// by on the first call before any
/// RecenterTo.
///
public TwoTierDiff ComputeFirstTickDiff()
{
var near = new List();
var far = new List();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = CenterX + dx;
int ny = CenterY + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = System.Math.Abs(dx);
int absDy = System.Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
if (absDx <= NearRadius && absDy <= NearRadius)
near.Add(id);
else
far.Add(id);
}
}
return new TwoTierDiff(
ToLoadFar: far,
ToLoadNear: near,
ToPromote: System.Array.Empty(),
ToDemote: System.Array.Empty(),
ToUnload: System.Array.Empty());
}
///
/// Call once after to seed
/// _tierResidence with the initial window. Every LB in the inner
/// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far.
///
public void MarkResidentFromBootstrap()
{
if (_bootstrapped)
throw new InvalidOperationException(
"MarkResidentFromBootstrap was already called; calling it again would " +
"reset accumulated tier-residence state and silently drop differential " +
"data built up by interim RecenterTo calls.");
_tierResidence.Clear();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = CenterX + dx;
int ny = CenterY + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = Math.Abs(dx);
int absDy = Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
_tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius)
? TierResidence.Near
: TierResidence.Far;
}
}
_bootstrapped = true;
}
///
/// Test-visible wrapper around so test
/// assemblies can build expected IDs without duplicating the encoding rule.
///
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
=> EncodeLandblockId(lbX, lbY);
///
/// Two-tier recenter: computes the 5-list diff per Phase A.5 spec §4.2.
/// Hysteresis: NearRadius+2 for Near→Far demote; FarRadius+2 for Far→null
/// unload. Requires (or a prior
/// call to this method) to have seeded _tierResidence.
///
public TwoTierDiff RecenterTo(int newCx, int newCy)
{
if (!_bootstrapped)
throw new InvalidOperationException(
"Two-tier RecenterTo called before MarkResidentFromBootstrap. " +
"First call ComputeFirstTickDiff to enqueue the bootstrap loads, " +
"then MarkResidentFromBootstrap to seed _tierResidence, then RecenterTo " +
"for subsequent observer moves.");
int nearUnloadThreshold = NearRadius + 2;
int farUnloadThreshold = FarRadius + 2;
var toLoadFar = new List();
var toLoadNear = new List();
var toPromote = new List();
var toDemote = new List();
var toUnload = new List();
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
var newCenterIds = new HashSet();
for (int dx = -FarRadius; dx <= FarRadius; dx++)
{
for (int dy = -FarRadius; dy <= FarRadius; dy++)
{
int nx = newCx + dx;
int ny = newCy + dy;
if (nx < 0 || nx > 0xFF || ny < 0 || ny > 0xFF) continue;
int absDx = Math.Abs(dx);
int absDy = Math.Abs(dy);
bool inNear = absDx <= NearRadius && absDy <= NearRadius;
var id = EncodeLandblockId(nx, ny);
newCenterIds.Add(id);
if (!_tierResidence.TryGetValue(id, out var current))
{
// Not resident at all — fresh load.
if (inNear) toLoadNear.Add(id);
else toLoadFar.Add(id);
_tierResidence[id] = inNear ? TierResidence.Near : TierResidence.Far;
}
else if (current == TierResidence.Far && inNear)
{
// Was Far, now inside Near ring — promote.
toPromote.Add(id);
_tierResidence[id] = TierResidence.Near;
}
// Near→Near and Far→Far are no-ops.
}
}
// Pass 2: check previously-resident LBs for demote / unload.
foreach (var kvp in _tierResidence.ToArray())
{
var id = kvp.Key;
var current = kvp.Value;
int lbX = (int)((id >> 24) & 0xFFu);
int lbY = (int)((id >> 16) & 0xFFu);
int absDx = Math.Abs(lbX - newCx);
int absDy = Math.Abs(lbY - newCy);
int distance = Math.Max(absDx, absDy); // Chebyshev
if (newCenterIds.Contains(id))
{
// Still in the far window — only Near→Far demote possible here.
if (current == TierResidence.Near && (absDx > NearRadius || absDy > NearRadius))
{
if (distance > nearUnloadThreshold)
{
toDemote.Add(id);
_tierResidence[id] = TierResidence.Far;
}
}
continue;
}
// Outside the new window — demote / unload by threshold.
if (current == TierResidence.Near)
{
if (distance > nearUnloadThreshold)
{
toDemote.Add(id);
_tierResidence[id] = TierResidence.Far;
if (distance > farUnloadThreshold)
{
toUnload.Add(id);
_tierResidence.Remove(id);
}
}
}
else if (current == TierResidence.Far)
{
if (distance > farUnloadThreshold)
{
toUnload.Add(id);
_tierResidence.Remove(id);
}
}
}
CenterX = newCx;
CenterY = newCy;
return new TwoTierDiff(toLoadFar, toLoadNear, toPromote, toDemote, toUnload);
}
///
/// Recompute the visible window around a new center and return the
/// delta vs. the previous state. Hysteresis: landblocks aren't unloaded
/// until they're further than Radius + 2 from the new center,
/// so boundary crossings don't thrash.
///
public RegionDiff RecenterToSingleTier(int newCx, int newCy)
{
// Snapshot the old resident set so we can diff against it.
var oldResident = new HashSet(_resident);
// Recompute _visible strictly as the new window.
Recenter(newCx, newCy);
// Loads = entries in the new window not yet in the resident set.
var toLoad = new List();
foreach (var id in _visible)
if (!oldResident.Contains(id))
toLoad.Add(id);
// Unloads = resident entries outside the hysteresis threshold
// (|dx| > Radius+2 OR |dy| > Radius+2).
int unloadThreshold = Radius + 2;
var toUnload = new List();
foreach (var id in oldResident)
{
if (_visible.Contains(id)) continue; // still in window, keep
int lbX = (int)((id >> 24) & 0xFFu);
int lbY = (int)((id >> 16) & 0xFFu);
int dx = Math.Abs(lbX - newCx);
int dy = Math.Abs(lbY - newCy);
if (dx > unloadThreshold || dy > unloadThreshold)
toUnload.Add(id);
}
// Update resident: (oldResident ∪ newVisible) ∖ toUnload.
_resident.UnionWith(_visible);
foreach (var id in toUnload)
_resident.Remove(id);
return new RegionDiff(toLoad, toUnload);
}
}
///
/// Output of : the landblocks to
/// start loading (newly entered the visible window) and the landblocks to
/// unload (fell outside the unload threshold, which is Radius + 2).
/// Both lists are disjoint from the current
/// set; the caller hands them to LandblockStreamer as jobs.
///
public readonly record struct RegionDiff(
IReadOnlyList ToLoad,
IReadOnlyList ToUnload);
///
/// Tracks which tier a landblock currently occupies in the two-tier streaming
/// model. Absence from the dictionary encodes "not resident"; the enum has no
/// None member to avoid suggesting a third runtime state.
///
internal enum TierResidence { Far, Near }