diff --git a/src/AcDream.App/World/TeleportArrivalController.cs b/src/AcDream.App/World/TeleportArrivalController.cs index eeddf858..7263299b 100644 --- a/src/AcDream.App/World/TeleportArrivalController.cs +++ b/src/AcDream.App/World/TeleportArrivalController.cs @@ -116,29 +116,29 @@ public static class TeleportArrivalRules /// The destination cell can never hydrate. /// Destination is an indoor cell (cell index ≥ 0x0100). /// For an indoor destination, the EnvCell floor has hydrated. + /// For an outdoor destination, the physics landblock is loaded + /// ( returned true for the + /// destination landblock id). Ignored for indoor destinations. public static ArrivalReadiness Decide( bool claimUnhydratable, bool indoor, - bool indoorCellReady) + bool indoorCellReady, + bool outdoorReady) { if (claimUnhydratable) return ArrivalReadiness.Impossible; // Indoor (sealed dungeon / building interior): gate on the EnvCell floor hydrating, - // exactly as #135. The dungeon-IN path pre-collapses + waits for the cell struct, and - // its placement validates the specific claimed cell (the indoor resolve is cell-keyed, - // not the grid-snap), so it is unaffected by the overlap that broke teleport-OUT. + // exactly as #135. Unaffected by the outdoor-landblock gate. if (indoor) return indoorCellReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; - // Outdoor: place IMMEDIATELY on the server-authoritative position. #145/#138 — holding - // for the destination to stream in is futile: streaming does NOT progress while the - // player is held in PortalSpace (the destination only loads once placement flips the - // state to InWorld). The stale source center landblock is dropped at recenter (see - // GameWindow.OnLivePositionUpdated), so the arrival resolve falls through to the - // server-authoritative cell/position (Resolve NO-LANDBLOCK verbatim) and the player is - // placed grounded there; streaming then loads the destination and the per-frame resolve - // grounds onto it. This trusts the server's teleport position — retail-faithful. - return ArrivalReadiness.Ready; + // Outdoor (#145 §3.4): hold until the physics landblock is registered. + // AddLandblock is atomic (terrain + cells + portals in one write — PhysicsEngine.cs:70), + // so ContainsKey implies the collision mesh is present and resolvable. The hold is + // self-resolving: streaming progresses unconditionally during the hold because the + // streaming controller runs before TeleportArrivalController.Tick (GameWindow.cs:7420-7551). + // The 10 s timeout safety-net (_maxHoldFrames) remains active. + return outdoorReady ? ArrivalReadiness.Ready : ArrivalReadiness.NotReady; } } diff --git a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs index eb0b12ea..a9602a9d 100644 --- a/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs +++ b/tests/AcDream.App.Tests/World/TeleportArrivalRulesTests.cs @@ -4,49 +4,63 @@ using Xunit; namespace AcDream.App.Tests.World; /// -/// The teleport-arrival readiness decision (#145/#138). -/// -/// Unhydratable claim → place immediately via the caller's safety-net (Impossible). -/// Indoor (sealed dungeon / building interior) → hold until the EnvCell floor hydrates. -/// Outdoor → place IMMEDIATELY on the server-authoritative position. Holding is futile — -/// streaming does not progress while the player is held in PortalSpace, so the -/// destination can't stream in during a hold. The stale source landblock is dropped at -/// recenter (GameWindow.OnLivePositionUpdated), so the resolve falls through to the -/// server cell (Resolve NO-LANDBLOCK verbatim) until the destination streams in. -/// +/// Full truth-table for TeleportArrivalRules.Decide after the §3.4 readiness-gate change. /// public class TeleportArrivalRulesTests { [Fact] - public void Unhydratable_IsImpossible() + public void Unhydratable_IsImpossible_Outdoor() { Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: false, indoorCellReady: false)); - // …even for an indoor claim. + TeleportArrivalRules.Decide( + claimUnhydratable: true, indoor: false, + indoorCellReady: false, outdoorReady: false)); + } + + [Fact] + public void Unhydratable_IsImpossible_Indoor() + { Assert.Equal(ArrivalReadiness.Impossible, - TeleportArrivalRules.Decide(claimUnhydratable: true, indoor: true, indoorCellReady: false)); + TeleportArrivalRules.Decide( + claimUnhydratable: true, indoor: true, + indoorCellReady: true, outdoorReady: false)); } [Fact] public void Indoor_CellReady_IsReady() { Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: true)); + TeleportArrivalRules.Decide( + claimUnhydratable: false, indoor: true, + indoorCellReady: true, outdoorReady: false)); } [Fact] - public void Indoor_CellNotReady_HoldsNotReady() + public void Indoor_CellNotReady_IsNotReady() { Assert.Equal(ArrivalReadiness.NotReady, - TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: true, indoorCellReady: false)); + TeleportArrivalRules.Decide( + claimUnhydratable: false, indoor: true, + indoorCellReady: false, outdoorReady: false)); } [Fact] - public void Outdoor_PlacesImmediately() + public void Outdoor_LandblockLoaded_IsReady() { - // #145: never hold an outdoor arrival — place at once on the server position. The - // indoorCellReady flag is irrelevant for an outdoor destination. Assert.Equal(ArrivalReadiness.Ready, - TeleportArrivalRules.Decide(claimUnhydratable: false, indoor: false, indoorCellReady: false)); + TeleportArrivalRules.Decide( + claimUnhydratable: false, indoor: false, + indoorCellReady: false, outdoorReady: true)); + } + + [Fact] + public void Outdoor_LandblockNotLoaded_IsNotReady() + { + // §3.4: the #145 residual — outdoor arrival on a not-yet-streamed landblock + // must hold. Previously returned Ready immediately. + Assert.Equal(ArrivalReadiness.NotReady, + TeleportArrivalRules.Decide( + claimUnhydratable: false, indoor: false, + indoorCellReady: false, outdoorReady: false)); } }