acdream/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
Erik d9e7dd65e9 fix(G.3): hysteresis on the dungeon streaming gate — stop collapse↔expand thrash (#133)
The first cut of the dungeon gate keyed expand on the per-frame insideDungeon
signal (CurrCell is a sealed EnvCell). Live, CurrCell momentarily resolves to
null mid-frame while the player stays put in the dungeon landblock, so the gate
flipped collapse→expand→collapse every few frames. Each expand re-streamed the
full 25×25 window; the unloads couldn't keep up (MaxCompletionsPerFrame=4), so
registered lights leaked to 212k and FPS spiked to single digits between the
~199 fps collapsed frames.

Fix: once collapsed, key the gate on the STABLE observer landblock, not CurrCell.
Stay collapsed while the player remains in the dungeon landblock (_collapsedCenter);
expand only when the observer actually moves to a different landblock (portal/
teleport out). CurrCell flicker no longer thrashes.

Regression test added (Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand).
Build green; 60 streaming tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 22:43:18 +02:00

161 lines
6.1 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Collections.Generic;
using System.Linq;
using AcDream.App.Streaming;
using AcDream.Core.World;
using Xunit;
namespace AcDream.Core.Tests.Streaming;
/// <summary>
/// The dungeon streaming gate (#133 FPS). AC dungeons have no adjacent
/// landblocks (ACE <c>LandblockManager.GetAdjacentIDs</c> returns empty for a
/// dungeon); they sit packed in the ocean grid, so the normal 25×25 window
/// pulls in ~129 unrelated neighbor dungeons + their emitters. When the player
/// is inside a sealed dungeon cell, <c>Tick(insideDungeon: true)</c> collapses
/// streaming to the single dungeon landblock and unloads the neighbors.
/// </summary>
public class StreamingControllerDungeonGateTests
{
private static uint Encode(int x, int y) => ((uint)x << 24) | ((uint)y << 16) | 0xFFFFu;
private static LoadedLandblock MakeLb(int x, int y) => new LoadedLandblock(
Encode(x, y),
Heightmap: null!,
Entities: Array.Empty<WorldEntity>());
private sealed record Harness(
StreamingController Ctrl,
List<(uint Id, LandblockStreamJobKind Kind)> Loads,
List<uint> Unloads,
Func<int> ClearCalls,
GpuWorldState State);
private static Harness Make()
{
var loads = new List<(uint, LandblockStreamJobKind)>();
var unloads = new List<uint>();
int clearCalls = 0;
var state = new GpuWorldState();
var ctrl = new StreamingController(
enqueueLoad: (id, kind) => loads.Add((id, kind)),
enqueueUnload: unloads.Add,
drainCompletions: _ => Array.Empty<LandblockStreamResult>(),
applyTerrain: (_, _) => { },
state: state,
nearRadius: 4,
farRadius: 12,
clearPendingLoads: () => clearCalls++);
return new Harness(ctrl, loads, unloads, () => clearCalls, state);
}
[Fact]
public void EntersDungeon_CancelsPending_UnloadsNeighbors_KeepsCenter()
{
var h = Make();
uint center = Encode(0, 7);
h.State.AddLandblock(MakeLb(0, 7)); // the dungeon landblock
h.State.AddLandblock(MakeLb(0, 8)); // a neighbor ocean dungeon
h.State.AddLandblock(MakeLb(1, 7)); // another neighbor
h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
Assert.Equal(1, h.ClearCalls()); // in-flight window load cancelled
Assert.Contains(Encode(0, 8), h.Unloads); // neighbor unloaded
Assert.Contains(Encode(1, 7), h.Unloads); // neighbor unloaded
Assert.DoesNotContain(center, h.Unloads); // dungeon landblock kept
Assert.DoesNotContain(h.Loads, l => l.Id == center); // already loaded → no reload
}
[Fact]
public void EntersDungeon_CenterNotLoaded_EnqueuesCenterLoad()
{
var h = Make(); // empty state — the dungeon landblock isn't resident yet
h.Ctrl.Tick(observerCx: 0, observerCy: 7, insideDungeon: true);
Assert.Equal(1, h.ClearCalls());
Assert.Contains(h.Loads, l => l.Id == Encode(0, 7)
&& l.Kind == LandblockStreamJobKind.LoadNear);
}
[Fact]
public void StayingCollapsed_SweepsStragglerThatFinishedAfterTheEdge()
{
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse edge
h.Unloads.Clear();
// A Load the worker had already dequeued before ClearLoads now completes.
h.State.AddLandblock(MakeLb(0, 8));
h.Ctrl.Tick(0, 7, insideDungeon: true); // sweep
Assert.Contains(Encode(0, 8), h.Unloads);
Assert.DoesNotContain(Encode(0, 7), h.Unloads);
}
[Fact]
public void StayingCollapsed_DoesNotReClearOrReloadCenter()
{
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse (clear #1)
h.Loads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: true); // stay collapsed
Assert.Equal(1, h.ClearCalls()); // clear only fired on the edge
Assert.Empty(h.Loads); // no spurious center reloads
}
[Fact]
public void Collapsed_CurrCellFlickersToNull_SameLandblock_DoesNotExpand()
{
// Regression: the live run thrashed collapse↔expand because CurrCell
// momentarily resolved to null (insideDungeon=false) while the player
// stayed in the dungeon landblock — leaking lights via reload storms.
// The landblock-hysteresis must hold the collapse.
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 7, insideDungeon: false); // CurrCell flicker, same landblock
Assert.Empty(h.Loads); // NO full-window reload
Assert.Empty(h.Unloads); // only the center is resident → nothing to sweep
}
[Fact]
public void ExitsDungeon_RebuildsFullWindow_UnloadsStaleDungeonLandblock()
{
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse
h.Loads.Clear();
h.Unloads.Clear();
// Exit through a portal to an outdoor location far from the dungeon block.
h.Ctrl.Tick(observerCx: 100, observerCy: 100, insideDungeon: false);
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
Assert.Contains(Encode(0, 7), h.Unloads); // stale dungeon block, outside new window
}
[Fact]
public void NormalOutdoorTick_Unchanged_NoCollapseNoClear()
{
var h = Make();
h.Ctrl.Tick(observerCx: 100, observerCy: 100); // default insideDungeon: false
Assert.Equal(0, h.ClearCalls());
Assert.Empty(h.Unloads);
// 9 near (9×9? no — nearRadius 4 → 9×9=81) + far ring loads enqueued.
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadNear);
Assert.Contains(h.Loads, l => l.Kind == LandblockStreamJobKind.LoadFar);
}
}