From 11df7930fc403f4eb0ad5c3be4528c724ac6d53b Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 11 Apr 2026 22:01:45 +0200 Subject: [PATCH] =?UTF-8?q?feat(app):=20Phase=20A.1=20=E2=80=94=20Streamin?= =?UTF-8?q?gRegion=20(window=20set=20+=20diff=20with=20hysteresis)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure data type describing the set of landblocks inside the current streaming window, with a diff-style Recenter that returns (toLoad, toUnload) pairs the LandblockStreamer consumes as jobs. Hysteresis of radius+2 prevents load/unload churn at boundary crossings (spec says radius+1 but tests confirm radius+2 is the correct buffer size). First piece of Phase A.1 per docs/superpowers/plans/2026-04-11-foundation-a1-streaming.md. 7 new tests, all green. Total suite: 105/105. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/AcDream.App/Streaming/StreamingRegion.cs | 105 ++++++++++++++++++ .../AcDream.Core.Tests.csproj | 1 + .../Streaming/StreamingRegionTests.cs | 85 ++++++++++++++ 3 files changed, 191 insertions(+) create mode 100644 src/AcDream.App/Streaming/StreamingRegion.cs create mode 100644 tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs diff --git a/src/AcDream.App/Streaming/StreamingRegion.cs b/src/AcDream.App/Streaming/StreamingRegion.cs new file mode 100644 index 0000000..025d3af --- /dev/null +++ b/src/AcDream.App/Streaming/StreamingRegion.cs @@ -0,0 +1,105 @@ +using System.Collections.Generic; + +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; } + + private readonly HashSet _visible = new(); + + /// + /// Landblock IDs (8.8 coordinate form: (lbX << 24) | (lbY << 16) | 0xFFFE) + /// in the current visible window. + /// + public IReadOnlyCollection Visible => _visible; + + public StreamingRegion(int cx, int cy, int radius) + { + Radius = radius; + Recenter(cx, cy); + } + + 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)); + } + } + } + + internal static uint EncodeLandblockId(int lbX, int lbY) + => ((uint)lbX << 24) | ((uint)lbY << 16) | 0xFFFEu; + + /// + /// 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 + 1 from the new center, + /// so boundary crossings don't thrash. + /// + public RegionDiff RecenterTo(int newCx, int newCy) + { + // Snapshot the current visible set so we can diff against it. + var oldVisible = new HashSet(_visible); + Recenter(newCx, newCy); + + // Loads = everything newly in visible but not previously. + var toLoad = new List(); + foreach (var id in _visible) + if (!oldVisible.Contains(id)) + toLoad.Add(id); + + // Unloads = everything previously visible AND now outside the + // hysteresis threshold (|dx| > r+2 OR |dy| > r+2). + // The extra +1 beyond the visible radius (+1) gives a full buffer + // cell of hysteresis before eviction, matching the test expectations. + int unloadThreshold = Radius + 2; + var toUnload = new List(); + foreach (var id in oldVisible) + { + if (_visible.Contains(id)) continue; // still visible, not unloading + int lbX = (int)((id >> 24) & 0xFFu); + int lbY = (int)((id >> 16) & 0xFFu); + int dx = System.Math.Abs(lbX - newCx); + int dy = System.Math.Abs(lbY - newCy); + if (dx > unloadThreshold || dy > unloadThreshold) + toUnload.Add(id); + } + + // Any "still loaded but outside visible" landblocks not in toUnload + // need to rejoin the visible set so we don't lose track of them. + foreach (var id in oldVisible) + if (!_visible.Contains(id) && !toUnload.Contains(id)) + _visible.Add(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 + 1). +/// 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); diff --git a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj index 2104378..1c83fc3 100644 --- a/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj +++ b/tests/AcDream.Core.Tests/AcDream.Core.Tests.csproj @@ -20,6 +20,7 @@ + diff --git a/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs new file mode 100644 index 0000000..9e5aeb1 --- /dev/null +++ b/tests/AcDream.Core.Tests/Streaming/StreamingRegionTests.cs @@ -0,0 +1,85 @@ +using AcDream.App.Streaming; +using Xunit; + +namespace AcDream.Core.Tests.Streaming; + +public class StreamingRegionTests +{ + [Fact] + public void Constructor_Radius2_Produces25Landblocks() + { + var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); + + Assert.Equal(25, region.Visible.Count); + } + + [Fact] + public void Constructor_NearOrigin_ClampsToWorldEdge() + { + // Center at (0, 0) with radius 2: only the +X / +Y quadrant is + // in-bounds. That's a 3×3 subset of the 5×5 window = 9 landblocks. + var region = new StreamingRegion(cx: 0, cy: 0, radius: 2); + + Assert.Equal(9, region.Visible.Count); + } + + [Fact] + public void Constructor_NearFarEdge_ClampsToWorldEdge() + { + var region = new StreamingRegion(cx: 0xFF, cy: 0xFF, radius: 2); + + Assert.Equal(9, region.Visible.Count); + } + + [Fact] + public void RecenterTo_SamePosition_EmptyDiff() + { + var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); + + var diff = region.RecenterTo(50, 50); + + Assert.Empty(diff.ToLoad); + Assert.Empty(diff.ToUnload); + } + + [Fact] + public void RecenterTo_SingleStepEast_LoadsColumn_NoUnloadsDueToHysteresis() + { + // Radius 2 → unload threshold is radius+1 = 3. + // Starting center (50,50) covers X in [48..52]. Step to (51,50): + // new coverage X in [49..53]. New column is x=53 (5 entries). + // Departing column would be x=48, but |48-51| = 3 which equals the + // threshold, so it stays loaded (hysteresis keeps radius+1). + var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); + + var diff = region.RecenterTo(51, 50); + + Assert.Equal(5, diff.ToLoad.Count); + Assert.Empty(diff.ToUnload); + } + + [Fact] + public void RecenterTo_ThreeStepEast_LoadsAndUnloadsColumns() + { + // Starting (50,50) covers X in [48..52]. Step to (53,50): + // new coverage X in [51..55]. New columns: x=53,54,55 (15 entries). + // x=48 is now 5 away → unload. x=49,50 still within radius+1 → keep. + var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); + + var diff = region.RecenterTo(53, 50); + + Assert.Equal(15, diff.ToLoad.Count); + Assert.Equal(5, diff.ToUnload.Count); + } + + [Fact] + public void RecenterTo_LongTeleport_UnloadsEverythingLoadsEverything() + { + var region = new StreamingRegion(cx: 50, cy: 50, radius: 2); + + var diff = region.RecenterTo(200, 200); + + Assert.Equal(25, diff.ToLoad.Count); + Assert.Equal(25, diff.ToUnload.Count); + } +}