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; /// /// C4 route 4b-1 (N3): HeadlessSessionEventRoute.Attach constructs its /// RuntimePlacementProjectionSubscription with /// retryPendingOnSubscribe: true, and that was the ONLY /// RetryPending 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 /// (RuntimePlacementProjectionSubscription.OnPlacement's doc comment), /// but nothing headless did ever asked again. This is the focused proof that /// — the method /// HeadlessSessionHost.Tick now calls every tick, immediately after /// HeadlessSessionWorldProjection.PumpFirstEntry — actually re-offers a /// declined head. Without a SECOND call, the declined receipt sits forever. /// /// /// Uses the SAME lightweight LiveSessionHost + no-op event/command /// route fixture as /// RuntimeAcceptedPositionDriveControllerTests.StartRuntime — it /// produces a genuine nonzero GameRuntime.Generation (required: /// RuntimePlacementProjectionChannel.IsCurrent 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. /// HeadlessSessionHost.Start would also work generation-wise, but its /// own internal route always uses the real /// HeadlessRuntimePlacementProjectionSink, which would consume-and- /// acknowledge this test's synthetic Place before this test's own route ever /// subscribed. /// /// 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(); } /// /// B5(c) review fix: /// must refuse once Runtime's generation has moved past the one this /// route attached under — mirroring the graphical host's /// RuntimePlacementProjectionRetrySlot, which already refuses a /// stale-generation callback the same way (BindOwned/ /// RetryPending'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. /// [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(), Array.Empty(), 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(), TextureChanges: Array.Empty(), SubPalettes: Array.Empty(), 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 datagram) { } public void Send(IPEndPoint remote, ReadOnlySpan datagram) { } public int Receive( Span destination, TimeSpan timeout, out IPEndPoint? from) { from = null; return -1; } public ValueTask ReceiveAsync( Memory destination, CancellationToken cancellationToken) => ValueTask.FromException( 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 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 ReadSetupCollision( uint sourceFileId, CancellationToken cancellationToken = default) => PreparedCollisionReadResult.Missing; public PreparedCollisionReadResult ReadGfxObjCollision( uint sourceFileId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public PreparedCollisionReadResult ReadCellStructureCollision( uint sourceFileId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public PreparedCollisionReadResult ReadEnvCellTopology( uint sourceFileId, CancellationToken cancellationToken = default) => throw new NotSupportedException(); public PreparedCollisionSourceStats CollisionStats => default; public void Dispose() { } } }