Builds the machinery route 4b-2 and 4b-3 will flip on, and changes no remote behaviour: it has no production caller, so RemotePlacementDrivePendingCount is provably 0 and IsConverged is unchanged. Five pieces: a per-entity remote placement owner (RuntimeRemotePlacementDriveController), a Position-time service-window guard with a Runtime interface plus BOTH host implementations, N3's headless RetryPending pump, parked-count observability in the ownership ledger, and the service-window optimisation that avoids parks we can cheaply predict. Landed alone because it is where the park-withdraws-the-entity failure was decided; that decision is fixed at the source in the preceding commit and must not share a review signal with a behaviour flip. Two parts of route 2's controller are deliberately NOT ported, both verified against retail rather than assumed. There is no ack: SendPositionEvent is called only inside HandleReceivedPosition's local-player FORCE_POSITION gate @0x0045400C-@0x00454091, and the remote arm @0x0045414D has no equivalent. There is no re-issue funnel: retail never re-attempts a position it could not apply — stale timestamps merely bump error_count @0x004542AC — and re-issuing packet N after N+1 has merged would apply a pose the newer packet already superseded, which is correct for a one-shot ForcePosition and wrong for a 5-10 Hz stream. The service-window guard is an OPTIMISATION, not the correctness mechanism. The original contract had it the other way round, justified by a claim that retail cannot represent "arrived but not placeable" — false, and corrected in the review findings: retail's GotoLostCell/reenter_visibility path represents it exactly. A pre-flight guard also cannot be complete, because Core defers on the entity's CURRENT cell, on the swept QueriedCellIds footprint spanning neighbouring landblocks, and on residency evaluated after AdjustToOutside — conditions only Core can see. Review found and this commit fixes: DetachRoute cleared two maps of LIVE Core operations without cancelling them (route 2's AbandonPending is the correct mirror, not the first-entry controller) and its test asserted that blindness as convergence; the headless predicate answered "can ever publish" rather than "is published", and after the first fix still matched only 1 of the 9 landblocks this host publishes; OwnsPlacement admitted remote top-level Creates until gated on the Teleport flag as well as the disposition; Advance re-submitted without re-checking the window; and four comments cited a report that did not exist. Contract item 6 is met by the structural proof, not the earlier test: HasOldPrefixPlacementDebt refuses collision-prefix mutation permission before ParkCollisionResidents is ever entered, so its overlap throw is unreachable. That same mechanism is the unbounded stall filed as #310, which 4b-1 does not bound — it only avoids widening it. #311 files the remaining per-tick allocation in RetryPendingProjections; the early-out for the empty-FIFO case landed via a new HasPendingReceipts accessor so hosts still never touch .Placements. directly. Gates: complete Release solution 10,973 passed / 4 skipped / 0 failed (baseline 10,938). Four review rounds; every fix discrimination-verified by revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
8.2 KiB
C#
215 lines
8.2 KiB
C#
using System.Collections.Immutable;
|
|
using System.Reflection;
|
|
using AcDream.Content;
|
|
using AcDream.Core.Combat;
|
|
using AcDream.Core.Items;
|
|
using AcDream.Core.Physics;
|
|
using AcDream.Core.Spells;
|
|
using AcDream.Headless.Configuration;
|
|
using AcDream.Headless.Hosting;
|
|
using AcDream.Runtime;
|
|
using AcDream.Runtime.Gameplay;
|
|
using AcDream.Runtime.Session;
|
|
|
|
namespace AcDream.Headless.Tests;
|
|
|
|
/// <summary>
|
|
/// B1 review fix: <see cref="HeadlessCollisionNeighborhood"/> implements TWO
|
|
/// interfaces that share the identical <c>bool IsWithinServiceWindow(uint)</c>
|
|
/// signature but ask different questions —
|
|
/// <see cref="IHeadlessCollisionNeighborhood.IsWithinServiceWindow"/> is a
|
|
/// pure geometry test ("can this landblock EVER collision-publish", true
|
|
/// outright with no center requested), while
|
|
/// <see cref="IRuntimeRemotePlacementServiceWindow.IsWithinServiceWindow"/>
|
|
/// must answer "is it collision-published RIGHT NOW". This is the focused
|
|
/// proof that the two answers genuinely diverge — before this fix a single
|
|
/// method satisfied both interfaces, so both answers were identical (and
|
|
/// wrong for the new interface's contract).
|
|
/// </summary>
|
|
public sealed class HeadlessCollisionNeighborhoodServiceWindowTests
|
|
{
|
|
[Fact]
|
|
public void ServiceWindowIsResidencyNotGeometry_UnpublishedLandblockIsRefusedDespiteGeometricMembership()
|
|
{
|
|
var factory = new FixtureContentFactory();
|
|
using var owner = new HeadlessProcessContentOwner(
|
|
ContentDescriptor(),
|
|
_ => { },
|
|
factory);
|
|
using HeadlessProcessContentOwner.HeadlessProcessContentLease lease =
|
|
owner.AcquireLease("fixture");
|
|
var operations = new FixtureGameplayOperations();
|
|
using var runtime = new GameRuntime(new GameRuntimeDependencies(
|
|
operations, operations, operations, operations));
|
|
var neighborhood = new HeadlessCollisionNeighborhood(runtime, lease);
|
|
|
|
// CenterOn was never called, so the geometry interface's own
|
|
// documented contract applies: "no center requested yet" => true
|
|
// (this landblock could theoretically EVER be served). Nothing has
|
|
// published ANY collision for it, though — the residency-based
|
|
// interface must say false.
|
|
const uint cell = 0xA9B40001u;
|
|
Assert.True(
|
|
((IHeadlessCollisionNeighborhood)neighborhood)
|
|
.IsWithinServiceWindow(cell));
|
|
Assert.False(
|
|
((IRuntimeRemotePlacementServiceWindow)neighborhood)
|
|
.IsWithinServiceWindow(cell));
|
|
}
|
|
|
|
/// <summary>
|
|
/// C2-3 review fix (delta round): <c>BuildPublicationPlan</c> publishes
|
|
/// the requested center's FULL 3x3 window, not just the exact center —
|
|
/// so a landblock this host HAS published but which is not the exact
|
|
/// <c>_centerLandblock</c> (a remote sitting one landblock off-center,
|
|
/// exactly the boundary population this route exists to serve) must
|
|
/// still read as currently published. Before this fix the predicate
|
|
/// inherited <see cref="IHeadlessCollisionNeighborhood.IsReady"/>'s own
|
|
/// <c>_centerLandblock != center</c> restriction — correct for
|
|
/// <c>IsReady</c>'s narrower question, wrong here — so only ONE of the
|
|
/// nine published landblocks would ever read true.
|
|
/// <para>
|
|
/// Seeds <c>_resident</c> directly via reflection (no lightweight DAT
|
|
/// fixture in this test project can drive real 3x3 publication through
|
|
/// <c>CenterOn</c> — its dummy <see cref="IDatReaderWriter"/> proxy makes
|
|
/// <c>LandblockLoader.Load</c> fail for every landblock, including a
|
|
/// REQUIRED center) — mirrors the existing reflection precedent
|
|
/// <c>HeadlessSessionHostTests.SeedRuntimePlacement</c> already uses for
|
|
/// otherwise-unreachable internal state. <c>_centerLandblock</c> is
|
|
/// deliberately left at its default (never set) — the whole point is
|
|
/// that this predicate no longer depends on it.
|
|
/// </para>
|
|
/// </summary>
|
|
[Fact]
|
|
public void ServiceWindowCoversAPublishedNeighborLandblockNotOnlyTheExactCenter()
|
|
{
|
|
var factory = new FixtureContentFactory();
|
|
using var owner = new HeadlessProcessContentOwner(
|
|
ContentDescriptor(),
|
|
_ => { },
|
|
factory);
|
|
using HeadlessProcessContentOwner.HeadlessProcessContentLease lease =
|
|
owner.AcquireLease("fixture");
|
|
var operations = new FixtureGameplayOperations();
|
|
using var runtime = new GameRuntime(new GameRuntimeDependencies(
|
|
operations, operations, operations, operations));
|
|
var neighborhood = new HeadlessCollisionNeighborhood(runtime, lease);
|
|
|
|
const uint neighborLandblock = 0xA9B5FFFFu;
|
|
const uint neighborCell = 0xA9B50001u;
|
|
runtime.EntityObjects.Physics.Engine.AddLandblock(
|
|
neighborLandblock,
|
|
new TerrainSurface(new byte[81], new float[256]),
|
|
Array.Empty<CellSurface>(),
|
|
Array.Empty<PortalPlane>(),
|
|
worldOffsetX: 0f,
|
|
worldOffsetY: 0f);
|
|
SeedResident(neighborhood, neighborLandblock);
|
|
|
|
Assert.True(
|
|
((IRuntimeRemotePlacementServiceWindow)neighborhood)
|
|
.IsWithinServiceWindow(neighborCell));
|
|
}
|
|
|
|
private static void SeedResident(
|
|
HeadlessCollisionNeighborhood neighborhood,
|
|
uint landblockId)
|
|
{
|
|
FieldInfo field = typeof(HeadlessCollisionNeighborhood).GetField(
|
|
"_resident",
|
|
BindingFlags.NonPublic | BindingFlags.Instance)
|
|
?? throw new MissingFieldException(
|
|
nameof(HeadlessCollisionNeighborhood), "_resident");
|
|
var resident = (HashSet<uint>)field.GetValue(neighborhood)!;
|
|
resident.Add(landblockId);
|
|
}
|
|
|
|
private static HeadlessContentDescriptor ContentDescriptor() => new()
|
|
{
|
|
DatDirectory = "fixture-dats",
|
|
PreparedAssetPath = "fixture.pak",
|
|
};
|
|
|
|
private sealed class FixtureContentFactory
|
|
: IHeadlessProcessContentFactory
|
|
{
|
|
internal FixtureContentFactory()
|
|
{
|
|
DatsResource =
|
|
DispatchProxy.Create<IDatReaderWriter, TestResourceProxy>();
|
|
PreparedResource =
|
|
DispatchProxy.Create<ITestPreparedSource, TestResourceProxy>();
|
|
}
|
|
|
|
internal IDatReaderWriter DatsResource { get; }
|
|
internal ITestPreparedSource PreparedResource { get; }
|
|
|
|
public HeadlessOpenedProcessContent Open(
|
|
HeadlessContentDescriptor descriptor,
|
|
Action<string> diagnostic) =>
|
|
new(
|
|
DatsResource,
|
|
PreparedResource,
|
|
MagicCatalog.Empty,
|
|
ImmutableArray.CreateRange(new float[256]));
|
|
}
|
|
|
|
private sealed class FixtureGameplayOperations
|
|
: IRuntimeCombatAttackOperations,
|
|
IRuntimeCombatTargetOperations,
|
|
IRuntimeCombatModeOperations,
|
|
IRuntimeSpellCastOperations
|
|
{
|
|
public bool CanStartAttack() => false;
|
|
public void PrepareAttackRequest()
|
|
{
|
|
}
|
|
|
|
public bool SendAttack(AttackHeight height, float power) => false;
|
|
public void SendCancelAttack()
|
|
{
|
|
}
|
|
|
|
public bool IsDualWield => false;
|
|
public bool PlayerReadyForAttack => false;
|
|
public bool AutoRepeatAttack => false;
|
|
public bool AutoTarget => false;
|
|
public uint? SelectClosestTarget() => null;
|
|
public bool IsInWorld => false;
|
|
public IReadOnlyList<ClientObject> GetOrderedEquipment() => [];
|
|
public void NotifyExplicitCombatModeRequest()
|
|
{
|
|
}
|
|
|
|
public void SendChangeCombatMode(CombatMode mode)
|
|
{
|
|
}
|
|
|
|
public uint LocalPlayerId => 0u;
|
|
public bool CanSend => false;
|
|
public bool HasRequiredComponents(uint spellId) => false;
|
|
|
|
public bool IsTargetCompatible(
|
|
uint targetId, SpellMetadata spell, bool showMessage) => false;
|
|
|
|
public void StopCompletely()
|
|
{
|
|
}
|
|
|
|
public void SendUntargeted(uint spellId)
|
|
{
|
|
}
|
|
|
|
public void SendTargeted(uint targetId, uint spellId)
|
|
{
|
|
}
|
|
|
|
public void DisplayMessage(string message)
|
|
{
|
|
}
|
|
|
|
public void IncrementBusy()
|
|
{
|
|
}
|
|
}
|
|
}
|