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));
}
}