using System.Collections.Concurrent;
using AcDream.App.Streaming;
using AcDream.Core.World;
using DatReaderWriter.DBObjs;
namespace AcDream.App.Tests.Streaming;
///
/// #418 build-worker-pool contract tests. The pool stripes jobs across
/// per-worker lanes by landblock id, so the contract is: per-landblock
/// enqueue order is execution (and completion-arrival) order, ClearLoads
/// supersedes every load queued before it on every lane, Near-tier jobs run
/// before Far-tier ones within a lane, a pool of one reproduces the serial
/// pre-#418 behavior, and disposal joins every worker.
///
public sealed class LandblockStreamerPoolTests
{
private const int SpinTimeoutMs = 10_000;
private const int SpinStepMs = 10;
private static AcDream.Core.Terrain.LandblockMeshData StubMesh() =>
new(
Array.Empty(),
Array.Empty());
private static LoadedLandblock StubLandblock(uint id) =>
new(id, new LandBlock(), Array.Empty());
///
/// Produce a landblock id (retail 0xXXYYFFFF shape) owned by
/// , distinct from every id in
/// .
///
private static uint IdInLane(LandblockStreamer streamer, int lane, HashSet taken)
{
for (uint x = 0; x < 256; x++)
{
for (uint y = 0; y < 256; y++)
{
uint id = (x << 24) | (y << 16) | 0xFFFFu;
if (streamer.LaneFor(id) == lane && taken.Add(id))
return id;
}
}
throw new InvalidOperationException($"No landblock id maps to lane {lane}.");
}
private static async Task> DrainCountAsync(
LandblockStreamer streamer,
int count,
int timeoutMs = SpinTimeoutMs)
{
var results = new List(count);
for (int i = 0; i < timeoutMs / SpinStepMs && results.Count < count; i++)
{
results.AddRange(streamer.DrainCompletions(count - results.Count));
if (results.Count < count)
await Task.Delay(SpinStepMs);
}
Assert.Equal(count, results.Count);
return results;
}
[Fact]
public void WorkerCount_DefaultsBoundedFloorsAtOneAndRejectsZero()
{
Assert.InRange(LandblockStreamer.DefaultWorkerCount, 1, 8);
using var defaulted = new LandblockStreamer(loadLandblock: _ => null);
Assert.Equal(LandblockStreamer.DefaultWorkerCount, defaulted.WorkerCount);
using var explicitThree = new LandblockStreamer(
loadLandblock: _ => null,
buildMeshOrNull: null,
workerCount: 3);
Assert.Equal(3, explicitThree.WorkerCount);
Assert.Throws(() => new LandblockStreamer(
loadLandblock: _ => null,
buildMeshOrNull: null,
workerCount: 0));
}
[Fact]
public void LaneAssignment_SpreadsRealWindowIdsAcrossLanes()
{
// Landblock ids carry their identity in the high word (0xXXYYFFFF);
// a bare modulo of the raw id would put EVERY id in one lane. Guard
// the mixed hash: a realistic 25x25 login window must engage more
// than one lane of an 8-lane pool.
using var streamer = new LandblockStreamer(
loadLandblock: _ => null,
buildMeshOrNull: null,
workerCount: 8);
var lanes = new HashSet();
for (uint x = 0xA0; x < 0xA0 + 25; x++)
for (uint y = 0xB0; y < 0xB0 + 25; y++)
lanes.Add(streamer.LaneFor((x << 24) | (y << 16) | 0xFFFFu));
Assert.True(
lanes.Count > 1,
$"25x25 window ids collapsed into {lanes.Count} lane(s).");
}
[Fact]
public async Task PerLandblockJobs_ExecuteInEnqueueOrder_UnderPoolContention()
{
const int idCount = 12;
const int jobsPerId = 8; // alternating LoadFar / Unload
int loaderCalls = 0;
using var streamer = new LandblockStreamer(
loadLandblock: (uint id, LandblockStreamJobKind _) =>
{
// Shake worker scheduling so a per-landblock ordering bug
// would actually interleave.
if (Interlocked.Increment(ref loaderCalls) % 3 == 0)
Thread.Sleep(1);
return StubLandblock(id);
},
buildMeshOrNull: (_, _) => StubMesh(),
workerCount: 4);
streamer.Start();
var ids = new uint[idCount];
for (uint i = 0; i < idCount; i++)
ids[i] = ((0x30u + i) << 24) | ((0x40u + i) << 16) | 0xFFFFu;
// Round-robin across ids while the pool is already running, so jobs
// for the same id repeatedly queue behind and race with other lanes.
ulong generation = 0;
for (int job = 0; job < jobsPerId; job++)
{
foreach (uint id in ids)
{
generation++;
if (job % 2 == 0)
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar, generation);
else
streamer.EnqueueUnload(id, generation);
}
}
List results =
await DrainCountAsync(streamer, idCount * jobsPerId);
foreach (uint id in ids)
{
var perId = results.Where(result => result.LandblockId == id).ToList();
Assert.Equal(jobsPerId, perId.Count);
// Completion arrival order per landblock id must be the enqueue
// order: alternating Loaded/Unloaded with strictly increasing
// generations.
for (int i = 0; i < perId.Count; i++)
{
if (i % 2 == 0)
Assert.IsType(perId[i]);
else
Assert.IsType(perId[i]);
if (i > 0)
{
Assert.True(
perId[i].Generation > perId[i - 1].Generation,
$"LB 0x{id:X8}: result {i} (gen {perId[i].Generation}) arrived " +
$"after gen {perId[i - 1].Generation} out of enqueue order.");
}
}
}
}
[Fact]
public async Task ClearPendingLoads_DropsQueuedLoadsAcrossAllLanes()
{
const int workerCount = 4;
using var release = new ManualResetEventSlim();
using var entered = new CountdownEvent(workerCount);
var loadedIds = new ConcurrentBag();
var blockerIds = new HashSet();
using var streamer = new LandblockStreamer(
loadLandblock: (uint id, LandblockStreamJobKind _) =>
{
loadedIds.Add(id);
bool isBlocker;
lock (blockerIds)
isBlocker = blockerIds.Contains(id);
if (isBlocker)
{
entered.Signal();
release.Wait();
}
return StubLandblock(id);
},
buildMeshOrNull: (_, _) => StubMesh(),
workerCount: workerCount);
var taken = new HashSet();
var victimIds = new List();
var unloadIds = new List();
var survivorIds = new List();
lock (blockerIds)
{
for (int lane = 0; lane < workerCount; lane++)
{
blockerIds.Add(IdInLane(streamer, lane, taken));
victimIds.Add(IdInLane(streamer, lane, taken));
victimIds.Add(IdInLane(streamer, lane, taken));
unloadIds.Add(IdInLane(streamer, lane, taken));
survivorIds.Add(IdInLane(streamer, lane, taken));
}
}
streamer.Start();
lock (blockerIds)
{
foreach (uint id in blockerIds)
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
}
// Every worker is now inside its blocker build; everything below
// queues behind them, one lane each.
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)));
foreach (uint id in victimIds)
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
foreach (uint id in unloadIds)
streamer.EnqueueUnload(id);
// Must supersede every load queued above on EVERY lane, while the
// queued unloads survive.
streamer.ClearPendingLoads();
foreach (uint id in survivorIds)
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
release.Set();
// Expected completions: 4 blocker Loaded + 4 Unloaded + 4 survivor
// Loaded. The victims produce nothing.
List results =
await DrainCountAsync(streamer, workerCount * 3);
var loadedResultIds = results
.OfType()
.Select(result => result.LandblockId)
.ToHashSet();
var unloadedResultIds = results
.OfType()
.Select(result => result.LandblockId)
.ToHashSet();
lock (blockerIds)
{
foreach (uint id in blockerIds)
Assert.Contains(id, loadedResultIds);
}
foreach (uint id in survivorIds)
Assert.Contains(id, loadedResultIds);
foreach (uint id in unloadIds)
Assert.Contains(id, unloadedResultIds);
foreach (uint id in victimIds)
{
Assert.DoesNotContain(id, loadedResultIds);
Assert.DoesNotContain(id, loadedIds);
}
}
[Fact]
public async Task NearTierJobs_RunBeforeQueuedFarJobs_WithinEachLane()
{
const int workerCount = 3;
using var release = new ManualResetEventSlim();
using var entered = new CountdownEvent(workerCount);
var executionOrder = new ConcurrentQueue();
var blockerIds = new HashSet();
using var streamer = new LandblockStreamer(
loadLandblock: (uint id, LandblockStreamJobKind _) =>
{
bool isBlocker;
lock (blockerIds)
isBlocker = blockerIds.Contains(id);
if (isBlocker)
{
entered.Signal();
release.Wait();
}
else
{
executionOrder.Enqueue(id);
}
return StubLandblock(id);
},
buildMeshOrNull: (_, _) => StubMesh(),
workerCount: workerCount);
var taken = new HashSet();
var farIds = new uint[workerCount];
var nearIds = new uint[workerCount];
lock (blockerIds)
{
for (int lane = 0; lane < workerCount; lane++)
{
blockerIds.Add(IdInLane(streamer, lane, taken));
farIds[lane] = IdInLane(streamer, lane, taken);
nearIds[lane] = IdInLane(streamer, lane, taken);
}
}
streamer.Start();
lock (blockerIds)
{
foreach (uint id in blockerIds)
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
}
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)));
// Far first, near second — the near job must still run first once
// the lane's blocker completes.
for (int lane = 0; lane < workerCount; lane++)
{
streamer.EnqueueLoad(farIds[lane], LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(nearIds[lane], LandblockStreamJobKind.LoadNear);
}
release.Set();
await DrainCountAsync(streamer, workerCount * 3);
var observed = executionOrder.ToList();
for (int lane = 0; lane < workerCount; lane++)
{
int nearIndex = observed.IndexOf(nearIds[lane]);
int farIndex = observed.IndexOf(farIds[lane]);
Assert.True(nearIndex >= 0 && farIndex >= 0);
Assert.True(
nearIndex < farIndex,
$"lane {lane}: near 0x{nearIds[lane]:X8} (index {nearIndex}) ran " +
$"after far 0x{farIds[lane]:X8} (index {farIndex}).");
}
}
[Fact]
public async Task SingleWorkerPool_ReproducesSerialGlobalOrdering()
{
// The degenerate pool of one must be today's (pre-#418) behavior
// exactly: one worker thread, global near-first execution order
// across DIFFERENT landblock ids.
var callOrder = new List();
var loaderThreads = new HashSet();
using var streamer = new LandblockStreamer(
loadLandblock: (uint id, LandblockStreamJobKind _) =>
{
callOrder.Add(id);
loaderThreads.Add(System.Environment.CurrentManagedThreadId);
return StubLandblock(id);
},
buildMeshOrNull: (_, _) => StubMesh(),
workerCount: 1);
Assert.Equal(1, streamer.WorkerCount);
streamer.EnqueueLoad(0xAAAAFFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xBBBBFFFFu, LandblockStreamJobKind.LoadFar);
streamer.EnqueueLoad(0xCCCCFFFFu, LandblockStreamJobKind.LoadNear);
streamer.Start();
List results = await DrainCountAsync(streamer, 3);
Assert.Equal(
new[] { 0xCCCCFFFFu, 0xAAAAFFFFu, 0xBBBBFFFFu },
callOrder);
Assert.Single(loaderThreads);
var first = Assert.IsType(results[0]);
Assert.Equal(0xCCCCFFFFu, first.LandblockId);
}
[Fact]
public async Task Dispose_JoinsEveryWorkerInThePool()
{
const int workerCount = 3;
using var release = new ManualResetEventSlim();
using var entered = new CountdownEvent(workerCount);
var loaderThreads = new ConcurrentDictionary();
var blockerIds = new HashSet();
var streamer = new LandblockStreamer(
loadLandblock: (uint id, LandblockStreamJobKind _) =>
{
loaderThreads.TryAdd(System.Environment.CurrentManagedThreadId, 0);
entered.Signal();
release.Wait();
return StubLandblock(id);
},
buildMeshOrNull: (_, _) => StubMesh(),
workerCount: workerCount);
try
{
var taken = new HashSet();
for (int lane = 0; lane < workerCount; lane++)
blockerIds.Add(IdInLane(streamer, lane, taken));
streamer.Start();
foreach (uint id in blockerIds)
streamer.EnqueueLoad(id, LandblockStreamJobKind.LoadFar);
Assert.True(entered.Wait(TimeSpan.FromSeconds(5)));
Assert.Equal(workerCount, loaderThreads.Count);
Task dispose = Task.Run(streamer.Dispose);
await Task.Delay(100);
// Dispose must be blocked on the still-building workers.
Assert.False(dispose.IsCompleted);
release.Set();
await dispose.WaitAsync(TimeSpan.FromSeconds(5));
Assert.Throws(
() => streamer.EnqueueLoad(0x1234FFFFu, LandblockStreamJobKind.LoadFar));
Assert.Throws(
() => streamer.EnqueueUnload(0x1234FFFFu));
Assert.Throws(streamer.ClearPendingLoads);
}
finally
{
release.Set();
streamer.Dispose();
}
}
}