acdream/tests/AcDream.App.Tests/World/EntityVisibilityCullerTests.cs
Erik e5b2d15b63 fix(world): AP-48 client-side visibility cull (FPS sink across portal-hops)
acdream accumulated every CreateObject from every town visited and never pruned by
distance/time (only on server DeleteObject / respawn de-dup), so the entity tables +
the O(N^2) TickAnimations scan grew with each hop and sank FPS (confirmed in Release).

Faithful port of holtburger liveness.rs (ACE_DESTRUCTION_TIMEOUT_SECS=25,
CONSERVATIVE_VISIBILITY_DISTANCE_M=384): a world entity is evicted only after being
>384m AND outside the 3x3 landblock neighborhood for 25s continuous (arm-on-leave /
clear-on-return). Logic in a pure, unit-tested EntityVisibilityCuller; GameWindow
wires a 1Hz tick that snapshots the world entities + player and tears each evicted
guid down through the existing pruner. Player + held/equipped/contained items are
excluded (player by guid; inventory items never carry a world position so they never
enter the culled map). A re-created object starts fresh (deadline cleared on remove).
Skipped during a teleport hold (frozen player position). AD-32 registered.

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

108 lines
3.8 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.Numerics;
using AcDream.App.World;
using Xunit;
namespace AcDream.App.Tests.World;
/// <summary>
/// AP-48 cull (holtburger liveness.rs port): visible = 3×3 landblock neighborhood OR ≤384 m;
/// evict only after 25 s continuously out-of-visibility (arm-on-leave / clear-on-return).
/// </summary>
public sealed class EntityVisibilityCullerTests
{
private static readonly EntityCullInfo Player =
new(0x50000001u, new Vector3(0, 0, 0), 169, 180, true);
// ---- IsVisible ----
[Fact]
public void Within384m_isVisible_byDistance_evenInAFarLandblock()
=> Assert.True(EntityVisibilityCuller.IsVisible(
new Vector3(50, 0, 0), 100, 100, true, new Vector3(0, 0, 0), 169, 180));
[Fact]
public void AdjacentLandblock_isVisible_evenWhenFarByPosition()
=> Assert.True(EntityVisibilityCuller.IsVisible(
new Vector3(5000, 0, 0), 169, 181, true, new Vector3(0, 0, 0), 169, 180));
[Fact]
public void FarAndNotAdjacent_isNotVisible()
=> Assert.False(EntityVisibilityCuller.IsVisible(
new Vector3(5000, 0, 0), 100, 100, true, new Vector3(0, 0, 0), 169, 180));
// ---- Step lifecycle ----
[Fact]
public void Step_visible_clearsDeadlineAndKeeps()
{
var (dl, evict) = EntityVisibilityCuller.Step(visible: true, deadline: 123.0, now: 200.0);
Assert.Null(dl);
Assert.False(evict);
}
[Fact]
public void Step_notVisible_unarmed_arms()
{
var (dl, evict) = EntityVisibilityCuller.Step(visible: false, deadline: null, now: 100.0);
Assert.Equal(100.0 + EntityVisibilityCuller.DestructionTimeoutSec, dl);
Assert.False(evict);
}
[Fact]
public void Step_notVisible_armedNotExpired_keeps()
{
var (dl, evict) = EntityVisibilityCuller.Step(visible: false, deadline: 125.0, now: 110.0);
Assert.Equal(125.0, dl);
Assert.False(evict);
}
[Fact]
public void Step_notVisible_armedExpired_evicts()
{
var (dl, evict) = EntityVisibilityCuller.Step(visible: false, deadline: 125.0, now: 125.0);
Assert.Null(dl);
Assert.True(evict);
}
// ---- Tick integration ----
[Fact]
public void Tick_neverEvictsTheLocalPlayer()
{
var c = new EntityVisibilityCuller();
var ents = new[] { Player };
c.Tick(ents, Player, now: 0);
c.Tick(ents, Player, now: 1000);
Assert.Empty(c.Tick(ents, Player, now: 1_000_000));
Assert.Equal(0, c.ArmedCount);
}
[Fact]
public void Tick_armsThenEvicts_aFarStaleEntity_afterTimeout()
{
var c = new EntityVisibilityCuller();
var far = new EntityCullInfo(0xABCDu, new Vector3(5000, 0, 0), 10, 10, true);
var ents = new[] { far };
Assert.Empty(c.Tick(ents, Player, now: 0)); // arm at 0 + 25
Assert.Equal(1, c.ArmedCount);
Assert.Empty(c.Tick(ents, Player, now: 24)); // not expired
var evicted = c.Tick(ents, Player, now: 25); // expired → evict
Assert.Equal(new[] { 0xABCDu }, evicted);
Assert.Equal(0, c.ArmedCount); // deadline cleared on evict
}
[Fact]
public void Tick_clearsDeadline_whenEntityReturnsToVisibility_beforeTimeout()
{
var c = new EntityVisibilityCuller();
var far = new EntityCullInfo(0xABCDu, new Vector3(5000, 0, 0), 10, 10, true);
var near = far with { Pos = new Vector3(50, 0, 0) }; // within 384 m → visible
Assert.Empty(c.Tick(new[] { far }, Player, now: 0)); // arm
Assert.Equal(1, c.ArmedCount);
Assert.Empty(c.Tick(new[] { near }, Player, now: 10)); // returned → clear
Assert.Equal(0, c.ArmedCount);
Assert.Empty(c.Tick(new[] { near }, Player, now: 100)); // never evicted
}
}