using System;
using System.Collections.Generic;
using System.Numerics;
using AcDream.Core.Physics;
using AcDream.Core.World.Cells;
using DatReaderWriter;
using DatReaderWriter.Options;
using Xunit;
using Xunit.Abstractions;
namespace AcDream.Core.Tests.Conformance;
///
/// #107 (2026-06-10): the indoor-login spawn wedge. The live capture
/// (resolve-107-login1.jsonl) showed ACE restoring the player with a POISONED
/// (cell, position) pair — cell id 0xA9B40162 (a different building, ~55 m
/// away) with a position inside cell 0xA9B40171 (the threshold cottage). The
/// unvalidated claim left the player fake-grounded (no contact plane, no
/// walkable polygon); the first movement demoted them to outdoor mid-building
/// and they fell 2.4 m through the cottage floor onto the terrain underneath.
///
/// These tests pin the dat-level facts that drove the fix (the retail
/// AdjustPosition validation at the player snap, PhysicsEngine.Resolve head):
/// the position is genuinely inside 0x171, genuinely NOT inside 0x162's
/// visible graph, and the production pick recovers correctly from a GOOD seed
/// while demoting to outdoor from the poisoned one (retail-identical: retail's
/// find_visible_child_cell also cannot recover a cross-building claim and
/// falls to seen_outside → adjust_to_outside, acclient :280037).
///
public sealed class Issue107SpawnDiagnosticTests
{
private readonly ITestOutputHelper _out;
public Issue107SpawnDiagnosticTests(ITestOutputHelper output) => _out = output;
private const float FootRadius = 0.48f; // capture input.sphereRadius
private const uint PoisonedClaim = 0xA9B40162u; // the inn-side cell ACE reported
private const uint ActualCell = 0xA9B40171u; // the cell that contains the position
/// The captured spawn position (A9B4-local == capture world frame).
private static readonly Vector3 Spawn = new(156.53314f, 11.775104f, 96.5f);
[Fact]
public void SpawnPosition_IsInside0171_NotInside0162_PickRecoversFromGoodSeed()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cache = new PhysicsDataCache();
var containing = new List();
int loaded = 0;
for (uint low = 0x0100; low <= 0x01FF; low++)
{
uint cellId = 0xA9B40000u | low;
EnvCell cell;
try { cell = ConformanceDats.LoadEnvCell(dats, cache, cellId); }
catch { continue; }
loaded++;
if (cell.PointInCell(Spawn + new Vector3(0, 0, FootRadius)))
{
containing.Add(cellId);
_out.WriteLine($"cell 0x{cellId:X8} CONTAINS spawn footCenter");
}
}
Assert.True(loaded > 0, "expected Holtburg interior cells to load");
// The poisoned-pair facts: position inside 0x171, NOT inside 0x162.
Assert.Contains(ActualCell, containing);
Assert.DoesNotContain(PoisonedClaim, containing);
var footCenter = Spawn + new Vector3(0, 0, FootRadius);
// Production pick with the GOOD seed keeps the player indoor.
uint pickGood = CellTransit.FindCellList(cache, footCenter, FootRadius, ActualCell);
_out.WriteLine($"FindCellList(seed=0x{ActualCell:X8}) -> 0x{pickGood:X8}");
Assert.Equal(ActualCell, pickGood);
// Production pick with the POISONED seed KEEPS it — retail's
// find_cell_list null-result behavior (pc:308788-308825; #112 removed
// the non-retail escape-hatch demote that used to return the outdoor
// column here). The poisoned-save case is handled at the SNAP:
// PhysicsEngine.Resolve's AdjustPosition validation rejects the claim
// and demotes via seen_outside BEFORE physics ever runs on it.
uint pickBad = CellTransit.FindCellList(cache, footCenter, FootRadius, PoisonedClaim);
_out.WriteLine($"FindCellList(seed=0x{PoisonedClaim:X8}) -> 0x{pickBad:X8}");
Assert.Equal(PoisonedClaim, pickBad);
}
[Fact]
public void SeenOutside_IsPopulated_ForGroundFloorInteriors()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cache = new PhysicsDataCache();
ConformanceDats.LoadEnvCell(dats, cache, ActualCell);
ConformanceDats.LoadEnvCell(dats, cache, PoisonedClaim);
var room = cache.GetCellStruct(ActualCell);
Assert.NotNull(room);
// 0x171 is a ground-floor cottage room with an exterior doorway —
// retail marks it seen_outside; AdjustPosition's indoor not-found
// fallback (acclient :280037) depends on this flag being cached.
Assert.True(room!.SeenOutside, "0xA9B40171 should be seen_outside");
_out.WriteLine($"0x{ActualCell:X8}.SeenOutside={room.SeenOutside}");
var inn = cache.GetCellStruct(PoisonedClaim);
Assert.NotNull(inn);
_out.WriteLine($"0x{PoisonedClaim:X8}.SeenOutside={inn!.SeenOutside}");
}
[Fact]
public void AdjacentRoomClaim_CorrectsToContainingRoom_ViaStabList()
{
// 2026-06-10 gate-run regression: ACE saved the cell one room off
// (claim 0xA9B40172, position inside 0xA9B40171 — adjacent rooms of the
// threshold cottage, connected via the 20 cm threshold cell 0x173).
// With every cell hydrated, retail's find_visible_child_cell stab-list
// search (acclient :311444) must correct the claim. The live failure
// was the auto-entry hold opening before 0x171 hydrated (the
// mid-population "any cell present" disambiguator, since removed) —
// this test pins the hydrated-path correctness.
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var cache = new PhysicsDataCache();
for (uint low = 0x0100; low <= 0x01FF; low++)
{
try { ConformanceDats.LoadEnvCell(dats, cache, 0xA9B40000u | low); }
catch { }
}
// The gate-run spawn point (ACE save: cell 0x172, position in 0x171).
var spawnFootCenter = new Vector3(155.390f, 11.020f, 94.0f + FootRadius);
uint corrected = CellTransit.FindVisibleChildCell(
cache, 0xA9B40172u, spawnFootCenter, useStabList: true);
_out.WriteLine($"FindVisibleChildCell(claim 0x172, stab) -> 0x{corrected:X8}");
Assert.Equal(ActualCell, corrected);
}
[Fact]
public void PoisonedClaim_IsADifferentBuilding_55mAway()
{
var datDir = ConformanceDats.ResolveDatDir();
if (datDir is null) { _out.WriteLine("dats unavailable — skipped"); return; }
using var dats = new DatCollection(datDir, DatAccessType.Read);
var claim = dats.Get(PoisonedClaim);
var actual = dats.Get(ActualCell);
Assert.NotNull(claim);
Assert.NotNull(actual);
float dist = Vector3.Distance(claim!.Position.Origin, actual!.Position.Origin);
_out.WriteLine(
$"claim origin=({claim.Position.Origin.X:F1},{claim.Position.Origin.Y:F1}) " +
$"actual origin=({actual.Position.Origin.X:F1},{actual.Position.Origin.Y:F1}) dist={dist:F1} m");
// The pair is not a one-tick-stale neighbour confusion — the buildings
// are tens of metres apart. The poisoning happened on the WIRE side
// (the old position-derived-landblock wireCellId composition) or in a
// prior wedged session's chaos, not in the per-tick membership pick.
Assert.True(dist > 20f, $"expected cross-building distance, got {dist:F1} m");
}
}