feat(app): Phase A.1 — StreamingRegion (window set + diff with hysteresis)

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-11 22:01:45 +02:00
parent fcfe8f1ce0
commit 11df7930fc
3 changed files with 191 additions and 0 deletions

View file

@ -0,0 +1,105 @@
using System.Collections.Generic;
namespace AcDream.App.Streaming;
/// <summary>
/// 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.
/// </summary>
public sealed class StreamingRegion
{
public int CenterX { get; private set; }
public int CenterY { get; private set; }
public int Radius { get; }
private readonly HashSet<uint> _visible = new();
/// <summary>
/// Landblock IDs (8.8 coordinate form: <c>(lbX &lt;&lt; 24) | (lbY &lt;&lt; 16) | 0xFFFE</c>)
/// in the current visible window.
/// </summary>
public IReadOnlyCollection<uint> 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;
/// <summary>
/// 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 <c>Radius + 1</c> from the new center,
/// so boundary crossings don't thrash.
/// </summary>
public RegionDiff RecenterTo(int newCx, int newCy)
{
// Snapshot the current visible set so we can diff against it.
var oldVisible = new HashSet<uint>(_visible);
Recenter(newCx, newCy);
// Loads = everything newly in visible but not previously.
var toLoad = new List<uint>();
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<uint>();
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);
}
}
/// <summary>
/// Output of <see cref="StreamingRegion.RecenterTo"/>: the landblocks to
/// start loading (newly entered the visible window) and the landblocks to
/// unload (fell outside the unload threshold, which is <c>radius + 1</c>).
/// Both lists are disjoint from the current <see cref="StreamingRegion.Visible"/>
/// set; the caller hands them to <c>LandblockStreamer</c> as jobs.
/// </summary>
public readonly record struct RegionDiff(
IReadOnlyList<uint> ToLoad,
IReadOnlyList<uint> ToUnload);

View file

@ -20,6 +20,7 @@
<ItemGroup>
<ProjectReference Include="..\..\src\AcDream.Core\AcDream.Core.csproj" />
<ProjectReference Include="..\..\src\AcDream.App\AcDream.App.csproj" />
</ItemGroup>
<ItemGroup>

View file

@ -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);
}
}