acdream/tests/AcDream.Core.Tests/Streaming/StreamingControllerDungeonGateTests.cs
Erik 2561918a70 fix(G.3): pin dungeon collapse to the cell's landblock, not the position-derived one (#133)
"The dungeon is broken" — the collapse was unloading the REAL dungeon. A dungeon's
EnvCells sit at arbitrary "ocean" world coords with negative cell-local Y (snap
showed pos=(58.9,-69.6) in cell 0x00070133), so the observer landblock
_liveCenterY + floor(pp.Y/192) = 7 + floor(-69.6/192) = 7 + (-1) = 6 lands one row
off. The collapse pinned to 0x0006 and unloaded 0x0007 — the real dungeon — which
nulled CurrCell (the cell no longer existed) and left the player floating in
outdoor-lit empty space (lb 1/1 @ ~1585 fps, but the wrong landblock). This is the
Bug-A negative-local-coordinate class.

Fix: when inside a dungeon, pin the collapse to the cell's OWN landblock
(CurrCell.Id >> 16), never the position-derived observer landblock — the cell id is
the authoritative landblock for ocean-placed dungeon geometry.

Also hardened the hysteresis so a transient CurrCell flicker can't thrash:
- Re-collapse when insideDungeon at a DIFFERENT landblock (multi-landblock dungeon).
- Expand only on a DISTANT move (Chebyshev > 1) — a real exit teleports far from the
  ocean-grid block; the off-by-one flicker is always an ADJACENT (±1) landblock, so
  it now HOLDS the collapse instead of expanding.
- SweepCollapsed always preserves _collapsedCenter (the true dungeon landblock),
  never the per-frame observer landblock.

Build green; 59 streaming tests green (flicker regression test updated to the
realistic adjacent off-by-one).

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

163 lines
6.3 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_CurrCellFlickersToAdjacentOffByOne_DoesNotExpand()
{
// Regression: the live run broke because a dungeon cell's negative local-Y
// makes the position-derived observer landblock land one row off (0,7→0,6).
// When CurrCell flickers null mid-frame, GameWindow stops overriding to the
// cell landblock and passes that adjacent (0,6). The Chebyshev>1 guard must
// treat that as a flicker and HOLD — never expand (which would unload the
// real dungeon and re-stream the 25×25 neighbor window).
var h = Make();
h.State.AddLandblock(MakeLb(0, 7));
h.Ctrl.Tick(0, 7, insideDungeon: true); // collapse onto the dungeon (0,7)
h.Loads.Clear();
h.Unloads.Clear();
h.Ctrl.Tick(0, 6, insideDungeon: false); // flicker → adjacent off-by-one
Assert.Empty(h.Loads); // NO full-window reload
Assert.Empty(h.Unloads); // dungeon (0,7) preserved; nothing else resident
}
[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);
}
}