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:
parent
fcfe8f1ce0
commit
11df7930fc
3 changed files with 191 additions and 0 deletions
105
src/AcDream.App/Streaming/StreamingRegion.cs
Normal file
105
src/AcDream.App/Streaming/StreamingRegion.cs
Normal 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 << 24) | (lbY << 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);
|
||||
Loading…
Add table
Add a link
Reference in a new issue