From fb6b61e8ef689478528a24e763d703eab42d424a Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 9 May 2026 22:36:20 +0200 Subject: [PATCH] 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) --- src/AcDream.App/Streaming/StreamingRegion.cs | 147 +++++++++++++++++- .../Streaming/StreamingRegionTwoTierTests.cs | 19 +++ 2 files changed, 165 insertions(+), 1 deletion(-) diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs index 7d0fc57..b4c1056 100644 --- a/src/AcDream.App/Streaming/StreamingRegion.cs +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -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 _resident = new(); + // Two-tier residence tracking: maps each resident LB to its current tier. + private readonly Dictionary _tierResidence = new(); + /// /// Landblock IDs in the current visible window in the AC 8.8 coordinate /// form: (lbX << 24) | (lbY << 16) | 0xFFFF. The trailing @@ -121,6 +125,142 @@ public sealed class StreamingRegion 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() + { + _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; + } + } + } + + /// + /// 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) + { + 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 = 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); + } + /// /// 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 } /// -/// Output of : the landblocks to +/// 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 @@ -175,3 +315,8 @@ public sealed class StreamingRegion public readonly record struct RegionDiff( IReadOnlyList ToLoad, IReadOnlyList ToUnload); + +/// +/// Tracks which tier a landblock currently occupies in the two-tier streaming model. +/// +internal enum TierResidence { None, Far, Near } diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs index 105b224..0d6f5b0 100644 --- a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTwoTierTests.cs @@ -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); + } }