feat(physics): C4 route 4b-1 — remote placement infrastructure (dormant)
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>
This commit is contained in:
parent
634bc5513a
commit
2e8e09acd0
14 changed files with 3151 additions and 6 deletions
|
|
@ -0,0 +1,215 @@
|
|||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,497 @@
|
|||
using System.Net;
|
||||
using System.Numerics;
|
||||
using AcDream.Content;
|
||||
using AcDream.Content.Pak;
|
||||
using AcDream.Core.Combat;
|
||||
using AcDream.Core.Items;
|
||||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Physics;
|
||||
using AcDream.Core.Spells;
|
||||
using AcDream.Headless.Hosting;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Entities;
|
||||
using AcDream.Runtime.Gameplay;
|
||||
using AcDream.Runtime.Physics;
|
||||
using AcDream.Runtime.Session;
|
||||
|
||||
namespace AcDream.Headless.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// C4 route 4b-1 (N3): <c>HeadlessSessionEventRoute.Attach</c> constructs its
|
||||
/// <c>RuntimePlacementProjectionSubscription</c> with
|
||||
/// <c>retryPendingOnSubscribe: true</c>, and that was the ONLY
|
||||
/// <c>RetryPending</c> call headless ever made — a Place a host sink declines
|
||||
/// (landblock not loaded, stale transit authority) is left at the FIFO head
|
||||
/// for its own later retry
|
||||
/// (<c>RuntimePlacementProjectionSubscription.OnPlacement</c>'s doc comment),
|
||||
/// but nothing headless did ever asked again. This is the focused proof that
|
||||
/// <see cref="HeadlessSessionEventRoute.RetryPending"/> — the method
|
||||
/// <c>HeadlessSessionHost.Tick</c> now calls every tick, immediately after
|
||||
/// <c>HeadlessSessionWorldProjection.PumpFirstEntry</c> — actually re-offers a
|
||||
/// declined head. Without a SECOND call, the declined receipt sits forever.
|
||||
///
|
||||
/// <para>
|
||||
/// Uses the SAME lightweight <c>LiveSessionHost</c> + no-op event/command
|
||||
/// route fixture as
|
||||
/// <c>RuntimeAcceptedPositionDriveControllerTests.StartRuntime</c> — it
|
||||
/// produces a genuine nonzero <c>GameRuntime.Generation</c> (required:
|
||||
/// <c>RuntimePlacementProjectionChannel.IsCurrent</c> rejects generation 0
|
||||
/// outright) WITHOUT wiring any real placement-projection subscription, so
|
||||
/// this test's own injected fake sink is the ONLY observer of the FIFO.
|
||||
/// <c>HeadlessSessionHost.Start</c> would also work generation-wise, but its
|
||||
/// own internal route always uses the real
|
||||
/// <c>HeadlessRuntimePlacementProjectionSink</c>, which would consume-and-
|
||||
/// acknowledge this test's synthetic Place before this test's own route ever
|
||||
/// subscribed.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class HeadlessSessionEventRouteRetryPendingTests
|
||||
{
|
||||
private const uint PlayerGuid = 0x50000001u;
|
||||
private const uint Landblock = 0xC1000000u;
|
||||
private const uint Cell = Landblock | 0x0001u;
|
||||
private const float Height = 6f;
|
||||
|
||||
[Fact]
|
||||
public void RetryPending_ReoffersAPreviouslyDeclinedHeadUntilTheSinkAccepts()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
Assert.NotEqual(0UL, runtime.Generation.Value);
|
||||
|
||||
CommitLandblockCollision(runtime, Landblock);
|
||||
RuntimeEntityRecord record = CreateRemoteRecord(runtime, 0x70004001u);
|
||||
AttachBody(runtime, record, Cell);
|
||||
|
||||
RuntimeEntityPlacementToken token = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative);
|
||||
Assert.True(token.IsValid);
|
||||
RuntimeSetPositionMoverPreparationStatus status = runtime.EntityObjects
|
||||
.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
PhysicsSetPositionFlags.Teleport | PhysicsSetPositionFlags.Slide,
|
||||
new UnusedCollisionSource(),
|
||||
gameTime: 10d,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
|
||||
var sink = new DecliningThenAcceptingSink();
|
||||
var events = new NoOpEventRoute();
|
||||
var route = new HeadlessSessionEventRoute(events, runtime, sink);
|
||||
|
||||
// Attach's own subscribe-time retry (retryPendingOnSubscribe: true)
|
||||
// is the ONLY chance the receipt gets today — the sink is still
|
||||
// declining, so it must remain unacknowledged.
|
||||
route.Attach();
|
||||
Assert.Equal(1, sink.CallCount);
|
||||
Assert.True(
|
||||
runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out _));
|
||||
|
||||
// The sink starts accepting (mirrors a landblock finishing streaming
|
||||
// in) — but without a SECOND RetryPending call nothing re-offers the
|
||||
// head. This is the exact gap N3 closes.
|
||||
sink.Accept = true;
|
||||
bool retried = route.RetryPending();
|
||||
|
||||
Assert.True(retried);
|
||||
Assert.Equal(2, sink.CallCount);
|
||||
Assert.False(
|
||||
runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out _));
|
||||
|
||||
route.Dispose();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B5(c) review fix: <see cref="HeadlessSessionEventRoute.RetryPending"/>
|
||||
/// must refuse once Runtime's generation has moved past the one this
|
||||
/// route attached under — mirroring the graphical host's
|
||||
/// <c>RuntimePlacementProjectionRetrySlot</c>, which already refuses a
|
||||
/// stale-generation callback the same way (<c>BindOwned</c>/
|
||||
/// <c>RetryPending</c>'s own guard). Before this fix headless
|
||||
/// dereferenced its subscription directly with no equivalent latch, so a
|
||||
/// route left live across a generation change (a reconnect race window)
|
||||
/// could still fire a callback against a retired generation.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void RetryPending_RefusesOnceRuntimeGenerationHasMovedPastAttach()
|
||||
{
|
||||
using StartedRuntime started = StartRuntime();
|
||||
GameRuntime runtime = started.Runtime;
|
||||
|
||||
CommitLandblockCollision(runtime, Landblock);
|
||||
RuntimeEntityRecord record = CreateRemoteRecord(runtime, 0x70004002u);
|
||||
AttachBody(runtime, record, Cell);
|
||||
|
||||
RuntimeEntityPlacementToken token = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative);
|
||||
Assert.True(token.IsValid);
|
||||
RuntimeSetPositionMoverPreparationStatus status = runtime.EntityObjects
|
||||
.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
PhysicsSetPositionFlags.Teleport | PhysicsSetPositionFlags.Slide,
|
||||
new UnusedCollisionSource(),
|
||||
gameTime: 10d,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
|
||||
var sink = new DecliningThenAcceptingSink();
|
||||
var events = new NoOpEventRoute();
|
||||
var route = new HeadlessSessionEventRoute(events, runtime, sink);
|
||||
route.Attach();
|
||||
Assert.Equal(1, sink.CallCount);
|
||||
|
||||
sink.Accept = true;
|
||||
RuntimeGenerationToken attachedGeneration = runtime.Generation;
|
||||
RuntimeTeardownAcknowledgement stopped =
|
||||
started.Live.Stop(attachedGeneration);
|
||||
Assert.True(stopped.IsComplete);
|
||||
Assert.NotEqual(attachedGeneration, runtime.Generation);
|
||||
|
||||
// The route is STILL live here (never Disposed) — exactly the shape
|
||||
// a reconnect race could leave it in for one host-tick window before
|
||||
// the owner swaps in the replacement route.
|
||||
bool retried = route.RetryPending();
|
||||
|
||||
Assert.False(retried);
|
||||
// The stale-generation refusal must short-circuit BEFORE ever
|
||||
// touching the subscription — the sink's call count must not move.
|
||||
Assert.Equal(1, sink.CallCount);
|
||||
|
||||
route.Dispose();
|
||||
}
|
||||
|
||||
// ── Fixture (mirrors RuntimeAcceptedPositionDriveControllerTests) ──────
|
||||
|
||||
private sealed class StartedRuntime : IDisposable
|
||||
{
|
||||
internal required GameRuntime Runtime { get; init; }
|
||||
internal required LiveSessionHost Live { get; init; }
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_ = Live.Stop(Runtime.Generation);
|
||||
Runtime.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static StartedRuntime StartRuntime()
|
||||
{
|
||||
var operations = new FixtureGameplayOperations();
|
||||
var sessionOperations = new FixtureSessionOperations();
|
||||
var runtime = new GameRuntime(new GameRuntimeDependencies(
|
||||
operations, operations, operations, operations,
|
||||
SessionOperations: sessionOperations));
|
||||
var resetHost = new FixtureResetHost();
|
||||
var options = new LiveSessionConnectOptions(
|
||||
true, "127.0.0.1", 9000, "account", "password");
|
||||
var live = new LiveSessionHost(
|
||||
runtime.Session,
|
||||
new LiveSessionHostBindings(
|
||||
new LiveSessionRoutingFactories(
|
||||
_ => new NoOpEventRoute(),
|
||||
_ => new NoOpCommandRoute()),
|
||||
generation => runtime.ResetGeneration(generation, resetHost),
|
||||
new LiveSessionSelectionBindings(
|
||||
id => runtime.PlayerIdentity.ServerGuid = id,
|
||||
_ => { },
|
||||
runtime.CommunicationOwner.Chat.SetLocalPlayerGuid,
|
||||
_ => { },
|
||||
_ => { },
|
||||
runtime.ActionOwner.Combat.Clear),
|
||||
new LiveSessionEnteredWorldBindings(
|
||||
_ => { }, () => { }, () => { }, _ => { }, () => { }),
|
||||
(_, _, _) => { },
|
||||
() => { }),
|
||||
options);
|
||||
LiveSessionStartResult startResult = live.Start(options);
|
||||
Assert.Equal(LiveSessionStartStatus.Connected, startResult.Status);
|
||||
Assert.NotEqual(0UL, runtime.Generation.Value);
|
||||
return new StartedRuntime { Runtime = runtime, Live = live };
|
||||
}
|
||||
|
||||
private static void CommitLandblockCollision(
|
||||
GameRuntime runtime, uint landblockId)
|
||||
{
|
||||
var heights = new byte[81];
|
||||
Array.Fill(heights, (byte)Height);
|
||||
var heightTable = new float[256];
|
||||
for (int index = 0; index < heightTable.Length; index++)
|
||||
heightTable[index] = index;
|
||||
runtime.EntityObjects.Physics.ObserveLocalWorldFrame(
|
||||
landblockId | 0x0001u, teleportAdvanced: false);
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
landblockId, 1UL);
|
||||
runtime.EntityObjects.Physics.Engine.AddLandblock(
|
||||
landblockId,
|
||||
new TerrainSurface(heights, heightTable),
|
||||
Array.Empty<CellSurface>(),
|
||||
Array.Empty<PortalPlane>(),
|
||||
worldOffsetX: 0f,
|
||||
worldOffsetY: 0f);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblockId, 1UL, ready: true);
|
||||
}
|
||||
|
||||
private static RuntimeEntityRecord CreateRemoteRecord(
|
||||
GameRuntime runtime, uint guid)
|
||||
{
|
||||
RuntimeEntityRecord record = runtime.EntityObjects.RegisterEntity(
|
||||
new WorldSession.EntitySpawn(
|
||||
Guid: guid,
|
||||
Position: new CreateObject.ServerPosition(
|
||||
Cell, 10f, 10f, Height, 1f, 0f, 0f, 0f),
|
||||
SetupTableId: null,
|
||||
AnimPartChanges: Array.Empty<CreateObject.AnimPartChange>(),
|
||||
TextureChanges: Array.Empty<CreateObject.TextureChange>(),
|
||||
SubPalettes: Array.Empty<CreateObject.SubPaletteSwap>(),
|
||||
BasePaletteId: null,
|
||||
ObjScale: null,
|
||||
Name: "remote",
|
||||
ItemType: null,
|
||||
MotionState: null,
|
||||
MotionTableId: 0x09000001u))
|
||||
.Canonical!;
|
||||
runtime.EntityObjects.Entities.SetFinalPhysicsState(
|
||||
record, PhysicsStateFlags.Gravity);
|
||||
return record;
|
||||
}
|
||||
|
||||
private static void AttachBody(
|
||||
GameRuntime runtime, RuntimeEntityRecord record, uint cellId)
|
||||
{
|
||||
runtime.EntityObjects.Entities.SetFullCell(
|
||||
record, cellId, (cellId & 0xFFFF0000u) | 0xFFFFu);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(10f, 10f, Height),
|
||||
Orientation = Quaternion.Identity,
|
||||
LastUpdateTime = 1d,
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
body.SnapToCell(cellId, body.Position, body.Position);
|
||||
runtime.EntityObjects.Entities.SetPhysicsBody(record, body);
|
||||
record.ObjectClock.Activate();
|
||||
runtime.EntityObjects.Physics.AcknowledgeSpatialProjection(
|
||||
record, spatial: true);
|
||||
}
|
||||
|
||||
private sealed class DecliningThenAcceptingSink
|
||||
: IRuntimePlacementProjectionSink
|
||||
{
|
||||
internal int CallCount { get; private set; }
|
||||
internal bool Accept { get; set; }
|
||||
|
||||
public bool TryApply(in RuntimePlacementProjectionSnapshot projection)
|
||||
{
|
||||
CallCount++;
|
||||
return Accept;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoOpEventRoute : ILiveSessionEventRouting
|
||||
{
|
||||
public void Attach()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoOpCommandRoute : ILiveSessionCommandRouting
|
||||
{
|
||||
public void Activate()
|
||||
{
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureSessionOperations : ILiveSessionOperations
|
||||
{
|
||||
public IPEndPoint ResolveEndpoint(string host, int port) =>
|
||||
new(IPAddress.Loopback, port);
|
||||
|
||||
public WorldSession CreateSession(IPEndPoint endpoint) =>
|
||||
new(endpoint, new FixtureTransport());
|
||||
|
||||
public void Connect(WorldSession session, string user, string password)
|
||||
{
|
||||
}
|
||||
|
||||
public CharacterList.Parsed GetCharacters(WorldSession session) =>
|
||||
new(
|
||||
0u,
|
||||
[new CharacterList.Character(PlayerGuid, "Direct", 0u)],
|
||||
[],
|
||||
11,
|
||||
"account",
|
||||
true,
|
||||
true);
|
||||
|
||||
public void EnterWorld(WorldSession session, int activeCharacterIndex)
|
||||
{
|
||||
}
|
||||
|
||||
public void Tick(WorldSession session)
|
||||
{
|
||||
}
|
||||
|
||||
public void DisposeSession(WorldSession session) => session.Dispose();
|
||||
}
|
||||
|
||||
private sealed class FixtureTransport : IWorldSessionTransport
|
||||
{
|
||||
public void Send(ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
}
|
||||
|
||||
public void Send(IPEndPoint remote, ReadOnlySpan<byte> datagram)
|
||||
{
|
||||
}
|
||||
|
||||
public int Receive(
|
||||
Span<byte> destination, TimeSpan timeout, out IPEndPoint? from)
|
||||
{
|
||||
from = null;
|
||||
return -1;
|
||||
}
|
||||
|
||||
public ValueTask<NetReceiveResult> ReceiveAsync(
|
||||
Memory<byte> destination, CancellationToken cancellationToken) =>
|
||||
ValueTask.FromException<NetReceiveResult>(
|
||||
new OperationCanceledException(cancellationToken));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FixtureResetHost : IRuntimeGenerationResetHost
|
||||
{
|
||||
public void RetireEntityProjection(RuntimeEntityRecord entity)
|
||||
{
|
||||
}
|
||||
|
||||
public void DrainEntityProjectionBoundary()
|
||||
{
|
||||
}
|
||||
|
||||
public void CompleteEntityProjectionRetirement()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class UnusedCollisionSource : IPreparedCollisionSource
|
||||
{
|
||||
public PreparedAssetPresence ProbeCollision(
|
||||
PakAssetType type, uint sourceFileId) =>
|
||||
PreparedAssetPresence.Available;
|
||||
|
||||
public PreparedCollisionReadResult<FlatSetupCollision> ReadSetupCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
PreparedCollisionReadResult<FlatSetupCollision>.Missing;
|
||||
|
||||
public PreparedCollisionReadResult<FlatGfxObjCollisionAsset> ReadGfxObjCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatCellStructureCollisionAsset> ReadCellStructureCollision(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionReadResult<FlatEnvCellTopology> ReadEnvCellTopology(
|
||||
uint sourceFileId, CancellationToken cancellationToken = default) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
public PreparedCollisionSourceStats CollisionStats => default;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -225,6 +225,125 @@ public sealed class HeadlessSessionHostTests
|
|||
Assert.True(host.Runtime.CaptureOwnership().IsConverged);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// B5(a) review fix: <see cref="HeadlessSessionEventRouteRetryPendingTests"/>
|
||||
/// proved the underlying re-offer MECHANISM works, but hand-constructed
|
||||
/// <see cref="HeadlessSessionEventRoute"/> directly and called
|
||||
/// <c>route.RetryPending()</c> itself — it never touches
|
||||
/// <see cref="HeadlessSessionHost.Tick"/>'s own
|
||||
/// <c>_eventRoute?.RetryPending()</c> call. This test drives <c>Tick</c>
|
||||
/// itself (via the <c>placementSinkOverride</c> test seam added for this
|
||||
/// fix, mirroring the existing <c>policyOverride</c> parameter) so a
|
||||
/// regression that deletes or reorders that exact line would fail HERE,
|
||||
/// not just in the lower-level subscription test.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void TickRetriesAPreviouslyDeclinedPlacementThroughTheRealEventRoute()
|
||||
{
|
||||
const uint remote = 0x70004301u;
|
||||
const uint landblock = 0xA9B40000u;
|
||||
const uint cell = landblock | 0x0001u;
|
||||
const float height = 6f;
|
||||
|
||||
var operations = new FixtureSessionOperations();
|
||||
using var credential = new HeadlessCredentialSecret(
|
||||
"fixture",
|
||||
"password");
|
||||
var sink = new DecliningThenAcceptingPlacementSink();
|
||||
using var host = new HeadlessSessionHost(
|
||||
Descriptor(),
|
||||
credential,
|
||||
new HeadlessDiagnosticWriter(TextWriter.Null),
|
||||
operations,
|
||||
placementSinkOverride: sink);
|
||||
GameRuntime runtime = host.Runtime;
|
||||
Assert.Equal(
|
||||
RuntimeSessionStartStatus.Connected,
|
||||
host.Start().Status);
|
||||
|
||||
runtime.EntityObjects.Physics.ObserveLocalWorldFrame(
|
||||
cell, teleportAdvanced: false);
|
||||
runtime.EntityObjects.Physics.SetPosition.BeginCollisionGeneration(
|
||||
landblock, 1UL);
|
||||
AddFlatLandblock(runtime.EntityObjects.Physics.Engine);
|
||||
runtime.EntityObjects.Physics.SetPosition.CommitCollisionGeneration(
|
||||
landblock, 1UL, ready: true);
|
||||
|
||||
RuntimeEntityRecord record = runtime.EntityObjects
|
||||
.RegisterEntity(Spawn(remote, cell))
|
||||
.Canonical!;
|
||||
runtime.EntityObjects.Entities.SetFinalPhysicsState(
|
||||
record, PhysicsStateFlags.Gravity);
|
||||
runtime.EntityObjects.Entities.SetFullCell(
|
||||
record, cell, landblock);
|
||||
var body = new PhysicsBody
|
||||
{
|
||||
Position = new Vector3(10f, 10f, height),
|
||||
Orientation = Quaternion.Identity,
|
||||
LastUpdateTime = 1d,
|
||||
State = PhysicsStateFlags.Gravity,
|
||||
TransientState = TransientStateFlags.Active,
|
||||
};
|
||||
body.SnapToCell(cell, body.Position, body.Position);
|
||||
runtime.EntityObjects.Entities.SetPhysicsBody(record, body);
|
||||
record.ObjectClock.Activate();
|
||||
runtime.EntityObjects.Physics.AcknowledgeSpatialProjection(
|
||||
record, spatial: true);
|
||||
|
||||
RuntimeEntityPlacementToken token = runtime.EntityObjects.Physics
|
||||
.SetPosition.TryBeginExclusiveAuthoredPlacement(
|
||||
record,
|
||||
record.PositionAuthorityVersion,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative);
|
||||
Assert.True(token.IsValid);
|
||||
RuntimeSetPositionMoverPreparationStatus status = runtime.EntityObjects
|
||||
.Physics.SetPosition.TryPrepareAndSubmitAuthoredPlacement(
|
||||
record,
|
||||
token,
|
||||
RuntimeSetPositionOperationKind.RemoteAuthoritative,
|
||||
PhysicsSetPositionFlags.Teleport | PhysicsSetPositionFlags.Slide,
|
||||
new LoadedSetupCollisionSource(),
|
||||
gameTime: runtime.Clock.SimulationTimeSeconds,
|
||||
out RuntimeSetPositionOutcome outcome,
|
||||
resolveWorldOffsetFromRuntimeFrame: true);
|
||||
Assert.Equal(RuntimeSetPositionMoverPreparationStatus.Prepared, status);
|
||||
Assert.Equal(
|
||||
RuntimeSetPositionStatus.CommittedHostAcknowledgementPending,
|
||||
outcome.Status);
|
||||
|
||||
// The production HeadlessSessionEventRoute's subscription attached
|
||||
// during host.Start() already observed this Place synchronously —
|
||||
// the fake sink is still declining, so it must remain unacknowledged.
|
||||
Assert.Equal(1, sink.CallCount);
|
||||
Assert.True(
|
||||
runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out _));
|
||||
|
||||
// The sink starts accepting (mirrors a landblock finishing streaming
|
||||
// in) — driving ONE real host tick is what must re-offer the head,
|
||||
// through Tick's own wiring, not a hand-built route.
|
||||
sink.Accept = true;
|
||||
host.Tick(0.015d);
|
||||
|
||||
Assert.Equal(2, sink.CallCount);
|
||||
Assert.False(
|
||||
runtime.EntityObjects.Physics.SetPosition.TryPeekProjection(
|
||||
out _));
|
||||
}
|
||||
|
||||
private sealed class DecliningThenAcceptingPlacementSink
|
||||
: IRuntimePlacementProjectionSink
|
||||
{
|
||||
internal int CallCount { get; private set; }
|
||||
internal bool Accept { get; set; }
|
||||
|
||||
public bool TryApply(in RuntimePlacementProjectionSnapshot projection)
|
||||
{
|
||||
CallCount++;
|
||||
return Accept;
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void WorldProjectionHydratesCanonicalMovementAndTeleportState()
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue