feat: switch servers by walking into a portal (Stage 7) - #222
feat: switch servers by walking into a portal (Stage 7)#222TheMeinerLP wants to merge 7 commits into
Conversation
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
Portals need a region model. Coris is the org's shape library for Minestom (OLF-L2-04), so its CuboidShape does the containment, the corner normalisation and the block-inclusive bounds instead of a bounding box written in Titan. aonyx-bom does not carry coris, so the version is pinned in the catalog with the reason, as OLF-L1-02 requires. Coris publishes mycelium-bom as its only runtime dependency; its constraints would move Minestom a release past the version aonyx-bom pins for Titan, which breaks tests at runtime, so the bom is excluded and only the jar comes in. junit-params comes along because the region edge cases are a table, not eight copies of one test.
A portal is a region, a target server and, optionally, the navigator feature guarding the same destination - all of it in portals.json, loaded the way app.json is loaded, so adding one is an edit and not a release. Validation is per entry and drops rather than throws: an entry that names an unknown feature, an unknown target type, no target or a region without volume is refused with a log line naming it, and the portals around it stay live. Dropping is the safe direction - a portal that does not exist sends nobody anywhere, while a portal built from a half-understood entry would, and an unresolvable feature name would otherwise leave a gated destination ungated. The lookup is the part that has to be cheap: it runs on every movement packet of every player. Portals are bucketed by chunk column once at load time, so a movement costs one hash lookup and, in the common case, no geometry at all - instead of testing every portal's bounds every time. Regions may be no larger than 4096 chunk columns; past that a coordinate is mistyped rather than large.
Deliver hands a switch request to the CloudNet bridge and returns; a request for a task nobody is running looks exactly like a successful one. Anything that has to keep the player when the switch cannot happen - a portal, above all - has to ask beforehand. ServiceAvailability is that question, and it follows the route ServerConnector already takes: the answer lives in the bridge extension classloader, where the CloudNet service list is visible, and reaches the application through a JDK-typed holder. The bridge extension installs an implementation over the CloudNet service list, counting a service as reachable when it is RUNNING and connected. With nothing installed, nothing is reachable. That is not pessimism: the missing bridge that leaves this holder empty leaves TitanServerConnector empty too, so a delivery would be dropped on the floor either way.
…7.03, 7.04) Walking into a portal now hands the player to the configured server over the same Deliver route the navigator uses. What the navigator decides with a click, the portal decides with a step - and by the same rules: - Permission is the FeatureGate, not a second check. A portal names the navigator feature guarding the same destination, and the gate answers for the portal exactly as it answers for the navigator entry (US-7.04). - An unreachable target keeps the player where they are and says so. Reachability is asked before the switch, and a delivery that throws is reported to the player and logged with its stack trace rather than swallowed (US-7.03). Re-entry is the part a naive version gets wrong. PlayerMoveEvent fires several times a second, and a player who was refused - or who came back - is standing inside the region while it does. So a portal fires on the transition into a region, not on being in one: a transient tag latches the portal the player is inside and is cleared only when they leave it, and a configurable cooldown debounces stepping out and straight back in. One entry produces one delivery and, at most, one message.
Marks US-7.01 to US-7.04 as implemented and notes the two decisions a reader would otherwise have to reconstruct from the code: the chunk-column index behind the movement check, and that a portal fires on entering rather than on standing inside. The reachability answer is qualified rather than claimed outright: it comes from the bridge extension, so without a loaded bridge no target counts as reachable.
The portal sources were written against the old location of TitanFeatures and were merged in without a conflict, so nothing pointed out that :common:compileJava no longer resolves the import. Also records two things a reader would otherwise have to work out: - Portal#region() exposes Coris' Shape, which Coris marks experimental, so a Coris minor bump can move Titan's own API. Kept as is - the library is in-house - but named, with the way out if that changes. - The portal cooldown is per player, so a portal next to one that just refused stays quiet for the window. The switch is still refused either way; only the explanation is missing. Making it per portal needs an expiry per portal id per player and the eviction that comes with it.
Both default messages open with <prefix>, which is not a MiniMessage standard tag: it resolves only because TitanMiniMessageImpl is registered as the MiniMessage.Provider through META-INF/services. Lose that registration - an unmerged service file in a shaded jar, a provider that stops being loaded - and the player reads the tag instead of the server name, with nothing else failing. Verified the test earns its place by removing the service file: it fails.
Review findings addressed — 158 tests greenThe merge blocker was real, and worse than reportedRebased onto the current The
|
706447b to
f65f342
Compare
Stacked on #216 — it reuses the same
FeatureGateinstance the navigator uses. Implements spec Stage 7 — US-7.01 … US-7.04. 148 tests green.The spec marks this stage
Couldand optional.Coris fitted, and it kept bounding-box code out of Titan
CuboidShapehandles containment, corner normalisation and block-inclusive bounds. Two consequences it forced rather than allowed: a portal needs two distinct corners (zero volume is rejected at load with a message naming the fix), andintersectis block-inclusive — which is the Multiverse-Portals convention anyway.Room/Floor/registries were not used: a portal is a shape plus a target, while floors and doors are dungeon structure looked up by key, not spatially.Coris dragged in the same trap Stage 1 hit. It publishes
mycelium-bomas its only runtime dependency, and those constraints moved Minestom a release past Titan's pin — which silently brokeSitHelperTestwith aNoSuchMethodError. Fixed by excluding that BOM from the coris dependency. Verified::common:runtimeClasspathresolvesnet.minestom:minestom:2026.06.05-26.1.2, the versionaonyx-bomprescribes, so NFR-001 holds on this branch.Coris is
@ApiStatus.Experimental.The movement check
PlayerMoveEventfires constantly, so a loop over every portal testing every bound would runplayers × portalstimes per packet.Portals are bucketed by chunk column into an immutable
Map<Long, List<Portal>>built once at load. A movement packschunkX/chunkZinto onelong, does a single hash lookup, and only then tests geometry — on the portals in that 16×16 column, normally none. Y is deliberately not a key dimension: portals rarely share a column but often overlap in height, so a third dimension would only spread the same few portals thinner. Immutable, so the lookup needs no lock.A portal may span at most 4096 chunk columns; past that the coordinate is a typo and the entry is dropped.
Re-trigger loops
Edge-triggered latch plus debounce.
Tags.PORTAL_INSIDEholds the id of the portal the player currently occupies, so a portal fires only on the transition into it — and the tag is set before the gate and reachability are evaluated, so a refusal does not retry on every subsequent movement packet.Tags.PORTAL_COOLDOWN(default 3 s, clock-injected) covers stepping out and straight back in.Both are
Tag.Transient, so they die with the session and never reach player NBT.What the tests actually pin
PortalIndexTest(15) includes a@CsvSourcetable for block-inclusivity — min corner inside,4.999inside,5.0and-0.001outside on each axis — plus a miss inside the same chunk column (bucket hits, geometry rejects), a portal across a chunk border found from both sides, and negative vs positive coordinates not colliding in the packed key.PortalServiceTest(14, real Minestom player through Cyano, realFeatureGateover an in-memory Togglz repo): an unreachable target leavesplayer.getPosition()unchanged and sends exactly oneSystemChatPacket; a throwing delivery does the same; a stage-internalportal denies without the permission and, once granted, the same gate call that would show the navigator entry returns true and the portal delivers. Re-trigger: 20 further movements inside yield one delivery and one message total, quick re-entry isCOOLING_DOWN, and after the clock advances it delivers again — a debounce, not a ban.Bad entries are dropped per entry with a log line rather than failing the file, so one typo costs its own portal.
Worth knowing
:bridgecompiles against the real driver API but could not be exercised at runtime here. Without a loaded bridge nothing counts as reachable — stated in the Javadoc and the spec note.