using System;
using System.Numerics;
using AcDream.Core.Physics;
using Xunit;
namespace AcDream.Core.Tests.Physics;
///
/// #345 acceptance: a grounded mover walking at an angle into a TOO-STEEP
/// terrain face must GLIDE laterally along it — faster the more angled the
/// approach — while a perpendicular approach stops. The user's retail
/// observation ("it glides, faster the more angle") is the axiom; the live
/// cdb profile (594 edge_slide/cliff_slide lockstep per run, step_up=0)
/// and the byte-pin in
/// docs/research/2026-08-08-345-d0-branch-pin.md establish the
/// mechanism: retail's validate_walkable below-plane arm returns OK
/// (not Adjusted) when its guard fails on a grounded-OnWalkable mover
/// against a too-steep plane (0x0050d1b9 jumps past the push AND past the
/// var_1c = 3 at 0x0050d249, leaving the 0x0050d025 init of OK), so
/// the insert proceeds, the step-down phase fails on the steep landing,
/// and the edge-slide family produces the per-tick lateral glide.
///
///
/// Discriminating fixture (the live #345 topology): the flat and
/// steep triangles share ONE terrain cell's diagonal. Cell (3,3) of the
/// synthetic landblock splits SW→NE (FSplitNESW), so raising only its TL
/// post to 32 m leaves the below-diagonal triangle {BL,BR,TR} flat while
/// the above-diagonal triangle {BL,TR,TL} carries the whole rise: normal
/// (0.469, -0.469, 0.469-normalized) with N.z ≈ 0.469 — well below the
/// walkable threshold (~0.664). Because both triangles live in the SAME
/// cell, the primary-phase terrain sample validates the steep plane the
/// moment the check position crosses the diagonal — the exact spot the
/// pre-fix code dead-looped (Adjusted with no push, byte-identical
/// retries). A cell-BOUNDARY face does NOT reproduce that loop (the
/// cell-scoped primary sample skips a triangle outside the primary cell),
/// which is why the supplementary boundary test below is not the
/// discriminator.
///
///
public sealed class Issue345SteepSlopeGlideTests
{
private const float DxyPerTick = 0.23f; // the captured per-tick request
private const int Ticks = 30;
// Cell (3,3): x,y in [72,96]. The diagonal runs BL(72,72) → TR(96,96)
// (the line y = x). Start on the flat triangle ~0.42 m perpendicular
// from the diagonal, mid-cell, so every approach engages the steep
// face within a couple of ticks.
private const float StartX = 80.4f;
private const float StartY = 79.8f;
// In-cell face frame: the steep face's horizontal trace is the
// diagonal, direction (1,1)/√2; the into-face perpendicular (from the
// flat side toward the steep side) is (-1,1)/√2.
private static readonly Vector2 Lateral = new(0.70710678f, 0.70710678f);
private static readonly Vector2 IntoFace = new(-0.70710678f, 0.70710678f);
[Fact]
public void Angled45Approach_GlidesAlongTheDiagonal()
{
var (finalPos, stuckTicks) = RunApproach(angleFromPerpendicularDeg: 45f);
float lateral = LateralAdvance(finalPos);
Assert.True(lateral > 1.0f,
$"expected the lateral component to survive against the " +
$"too-steep face (the retail glide), got only {lateral:F3} m " +
$"along the face over {Ticks} ticks (final=" +
$"{finalPos.X:F3},{finalPos.Y:F3},{finalPos.Z:F3})");
// The glide must not secretly climb the steep face — and the new
// OK return is specifically the "no push-out" path, so the mover
// must not sink below the flat triangle (z=0) either.
Assert.True(finalPos.Z < 1.0f,
$"expected the mover to stay at the base of the too-steep " +
$"face, but Z climbed to {finalPos.Z:F3}");
Assert.True(finalPos.Z > -0.05f,
$"expected the mover to stay on the flat surface (z=0), but " +
$"it sank to Z={finalPos.Z:F3}");
// The pre-fix dead loop spent EVERY post-crossing tick stuck
// (resolve output identical to input against a nonzero request;
// 28 of 30 here). The fixed glide alternates: the arming tick
// absorbs the request while edge-slide sets the sliding normal,
// the next tick consumes it and moves (14 of 30 stuck). The
// alternation IS retail's own shape — confirmed by the #347
// round-2 cdb capture (345-glide-stacks.cdb.log: ~1.5 edge_slide
// per find_transitional_position during a live glide, the
// alternation's exact signature) — so this range pins it from
// both sides. Stuck ticks are counted from positions, not the
// (temporary) transit-fail probe, so the assertion survives the
// probe family's eventual strip; the lower bound keeps it from
// going vacuous if the fixture stops engaging the face at all.
Assert.InRange(stuckTicks, 1, Ticks / 2 + 2);
}
[Fact]
public void SteeperApproachAngle_YieldsMoreLateralAdvance()
{
// "Faster the more angle you run towards it" — ordering only, no
// feel constants.
var (pos30, _) = RunApproach(angleFromPerpendicularDeg: 30f);
var (pos60, _) = RunApproach(angleFromPerpendicularDeg: 60f);
float lat30 = LateralAdvance(pos30);
float lat60 = LateralAdvance(pos60);
Assert.True(lat60 > lat30,
$"expected the more-angled approach to glide farther " +
$"(lat60={lat60:F3} m vs lat30={lat30:F3} m)");
}
[Fact]
public void PerpendicularApproach_Stops()
{
// The user's paired retail observation: walking straight at the
// face stops — there is no lateral component to preserve.
var (finalPos, _) = RunApproach(angleFromPerpendicularDeg: 0f);
float lateral = MathF.Abs(LateralAdvance(finalPos));
Assert.True(lateral < 0.15f,
$"expected no lateral drift on a perpendicular approach, got " +
$"{lateral:F3} m");
float dx = finalPos.X - StartX;
float dy = finalPos.Y - StartY;
float xyTravel = MathF.Sqrt(dx * dx + dy * dy);
Assert.True(xyTravel < 1.2f,
$"expected the too-steep face to stop the perpendicular " +
$"approach at its base (~0.4 m away), got {xyTravel:F3} m of " +
$"travel");
Assert.True(finalPos.Z < 1.0f,
$"expected no climb on a perpendicular approach, got " +
$"Z={finalPos.Z:F3}");
Assert.True(finalPos.Z > -0.05f,
$"expected no sink on a perpendicular approach, got " +
$"Z={finalPos.Z:F3}");
}
///
/// Supplementary coverage, NOT the discriminator: a steep face rising
/// from a cell BOUNDARY (whole neighboring cell raised). The primary
/// terrain sample is cell-scoped, so this topology resolves through
/// the cross-cell path and glides both pre- and post-fix; it pins the
/// boundary behavior so the diagonal fix cannot regress it.
///
[Fact]
public void CellBoundaryFace_Angled45_AlsoGlides()
{
var engine = BuildBoundaryFaceEngine();
var body = NewGroundedBody();
var position = new Vector3(91f, 36f, 0f);
uint cell = TerrainSurface.ComputeOutdoorCellId(0xA9B4FFFFu, 91f, 36f);
float d = DxyPerTick * 0.70710678f;
for (int tick = 0; tick < 40; tick++)
{
var result = engine.ResolveWithTransition(
currentPos: position,
targetPos: new Vector3(position.X + d, position.Y + d, position.Z),
cellId: cell,
sphereRadius: 0.47f,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.50f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x5000000Au);
position = result.Position;
cell = result.CellId;
}
Assert.True(position.Y - 36f > 0.5f,
$"expected lateral advance along the boundary face, got " +
$"{position.Y - 36f:F3} m");
Assert.True(position.Z < 1.0f,
$"expected no climb up the boundary face, got Z={position.Z:F3}");
}
private static float LateralAdvance(Vector3 finalPos)
=> (finalPos.X - StartX) * Lateral.X + (finalPos.Y - StartY) * Lateral.Y;
private static (Vector3 FinalPos, int StuckTicks) RunApproach(
float angleFromPerpendicularDeg)
{
var engine = BuildDiagonalFaceEngine();
var body = NewGroundedBody();
float rad = angleFromPerpendicularDeg * MathF.PI / 180f;
Vector2 dir = MathF.Cos(rad) * IntoFace + MathF.Sin(rad) * Lateral;
float dx = DxyPerTick * dir.X;
float dy = DxyPerTick * dir.Y;
var position = new Vector3(StartX, StartY, 0f);
uint cell = TerrainSurface.ComputeOutdoorCellId(0xA9B4FFFFu, StartX, StartY);
int stuckTicks = 0;
for (int tick = 0; tick < Ticks; tick++)
{
var result = engine.ResolveWithTransition(
currentPos: position,
targetPos: new Vector3(position.X + dx, position.Y + dy, position.Z),
cellId: cell,
sphereRadius: 0.47f,
sphereHeight: 1.20f,
stepUpHeight: 0.60f,
stepDownHeight: 1.50f,
isOnGround: true,
body: body,
moverFlags: ObjectInfoState.IsPlayer | ObjectInfoState.EdgeSlide,
movingEntityId: 0x5000000Au);
// The stuck-tick predicate, from positions: nonzero XY request,
// zero XY delivered.
if (result.Position.X == position.X && result.Position.Y == position.Y)
stuckTicks++;
position = result.Position;
cell = result.CellId;
}
return (position, stuckTicks);
}
private static PhysicsBody NewGroundedBody() => new()
{
State = PhysicsStateFlags.Gravity,
TransientState = TransientStateFlags.Active | TransientStateFlags.Contact | TransientStateFlags.OnWalkable,
};
///
/// Only cell (3,3)'s TL post (x-index 3, y-index 4) is raised: its
/// below-diagonal triangle stays flat at z=0 and its above-diagonal
/// triangle carries the 32 m rise (N.z ≈ 0.469, too steep). x-major
/// heights[x*9+y]; heightTable[i] = i meters.
///
private static PhysicsEngine BuildDiagonalFaceEngine()
{
var heights = new byte[81];
heights[3 * 9 + 4] = 32;
return BuildEngine(heights);
}
///
/// Posts 0..4 flat at 0, posts 5..8 at 32 m: cell cx=4 (x in [96,120])
/// carries the rise as a whole-cell face on the x=96 boundary
/// (N = (-0.8, 0, 0.6)).
///
private static PhysicsEngine BuildBoundaryFaceEngine()
{
var heights = new byte[81];
for (int x = 5; x < 9; x++)
for (int y = 0; y < 9; y++)
heights[x * 9 + y] = 32;
return BuildEngine(heights);
}
private static PhysicsEngine BuildEngine(byte[] heights)
{
var heightTable = new float[256];
for (int i = 0; i < 256; i++) heightTable[i] = i;
var engine = new PhysicsEngine();
engine.AddLandblock(
0xA9B4FFFFu,
new TerrainSurface(heights, heightTable),
Array.Empty(),
Array.Empty(),
worldOffsetX: 0f,
worldOffsetY: 0f);
return engine;
}
}