Gate-2 fallout chain: session 1's bare-id wedge poisoned ACE's save (the client reported garbage cells while walking through walls), so session 2 logged in with (cell=0xA9B4013F inn interior, pos=(91.4,32.7)) — a position that cell does not contain. Probe evidence: exactly one [cell-transit] line all session; the player free-fell into an empty world. Two holes, both fixed at the root: 1. CellTransit pick escape hatch — restores the #83/A1.7 + #90 verification that lived in PhysicsEngine.ResolveCellId before the collide-then-pick rewrite moved membership into BuildCellSetAndPickContaining: an indoor current cell that IS hydrated but whose CellBSP no longer overlaps ANY part of the foot sphere is a bogus claim (corrupt save, or walked out through an unblocked gap). The portal BFS can never reach an exit portal from a cell the sphere isn't in, so no candidates exist and the claim held forever — wedging collision (ShadowObjectRegistry's #98 gate reads "indoor primary" -> outdoor object sweep skipped), wall BSP, terrain, and the render root. The pick now demotes to the outdoor column under the sphere centre (the LandDefs.AdjustToOutside result already computed for the pick — cross-block safe). Sphere-overlap (BSPQuery.SphereIntersectsCellBsp, pseudo_c:317666 -> :323267), NOT point-in: doorway push-back leaves the centre a few cm outside while the sphere still overlaps — no demotion, #90's ping-pong stays dead. An unhydrated cell cannot be verified — stale beats null while streaming hydrates (retail-equivalent: stale curr_cell kept when the pick finds nothing). 2. PlayerModeAutoEntry spawn-ground hold — player-mode entry now waits for the terrain under the spawn position to stream in (isSpawnGroundReady predicate, K.2 pattern). Entering earlier integrates gravity against an empty world: indoor-claimed spawns got no floor from any source and free-fell into the void; outdoor spawns raced hydration by ~1s every login. Retail never has this state (it loads cells synchronously) — the hold is the async-streaming equivalent of that invariant. With the hold, the entry snap (Resolve, stepUp=100) runs against hydrated cell floors + terrain and re-seats a corrupt save's claim immediately. Tests: IndoorSeed_SphereFullyOutsideHydratedCell_DemotesToOutdoorColumn (the gate-2 wedge shape, red pre-fix), straddle + no-BSP guards (the #90 hysteresis and stale-beats-null), TryEnter_Armed_SpawnGroundNotReady_ DoesNotFire. Full suite: 294+218+420 green; Core 1375 green + the same 4 pre-existing door/#99-era failures + 1 skip. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
186 lines
6.1 KiB
C#
186 lines
6.1 KiB
C#
using AcDream.App.Input;
|
|
|
|
namespace AcDream.Core.Tests.Input;
|
|
|
|
/// <summary>
|
|
/// Phase K.2 — guard logic for auto-entering player mode at login.
|
|
/// The trigger fires exactly once when:
|
|
/// <list type="number">
|
|
/// <item>It has been <see cref="PlayerModeAutoEntry.Arm"/>'d (login
|
|
/// succeeded).</item>
|
|
/// <item>The live session is in <c>InWorld</c>.</item>
|
|
/// <item>The player entity has been streamed into the local
|
|
/// dictionary.</item>
|
|
/// <item>The player movement controller is ready to attach.</item>
|
|
/// </list>
|
|
/// All four predicates are passed as <see cref="Func{bool}"/>; tests
|
|
/// flip plain boolean fields and assert against an entry-counter that
|
|
/// the entry callback bumps.
|
|
/// </summary>
|
|
public sealed class AutoEnterPlayerModeTests
|
|
{
|
|
private sealed class State
|
|
{
|
|
public bool LiveInWorld;
|
|
public bool PlayerEntityPresent;
|
|
public bool PlayerControllerReady;
|
|
// Defaults TRUE: the hydration hold is not under test in the
|
|
// original K.2 cases (see SpawnGroundNotReady test for it).
|
|
public bool SpawnGroundReady = true;
|
|
public int EnteredCount;
|
|
|
|
public PlayerModeAutoEntry Build() =>
|
|
new(
|
|
isLiveInWorld: () => LiveInWorld,
|
|
isPlayerEntityPresent: () => PlayerEntityPresent,
|
|
isPlayerControllerReady: () => PlayerControllerReady,
|
|
isSpawnGroundReady: () => SpawnGroundReady,
|
|
enterPlayerMode: () => EnteredCount++);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_Armed_SpawnGroundNotReady_DoesNotFire()
|
|
{
|
|
// #106 gate-2 (2026-06-09): player physics must not start before
|
|
// the terrain under the spawn position has streamed in — entering
|
|
// earlier free-falls the player into the void (gravity integrates
|
|
// against an empty world; retail never has this state because it
|
|
// loads cells synchronously). The guard holds until the streaming
|
|
// pipeline registers the spawn landblock.
|
|
var s = new State
|
|
{
|
|
LiveInWorld = true, PlayerEntityPresent = true,
|
|
PlayerControllerReady = true, SpawnGroundReady = false,
|
|
};
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(0, s.EnteredCount);
|
|
Assert.True(guard.IsArmed);
|
|
|
|
// Terrain hydrates → fires on the next tick.
|
|
s.SpawnGroundReady = true;
|
|
Assert.True(guard.TryEnter());
|
|
Assert.Equal(1, s.EnteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_NotArmed_DoesNotFire()
|
|
{
|
|
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
|
|
var guard = s.Build();
|
|
|
|
// Not armed → must NOT fire even though every precondition is true.
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(0, s.EnteredCount);
|
|
Assert.False(guard.IsArmed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_Armed_LiveNotInWorld_DoesNotFire()
|
|
{
|
|
var s = new State { LiveInWorld = false, PlayerEntityPresent = true, PlayerControllerReady = true };
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(0, s.EnteredCount);
|
|
Assert.True(guard.IsArmed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_Armed_PlayerEntityNotPresent_DoesNotFire()
|
|
{
|
|
var s = new State { LiveInWorld = true, PlayerEntityPresent = false, PlayerControllerReady = true };
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(0, s.EnteredCount);
|
|
Assert.True(guard.IsArmed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_Armed_PlayerControllerNotReady_DoesNotFire()
|
|
{
|
|
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = false };
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(0, s.EnteredCount);
|
|
Assert.True(guard.IsArmed);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_AllConditionsSatisfied_FiresExactlyOnce()
|
|
{
|
|
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
Assert.True(guard.TryEnter());
|
|
Assert.Equal(1, s.EnteredCount);
|
|
Assert.False(guard.IsArmed);
|
|
|
|
// Subsequent tick must not re-fire — one-shot semantics.
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(1, s.EnteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void TryEnter_FiresOnLaterTickWhenPreconditionsBecomeTrue()
|
|
{
|
|
var s = new State();
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
// Tick 1: only LiveInWorld true.
|
|
s.LiveInWorld = true;
|
|
Assert.False(guard.TryEnter());
|
|
|
|
// Tick 2: + PlayerEntityPresent.
|
|
s.PlayerEntityPresent = true;
|
|
Assert.False(guard.TryEnter());
|
|
|
|
// Tick 3: + PlayerControllerReady → fires.
|
|
s.PlayerControllerReady = true;
|
|
Assert.True(guard.TryEnter());
|
|
Assert.Equal(1, s.EnteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Cancel_BeforeFiring_SuppressesAutoEntry()
|
|
{
|
|
// Manual fly-mode toggle BEFORE the auto-entry fires must
|
|
// disarm the trigger; the user's choice wins.
|
|
var s = new State();
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
|
|
// User opts out before any precondition is true.
|
|
guard.Cancel();
|
|
Assert.False(guard.IsArmed);
|
|
|
|
// Even when every precondition flips true, the guard stays
|
|
// silent — the user's manual fly-mode choice wins.
|
|
s.LiveInWorld = true;
|
|
s.PlayerEntityPresent = true;
|
|
s.PlayerControllerReady = true;
|
|
Assert.False(guard.TryEnter());
|
|
Assert.Equal(0, s.EnteredCount);
|
|
}
|
|
|
|
[Fact]
|
|
public void Arm_WhileAlreadyArmed_IsIdempotent()
|
|
{
|
|
var s = new State { LiveInWorld = true, PlayerEntityPresent = true, PlayerControllerReady = true };
|
|
var guard = s.Build();
|
|
guard.Arm();
|
|
guard.Arm(); // second Arm() — no-op.
|
|
|
|
Assert.True(guard.TryEnter());
|
|
Assert.Equal(1, s.EnteredCount);
|
|
}
|
|
}
|