test(phys): A6.P3 #98 — live ResolveWithTransition capture apparatus
Apparatus only — no fix attempt. Per the systematic-debugging skill's
"3+ failures = question architecture" rule, the 6 hypotheses we
tested speculatively on the harness's airborne-at-tick-1 bug all
failed because we kept guessing what state the harness lacks. This
commit ships the evidence-driven path: capture the EXACT player
ResolveWithTransition call (every input + body-before + body-after +
result) into a JSON Lines fixture, then a comparison test loads the
fixture and replays it against the test engine. The first per-field
divergence pinpoints the missing apparatus state — no more guessing.
Adds:
- src/AcDream.Core/Physics/PhysicsResolveCapture.cs — new static module
with CapturePath (env var ACDREAM_CAPTURE_RESOLVE), PhysicsBodySnapshot
record, JSON Lines writer (thread-safe, flushes per record), process-
exit hook for clean shutdown.
- PhysicsEngine.ResolveWithTransition probe wiring: snapshot body at
method entry, snapshot again before return, refactor the two returns
into one path so the capture call site is single. Filtered to
IsPlayer mover flag so NPC/remote DR calls don't pollute.
- CellarUpTrajectoryReplayTests.cs:
• Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled — drives 3
ticks with capture on, reads file back, verifies round-trip of
inputs + body-before/after snapshots.
• Capture_SkipsNonPlayerCalls — drives 3 NPC-style ticks (no
IsPlayer flag), confirms the file is not created.
Off by default. Set ACDREAM_CAPTURE_RESOLVE=<path> to a writable file
path; capture starts on the next player ResolveWithTransition call.
Test baseline: 1172 + 8 pre-existing failures + 2 new smoke tests
that pass = 1174 + 8. Verified by stashed-baseline comparison.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ec47159a2e
commit
fb5fba6229
4 changed files with 461 additions and 16 deletions
10
CLAUDE.md
10
CLAUDE.md
|
|
@ -1314,6 +1314,16 @@ via `PlayerMovementController.ApplyServerRunRate`) or from
|
|||
(~100–500 lines/sec). Pair with retail's cdb breakpoint set at
|
||||
`tools/cdb/a6-probe.cdb` for the A6.P1 capture protocol.
|
||||
Runtime-toggleable via the DebugPanel "Diagnostics" section.
|
||||
- `ACDREAM_CAPTURE_RESOLVE=<path>` — A6.P3 #98 live capture of every
|
||||
player-side `PhysicsEngine.ResolveWithTransition` call (2026-05-23 PM
|
||||
apparatus). Each call appends one JSON Lines record with full inputs,
|
||||
PhysicsBody snapshot before AND after, plus the `ResolveResult`.
|
||||
Filtered to `IsPlayer` mover flag — NPC / remote DR calls don't
|
||||
pollute. Pairs with the trajectory replay harness comparison test
|
||||
(`CellarUpTrajectoryReplayTests.Capture_*`) to diff captured vs harness
|
||||
state per field — the first divergence pinpoints missing apparatus
|
||||
state. Capture is OFF when the env var is unset (one null-check
|
||||
cost per call).
|
||||
- *(retired 2026-05-05 by L.3 M2/M3)* `ACDREAM_INTERP_MANAGER` was an
|
||||
env-var gate on an experimental per-tick remote motion path. L.3 M2
|
||||
(commit 40d88b9) replaced both gates (`OnLivePositionUpdated` +
|
||||
|
|
|
|||
|
|
@ -595,6 +595,17 @@ public sealed class PhysicsEngine
|
|||
ObjectInfoState moverFlags = ObjectInfoState.None,
|
||||
uint movingEntityId = 0)
|
||||
{
|
||||
// A6.P3 #98 (2026-05-23) live capture. Filtered to IsPlayer so NPC /
|
||||
// remote ResolveWithTransition calls don't pollute the capture. Snapshot
|
||||
// the body BEFORE the engine mutates it so the replay test can seed its
|
||||
// PhysicsBody with the exact pre-call state. See PhysicsResolveCapture.cs.
|
||||
bool captureEnabled = PhysicsResolveCapture.IsEnabled
|
||||
&& moverFlags.HasFlag(ObjectInfoState.IsPlayer);
|
||||
PhysicsBodySnapshot? bodyBeforeSnap =
|
||||
captureEnabled && body is not null
|
||||
? PhysicsResolveCapture.Snapshot(body)
|
||||
: null;
|
||||
|
||||
var transition = new Transition();
|
||||
transition.ObjectInfo.StepUpHeight = stepUpHeight;
|
||||
transition.ObjectInfo.StepDownHeight = stepDownHeight;
|
||||
|
|
@ -825,34 +836,68 @@ public sealed class PhysicsEngine
|
|||
$"[resolve] ent=0x{movingEntityId:X8} in=({currentPos.X:F3},{currentPos.Y:F3},{currentPos.Z:F3}) cell=0x{cellId:X8} tgt=({targetPos.X:F3},{targetPos.Y:F3},{targetPos.Z:F3}) out=({probePost.X:F3},{probePost.Y:F3},{probePost.Z:F3}) cell=0x{sp.CheckCellId:X8} ok={ok} groundedIn={isOnGround} cp={probeCp} hit={probeHit} walkable={sp.HasLastWalkablePolygon}"));
|
||||
}
|
||||
|
||||
ResolveResult resolveResult;
|
||||
if (ok)
|
||||
{
|
||||
bool onGround = ci.ContactPlaneValid
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable);
|
||||
|
||||
return new ResolveResult(
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, sp.CheckCellId),
|
||||
onGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Transition failed (e.g., stuck in corner, too many steps).
|
||||
// Use whatever position the transition reached (partial movement)
|
||||
// instead of falling back to the no-collision Resolve.
|
||||
// If CheckPos hasn't moved from CurPos, the player stays put —
|
||||
// this is correct behavior when completely blocked.
|
||||
bool partialOnGround = ci.ContactPlaneValid
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|
||||
|| isOnGround;
|
||||
|
||||
// Transition failed (e.g., stuck in corner, too many steps).
|
||||
// Use whatever position the transition reached (partial movement)
|
||||
// instead of falling back to the no-collision Resolve.
|
||||
// If CheckPos hasn't moved from CurPos, the player stays put —
|
||||
// this is correct behavior when completely blocked.
|
||||
bool partialOnGround = ci.ContactPlaneValid
|
||||
|| transition.ObjectInfo.State.HasFlag(ObjectInfoState.OnWalkable)
|
||||
|| isOnGround;
|
||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||
resolveResult = new ResolveResult(
|
||||
sp.CheckPos,
|
||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
|
||||
partialOnGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal);
|
||||
}
|
||||
|
||||
uint partialCellId = sp.CheckCellId != 0 ? sp.CheckCellId : cellId;
|
||||
return new ResolveResult(
|
||||
sp.CheckPos,
|
||||
ResolveCellId(sp.GlobalSphere[0].Origin, sphereRadius, partialCellId),
|
||||
partialOnGround,
|
||||
collisionNormalValid,
|
||||
collisionNormal);
|
||||
// A6.P3 #98 capture: emit one JSON Lines record per player call,
|
||||
// with bodyBefore snapshot (taken at method entry, before any
|
||||
// engine mutation) + bodyAfter snapshot (taken now, after the
|
||||
// engine wrote back the contact plane / walkable / sliding state
|
||||
// to the body). Loaded by CellarUpTrajectoryReplayTests.cs.
|
||||
if (captureEnabled)
|
||||
{
|
||||
PhysicsResolveCapture.LogCall(
|
||||
new ResolveCallInputs(
|
||||
CurrentPos: currentPos,
|
||||
TargetPos: targetPos,
|
||||
CellId: cellId,
|
||||
SphereRadius: sphereRadius,
|
||||
SphereHeight: sphereHeight,
|
||||
StepUpHeight: stepUpHeight,
|
||||
StepDownHeight: stepDownHeight,
|
||||
IsOnGround: isOnGround,
|
||||
MoverFlags: (uint)moverFlags,
|
||||
MovingEntityId: movingEntityId),
|
||||
bodyBeforeSnap,
|
||||
new ResolveCallResult(
|
||||
Position: resolveResult.Position,
|
||||
CellId: resolveResult.CellId,
|
||||
IsOnGround: resolveResult.IsOnGround,
|
||||
CollisionNormalValid: resolveResult.CollisionNormalValid,
|
||||
CollisionNormal: resolveResult.CollisionNormal),
|
||||
body is not null ? PhysicsResolveCapture.Snapshot(body) : null);
|
||||
}
|
||||
|
||||
return resolveResult;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
249
src/AcDream.Core/Physics/PhysicsResolveCapture.cs
Normal file
249
src/AcDream.Core/Physics/PhysicsResolveCapture.cs
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Numerics;
|
||||
using System.Text.Json;
|
||||
using System.Threading;
|
||||
|
||||
namespace AcDream.Core.Physics;
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// A6.P3 issue #98 (2026-05-23) — live capture of every player-side
|
||||
// PhysicsEngine.ResolveWithTransition call so the trajectory replay harness
|
||||
// (tests/AcDream.Core.Tests/Physics/CellarUpTrajectoryReplayTests.cs) can
|
||||
// load the captured fixture and replay the EXACT inputs deterministically.
|
||||
// The captured outputs become the diff target: any per-field divergence
|
||||
// between live and replay pinpoints harness apparatus that is missing,
|
||||
// short-circuiting six rounds of guessing what state the harness lacks.
|
||||
//
|
||||
// Enabled by setting ACDREAM_CAPTURE_RESOLVE=<path>. Each call appends one
|
||||
// JSON Lines record (self-contained — no cross-line dependencies). Filtered
|
||||
// to IsPlayer mover flag so NPC / remote ResolveWithTransition calls don't
|
||||
// pollute the capture.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// A6.P3 issue #98 live capture of player ResolveWithTransition calls.
|
||||
/// Pairs with <see cref="CellarUpTrajectoryReplayTests"/>'s replay harness:
|
||||
/// the capture is the test's input fixture AND the diff-target output.
|
||||
/// Off unless <c>ACDREAM_CAPTURE_RESOLVE=<path></c> is set.
|
||||
/// </summary>
|
||||
public static class PhysicsResolveCapture
|
||||
{
|
||||
/// <summary>
|
||||
/// Output path for captured records. Each call appends one JSON Lines
|
||||
/// record. When null/empty, capture is off (one null-check cost per
|
||||
/// call, no allocations). Initialized from
|
||||
/// <c>ACDREAM_CAPTURE_RESOLVE</c> at process start; runtime mutation
|
||||
/// allowed for tests.
|
||||
/// </summary>
|
||||
public static string? CapturePath { get; set; } =
|
||||
Environment.GetEnvironmentVariable("ACDREAM_CAPTURE_RESOLVE");
|
||||
|
||||
/// <summary>True when <see cref="CapturePath"/> is non-empty.</summary>
|
||||
public static bool IsEnabled => !string.IsNullOrWhiteSpace(CapturePath);
|
||||
|
||||
private static int _tickCounter;
|
||||
private static StreamWriter? _writer;
|
||||
private static readonly object _writerLock = new();
|
||||
|
||||
// System.Numerics.Vector3 / Quaternion / Plane expose their components
|
||||
// as public FIELDS (X/Y/Z[/W], Normal/D), not properties. Without
|
||||
// IncludeFields=true the resulting JSON serializes them as empty
|
||||
// objects ({}) — verified by smoke-test failure 2026-05-23 evening.
|
||||
private static readonly JsonSerializerOptions s_jsonOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
IncludeFields = true,
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Captures every PhysicsBody field that
|
||||
/// <see cref="PhysicsEngine.ResolveWithTransition"/> reads or writes.
|
||||
/// Copies value-type fields directly (Vector3 / Plane / Quaternion
|
||||
/// are structs — no aliasing) and clones <c>WalkableVertices</c>
|
||||
/// since the engine may replace the array.
|
||||
/// </summary>
|
||||
public static PhysicsBodySnapshot Snapshot(PhysicsBody body) => new(
|
||||
Position: body.Position,
|
||||
Orientation: body.Orientation,
|
||||
Velocity: body.Velocity,
|
||||
Acceleration: body.Acceleration,
|
||||
Omega: body.Omega,
|
||||
GroundNormal: body.GroundNormal,
|
||||
SlidingNormal: body.SlidingNormal,
|
||||
ContactPlaneValid: body.ContactPlaneValid,
|
||||
ContactPlane: body.ContactPlane,
|
||||
ContactPlaneCellId: body.ContactPlaneCellId,
|
||||
ContactPlaneIsWater: body.ContactPlaneIsWater,
|
||||
WalkablePolygonValid: body.WalkablePolygonValid,
|
||||
WalkablePlane: body.WalkablePlane,
|
||||
WalkableVertices: body.WalkableVertices is null
|
||||
? null
|
||||
: (Vector3[])body.WalkableVertices.Clone(),
|
||||
WalkableUp: body.WalkableUp,
|
||||
Elasticity: body.Elasticity,
|
||||
Friction: body.Friction,
|
||||
State: (uint)body.State,
|
||||
TransientState: (uint)body.TransientState,
|
||||
LastUpdateTime: body.LastUpdateTime);
|
||||
|
||||
/// <summary>
|
||||
/// Write one JSON-Lines record for a player ResolveWithTransition call.
|
||||
/// Caller MUST guard with <c>if (!IsEnabled) return;</c> before snapshotting
|
||||
/// the body — this method assumes the caller paid the snapshot cost.
|
||||
/// </summary>
|
||||
public static void LogCall(
|
||||
ResolveCallInputs input,
|
||||
PhysicsBodySnapshot? bodyBefore,
|
||||
ResolveCallResult result,
|
||||
PhysicsBodySnapshot? bodyAfter)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(CapturePath))
|
||||
return;
|
||||
|
||||
var record = new ResolveCaptureRecord(
|
||||
Tick: Interlocked.Increment(ref _tickCounter) - 1,
|
||||
TimestampMs: (long)(System.Diagnostics.Stopwatch.GetTimestamp()
|
||||
* 1000.0 / System.Diagnostics.Stopwatch.Frequency),
|
||||
Input: input,
|
||||
BodyBefore: bodyBefore,
|
||||
Result: result,
|
||||
BodyAfter: bodyAfter);
|
||||
|
||||
string json = JsonSerializer.Serialize(record, s_jsonOptions);
|
||||
|
||||
lock (_writerLock)
|
||||
{
|
||||
EnsureWriter_NoLock();
|
||||
_writer!.WriteLine(json);
|
||||
_writer.Flush();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Close the capture file. Called by the writer's process-exit hook
|
||||
/// in <see cref="EnsureWriter_NoLock"/>; tests may also call it
|
||||
/// manually to release the file before re-opening.
|
||||
/// </summary>
|
||||
public static void Close()
|
||||
{
|
||||
lock (_writerLock)
|
||||
{
|
||||
if (_writer is not null)
|
||||
{
|
||||
_writer.Flush();
|
||||
_writer.Dispose();
|
||||
_writer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reset the per-process tick counter. Tests call this between
|
||||
/// fixtures so the JSON tick field starts at zero.
|
||||
/// </summary>
|
||||
public static void ResetTickCounter() =>
|
||||
Interlocked.Exchange(ref _tickCounter, 0);
|
||||
|
||||
private static void EnsureWriter_NoLock()
|
||||
{
|
||||
if (_writer is not null)
|
||||
return;
|
||||
|
||||
var path = CapturePath!;
|
||||
var dir = Path.GetDirectoryName(path);
|
||||
if (!string.IsNullOrEmpty(dir))
|
||||
Directory.CreateDirectory(dir);
|
||||
|
||||
// Append mode — multiple sessions can accumulate in the same file
|
||||
// (the user can split by Tick=0 boundaries later).
|
||||
var stream = new FileStream(
|
||||
path,
|
||||
FileMode.Append,
|
||||
FileAccess.Write,
|
||||
FileShare.Read);
|
||||
_writer = new StreamWriter(stream)
|
||||
{
|
||||
AutoFlush = false,
|
||||
};
|
||||
|
||||
// Best-effort: flush + close on process exit so a hard-kill doesn't
|
||||
// truncate the last record.
|
||||
AppDomain.CurrentDomain.ProcessExit += static (_, _) => Close();
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// Serializable records. System.Text.Json serializes Vector3/Quaternion/Plane
|
||||
// natively as {X,Y,Z[,W]} objects — no custom converters needed. Numeric
|
||||
// types and bools are camelCased per the global JsonSerializerOptions above.
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// One captured player-side ResolveWithTransition call. Self-contained:
|
||||
/// every field needed to replay the call is here.
|
||||
/// </summary>
|
||||
public sealed record ResolveCaptureRecord(
|
||||
int Tick,
|
||||
long TimestampMs,
|
||||
ResolveCallInputs Input,
|
||||
PhysicsBodySnapshot? BodyBefore,
|
||||
ResolveCallResult Result,
|
||||
PhysicsBodySnapshot? BodyAfter);
|
||||
|
||||
/// <summary>
|
||||
/// Every parameter to
|
||||
/// <see cref="PhysicsEngine.ResolveWithTransition"/>, in capture form.
|
||||
/// </summary>
|
||||
public sealed record ResolveCallInputs(
|
||||
Vector3 CurrentPos,
|
||||
Vector3 TargetPos,
|
||||
uint CellId,
|
||||
float SphereRadius,
|
||||
float SphereHeight,
|
||||
float StepUpHeight,
|
||||
float StepDownHeight,
|
||||
bool IsOnGround,
|
||||
uint MoverFlags,
|
||||
uint MovingEntityId);
|
||||
|
||||
/// <summary>
|
||||
/// Every field of <see cref="ResolveResult"/>, in capture form.
|
||||
/// </summary>
|
||||
public sealed record ResolveCallResult(
|
||||
Vector3 Position,
|
||||
uint CellId,
|
||||
bool IsOnGround,
|
||||
bool CollisionNormalValid,
|
||||
Vector3 CollisionNormal);
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of every <see cref="PhysicsBody"/> field that
|
||||
/// <see cref="PhysicsEngine.ResolveWithTransition"/> reads or writes.
|
||||
/// Captured both before and after the call so the comparison harness
|
||||
/// can verify that engine mutation matches between live and replay.
|
||||
/// </summary>
|
||||
public sealed record PhysicsBodySnapshot(
|
||||
Vector3 Position,
|
||||
Quaternion Orientation,
|
||||
Vector3 Velocity,
|
||||
Vector3 Acceleration,
|
||||
Vector3 Omega,
|
||||
Vector3 GroundNormal,
|
||||
Vector3 SlidingNormal,
|
||||
bool ContactPlaneValid,
|
||||
Plane ContactPlane,
|
||||
uint ContactPlaneCellId,
|
||||
bool ContactPlaneIsWater,
|
||||
bool WalkablePolygonValid,
|
||||
Plane WalkablePlane,
|
||||
Vector3[]? WalkableVertices,
|
||||
Vector3 WalkableUp,
|
||||
float Elasticity,
|
||||
float Friction,
|
||||
uint State,
|
||||
uint TransientState,
|
||||
double LastUpdateTime);
|
||||
|
|
@ -299,6 +299,147 @@ public class CellarUpTrajectoryReplayTests
|
|||
$"Took: {sw.ElapsedMilliseconds} ms.");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A6.P3 #98 (2026-05-23 evening apparatus extension) — smoke-tests
|
||||
/// the <see cref="PhysicsResolveCapture"/> probe. Drives 3 ticks with
|
||||
/// capture enabled, then reads the JSON-Lines file back and verifies:
|
||||
/// <list type="bullet">
|
||||
/// <item>One record per call.</item>
|
||||
/// <item>Inputs round-trip (currentPos, targetPos, cellId, flags).</item>
|
||||
/// <item>Body-before and body-after snapshots are present.</item>
|
||||
/// </list>
|
||||
/// This proves the production probe is wire-correct before we ask the
|
||||
/// user to run a live capture in the cellar — if this test passes, the
|
||||
/// only variable left is what's different about the live run.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Capture_WritesJsonLinesRecordsWhenIsPlayerAndEnabled()
|
||||
{
|
||||
string capturePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream_capture_{Guid.NewGuid():N}.jsonl");
|
||||
|
||||
try
|
||||
{
|
||||
PhysicsResolveCapture.CapturePath = capturePath;
|
||||
PhysicsResolveCapture.ResetTickCounter();
|
||||
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var body = BuildInitialBody();
|
||||
_ = SimulateTicks(engine, body, CellarId, 3);
|
||||
|
||||
PhysicsResolveCapture.Close();
|
||||
|
||||
Assert.True(File.Exists(capturePath), "Capture file should exist.");
|
||||
var lines = File.ReadAllLines(capturePath);
|
||||
Assert.Equal(3, lines.Length);
|
||||
|
||||
var records = lines
|
||||
.Select(static l => System.Text.Json.JsonSerializer.Deserialize<ResolveCaptureRecord>(
|
||||
l, CaptureJsonOptions))
|
||||
.ToList();
|
||||
|
||||
// Tick monotonic 0,1,2.
|
||||
Assert.Equal(0, records[0]!.Tick);
|
||||
Assert.Equal(1, records[1]!.Tick);
|
||||
Assert.Equal(2, records[2]!.Tick);
|
||||
|
||||
// Inputs at tick 0 must match the harness's initial position.
|
||||
var firstInput = records[0]!.Input;
|
||||
Assert.Equal(InitialSphereWorld.X, firstInput.CurrentPos.X, 4);
|
||||
Assert.Equal(InitialSphereWorld.Y, firstInput.CurrentPos.Y, 4);
|
||||
Assert.Equal(InitialSphereWorld.Z, firstInput.CurrentPos.Z, 4);
|
||||
Assert.Equal(CellarId, firstInput.CellId);
|
||||
Assert.True(firstInput.IsOnGround,
|
||||
"First tick is seeded grounded.");
|
||||
|
||||
// Body before + after snapshots present.
|
||||
Assert.NotNull(records[0]!.BodyBefore);
|
||||
Assert.NotNull(records[0]!.BodyAfter);
|
||||
|
||||
// Body-before's ContactPlane should match the seeded floor plane.
|
||||
var cpBefore = records[0]!.BodyBefore!.ContactPlane;
|
||||
Assert.Equal(0f, cpBefore.Normal.X, 5);
|
||||
Assert.Equal(0f, cpBefore.Normal.Y, 5);
|
||||
Assert.Equal(1f, cpBefore.Normal.Z, 5);
|
||||
Assert.Equal(-CellarFloorZ, cpBefore.D, 3);
|
||||
}
|
||||
finally
|
||||
{
|
||||
PhysicsResolveCapture.CapturePath = null;
|
||||
PhysicsResolveCapture.Close();
|
||||
if (File.Exists(capturePath))
|
||||
File.Delete(capturePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Capture is filtered to <c>IsPlayer</c> mover flag. Calls without
|
||||
/// that flag (NPC, remote dead-reckoning) must NOT pollute the
|
||||
/// capture file.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Capture_SkipsNonPlayerCalls()
|
||||
{
|
||||
string capturePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"acdream_capture_npc_{Guid.NewGuid():N}.jsonl");
|
||||
|
||||
try
|
||||
{
|
||||
PhysicsResolveCapture.CapturePath = capturePath;
|
||||
PhysicsResolveCapture.ResetTickCounter();
|
||||
|
||||
var (engine, _) = BuildEngineWithCellarFixtures();
|
||||
var body = BuildInitialBody();
|
||||
|
||||
// Drive 3 ticks WITHOUT IsPlayer flag — simulates an NPC path.
|
||||
uint cellId = CellarId;
|
||||
bool isOnGround = true;
|
||||
for (int i = 0; i < 3; i++)
|
||||
{
|
||||
Vector3 target = body.Position + PerTickOffset;
|
||||
var result = engine.ResolveWithTransition(
|
||||
body.Position, target, cellId,
|
||||
SphereRadius, SphereHeight,
|
||||
StepUpHeight, StepDownHeight,
|
||||
isOnGround,
|
||||
body: body,
|
||||
moverFlags: ObjectInfoState.EdgeSlide, // ← no IsPlayer
|
||||
movingEntityId: 0);
|
||||
body.Position = result.Position;
|
||||
cellId = result.CellId;
|
||||
isOnGround = result.IsOnGround;
|
||||
}
|
||||
|
||||
PhysicsResolveCapture.Close();
|
||||
|
||||
// No records written because no IsPlayer call ran.
|
||||
Assert.False(File.Exists(capturePath),
|
||||
"Capture file should NOT exist when only non-player calls ran.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
PhysicsResolveCapture.CapturePath = null;
|
||||
PhysicsResolveCapture.Close();
|
||||
if (File.Exists(capturePath))
|
||||
File.Delete(capturePath);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Shared deserialization options matching
|
||||
/// <see cref="PhysicsResolveCapture"/>'s serializer. <c>IncludeFields</c>
|
||||
/// is required because Vector3/Quaternion/Plane store components as
|
||||
/// fields, not properties.
|
||||
/// </summary>
|
||||
public static readonly System.Text.Json.JsonSerializerOptions CaptureJsonOptions =
|
||||
new()
|
||||
{
|
||||
IncludeFields = true,
|
||||
PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase,
|
||||
};
|
||||
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
// Harness internals
|
||||
// ───────────────────────────────────────────────────────────────
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue