feat(A.5 T5): StreamingRegion two-tier RecenterTo + TierResidence tracking

Adds TierResidence enum (None/Far/Near), _tierResidence dictionary seeded
by MarkResidentFromBootstrap, and the canonical two-tier RecenterTo overload
returning TwoTierDiff. Pass 1 walks the new far window and emits ToLoadFar /
ToLoadNear / ToPromote; Pass 2 walks prior residents and emits ToDemote /
ToUnload using Chebyshev hysteresis thresholds (NearRadius+2 / FarRadius+2).
EncodeLandblockIdForTest exposes the encoding rule to test assemblies.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-05-09 22:36:20 +02:00
parent 7bcababf82
commit fb6b61e8ef
2 changed files with 165 additions and 1 deletions

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace AcDream.App.Streaming;
@ -22,6 +23,9 @@ public sealed class StreamingRegion
// Everything currently loaded: window + hysteresis-retained landblocks.
private readonly HashSet<uint> _resident = new();
// Two-tier residence tracking: maps each resident LB to its current tier.
private readonly Dictionary<uint, TierResidence> _tierResidence = new();
/// <summary>
/// Landblock IDs in the current visible window in the AC 8.8 coordinate
/// form: <c>(lbX &lt;&lt; 24) | (lbY &lt;&lt; 16) | 0xFFFF</c>. The trailing
@ -121,6 +125,142 @@ public sealed class StreamingRegion
ToUnload: System.Array.Empty<uint>());
}
/// <summary>
/// Call once after <see cref="ComputeFirstTickDiff"/> to seed
/// <c>_tierResidence</c> with the initial window. Every LB in the inner
/// ring (Chebyshev ≤ NearRadius) is marked Near; everything else Far.
/// </summary>
public void MarkResidentFromBootstrap()
{
_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 = System.Math.Abs(dx);
int absDy = System.Math.Abs(dy);
var id = EncodeLandblockId(nx, ny);
_tierResidence[id] = (absDx <= NearRadius && absDy <= NearRadius)
? TierResidence.Near
: TierResidence.Far;
}
}
}
/// <summary>
/// Test-visible wrapper around <see cref="EncodeLandblockId"/> so test
/// assemblies can build expected IDs without duplicating the encoding rule.
/// </summary>
internal static uint EncodeLandblockIdForTest(int lbX, int lbY)
=> EncodeLandblockId(lbX, lbY);
/// <summary>
/// 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 <see cref="MarkResidentFromBootstrap"/> (or a prior
/// call to this method) to have seeded <c>_tierResidence</c>.
/// </summary>
public TwoTierDiff RecenterTo(int newCx, int newCy)
{
int nearUnloadThreshold = NearRadius + 2;
int farUnloadThreshold = FarRadius + 2;
var toLoadFar = new List<uint>();
var toLoadNear = new List<uint>();
var toPromote = new List<uint>();
var toDemote = new List<uint>();
var toUnload = new List<uint>();
// Pass 1: walk new far window — emit ToLoadFar / ToLoadNear / ToPromote.
var newCenterIds = new HashSet<uint>();
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 = System.Math.Abs(dx);
int absDy = System.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 = System.Math.Abs(lbX - newCx);
int absDy = System.Math.Abs(lbY - newCy);
int distance = System.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);
}
/// <summary>
/// Recompute the visible window around a new center and return the
/// delta vs. the previous state. Hysteresis: landblocks aren't unloaded
@ -166,7 +306,7 @@ public sealed class StreamingRegion
}
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/>: the landblocks to
/// Output of <see cref="StreamingRegion.RecenterToSingleTier"/>: the landblocks to
/// start loading (newly entered the visible window) and the landblocks to
/// unload (fell outside the unload threshold, which is <c>Radius + 2</c>).
/// Both lists are disjoint from the current <see cref="StreamingRegion.Visible"/>
@ -175,3 +315,8 @@ public sealed class StreamingRegion
public readonly record struct RegionDiff(
IReadOnlyList<uint> ToLoad,
IReadOnlyList<uint> ToUnload);
/// <summary>
/// Tracks which tier a landblock currently occupies in the two-tier streaming model.
/// </summary>
internal enum TierResidence { None, Far, Near }

View file

@ -34,4 +34,23 @@ public class StreamingRegionTwoTierTests
Assert.Empty(diff.ToDemote);
Assert.Empty(diff.ToUnload);
}
[Fact]
public void RecenterTo_PlayerWalks_NullToFar_AppearsInToLoadFar()
{
var region = new StreamingRegion(centerX: 100, centerY: 100, nearRadius: 1, farRadius: 3);
_ = region.ComputeFirstTickDiff();
region.MarkResidentFromBootstrap();
// Walk one LB east — center (100,100) → (101,100). LB column at lbX=104
// (relative dx=+3 from new center) enters the far window from null.
var diff = region.RecenterTo(newCx: 101, newCy: 100);
foreach (var y in new[] { 97, 98, 99, 100, 101, 102, 103 })
{
var id = StreamingRegion.EncodeLandblockIdForTest(104, y);
Assert.Contains(id, diff.ToLoadFar);
}
Assert.Empty(diff.ToLoadNear);
}
}