From 7947d7ad0a4f4f426cb66b8355df5e703759f554 Mon Sep 17 00:00:00 2001 From: Erik Date: Sat, 13 Jun 2026 17:06:33 +0200 Subject: [PATCH] feat(G.3a): TeleportArrivalController hold-until-hydration state machine (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../World/TeleportArrivalController.cs | 103 +++++++++++++++ .../World/TeleportArrivalControllerTests.cs | 123 ++++++++++++++++++ 2 files changed, 226 insertions(+) create mode 100644 src/AcDream.App/World/TeleportArrivalController.cs create mode 100644 tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs new file mode 100644 index 00000000..d6538533 --- /dev/null +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -0,0 +1,103 @@ +using System; +using System.Numerics; + +namespace AcDream.App.World; + +/// Verdict from the per-frame readiness probe for a held teleport arrival. +public enum ArrivalReadiness +{ + /// Destination not yet hydrated; keep holding. + NotReady, + + /// Destination terrain + cell are ready; place now. + Ready, + + /// The claim can never hydrate (e.g. an indoor cell id outside the dat's + /// LandBlockInfo.NumCells range). Place immediately via the caller's safety-net + /// demote rather than hold forever. + Impossible, +} + +/// Lifecycle of a single teleport arrival. +public enum TeleportArrivalPhase { Idle, Holding } + +/// +/// G.3a (#133) — holds a teleport arrival in portal space until the destination +/// dungeon landblock/cell has streamed in, THEN places the player. Replaces the +/// unconditional snap in GameWindow.OnLivePositionUpdated that resolved the +/// arrival against the resident (old) landblocks before the destination hydrated +/// and landed the player in ocean. +/// +/// The controller is pure: readiness and placement are injected delegates, +/// so it carries no GL / dat / network dependency and is fully unit-testable. The +/// player stays input-frozen while this is Holding because the GameWindow keeps +/// PlayerState.PortalSpace until the placement delegate flips it back to +/// InWorld. +/// +/// The timeout is a coarse frame count (not wall-clock) so the controller +/// needs no external clock; it is a loud safety net for a never-hydrating +/// destination, not a precise deadline. +/// +public sealed class TeleportArrivalController +{ + /// ~10 s at 60 fps. Coarse safety net for a destination that never streams. + public const int DefaultMaxHoldFrames = 600; + + private readonly Func _readiness; + private readonly Action _place; // (destPos, destCell, forced) + private readonly int _maxHoldFrames; + + private Vector3 _destPos; + private uint _destCell; + private int _heldFrames; + + public TeleportArrivalPhase Phase { get; private set; } = TeleportArrivalPhase.Idle; + + public TeleportArrivalController( + Func readiness, + Action place, + int maxHoldFrames = DefaultMaxHoldFrames) + { + _readiness = readiness ?? throw new ArgumentNullException(nameof(readiness)); + _place = place ?? throw new ArgumentNullException(nameof(place)); + _maxHoldFrames = maxHoldFrames; + } + + /// Begin holding for a teleport arrival. Called from OnLivePositionUpdated + /// AFTER the streaming origin has been recentered on the destination landblock. + /// Re-calling with a fresh server position resets the hold (server-authoritative). + public void BeginArrival(Vector3 destPos, uint destCell) + { + _destPos = destPos; + _destCell = destCell; + _heldFrames = 0; + Phase = TeleportArrivalPhase.Holding; + } + + /// Per-frame: evaluate readiness and place when ready / impossible / timed out. + /// No-op when Idle. + public void Tick() + { + if (Phase != TeleportArrivalPhase.Holding) return; + _heldFrames++; + + ArrivalReadiness verdict = _readiness(_destPos, _destCell); + if (verdict == ArrivalReadiness.Ready) + { + Place(forced: false); + return; + } + + if (verdict == ArrivalReadiness.Impossible || _heldFrames >= _maxHoldFrames) + { + Place(forced: true); + } + // else NotReady -> keep holding + } + + private void Place(bool forced) + { + _place(_destPos, _destCell, forced); + Phase = TeleportArrivalPhase.Idle; + } +} diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs new file mode 100644 index 00000000..fbf8727f --- /dev/null +++ b/tests/AcDream.App.Tests/World/TeleportArrivalControllerTests.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using System.Numerics; +using AcDream.App.World; +using Xunit; + +namespace AcDream.App.Tests.World; + +public class TeleportArrivalControllerTests +{ + // Records each Place(destPos, destCell, forced) call. + private sealed record PlaceCall(Vector3 Pos, uint Cell, bool Forced); + + private static TeleportArrivalController Make( + ArrivalReadiness verdict, + List placed, + int maxHoldFrames = TeleportArrivalController.DefaultMaxHoldFrames) + => new( + readiness: (_, _) => verdict, + place: (pos, cell, forced) => placed.Add(new PlaceCall(pos, cell, forced)), + maxHoldFrames: maxHoldFrames); + + [Fact] + public void BeginArrival_EntersHolding() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed); + + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_WhenIdle_IsNoOp() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + + c.Tick(); // never began + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_NotReady_KeepsHolding_DoesNotPlace() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed); + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + c.Tick(); + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + Assert.Empty(placed); + } + + [Fact] + public void Tick_Ready_PlacesUnforced_AndIdles() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + c.BeginArrival(new Vector3(30, -60, 6.005f), 0x01250126u); + + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + var call = Assert.Single(placed); + Assert.False(call.Forced); + Assert.Equal(0x01250126u, call.Cell); + Assert.Equal(new Vector3(30, -60, 6.005f), call.Pos); + } + + [Fact] + public void Tick_Impossible_PlacesForced_AndIdles() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Impossible, placed); + c.BeginArrival(new Vector3(1, 2, 3), 0x0125FF00u); + + c.Tick(); + + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + var call = Assert.Single(placed); + Assert.True(call.Forced); + } + + [Fact] + public void Tick_Timeout_PlacesForced_AfterMaxHoldFrames() + { + var placed = new List(); + var c = Make(ArrivalReadiness.NotReady, placed, maxHoldFrames: 3); + c.BeginArrival(new Vector3(1, 2, 3), 0x01250126u); + + c.Tick(); // 1 + c.Tick(); // 2 + Assert.Empty(placed); + Assert.Equal(TeleportArrivalPhase.Holding, c.Phase); + + c.Tick(); // 3 -> timeout + + var call = Assert.Single(placed); + Assert.True(call.Forced); + Assert.Equal(TeleportArrivalPhase.Idle, c.Phase); + } + + [Fact] + public void BeginArrival_AfterPlace_ReArms() + { + var placed = new List(); + var c = Make(ArrivalReadiness.Ready, placed); + + c.BeginArrival(new Vector3(1, 0, 0), 0x01250126u); + c.Tick(); // places #1, idle + c.BeginArrival(new Vector3(2, 0, 0), 0x01250127u); + c.Tick(); // places #2, idle + + Assert.Equal(2, placed.Count); + Assert.Equal(0x01250127u, placed[1].Cell); + } +}