Skip to content

feat: switch servers by walking into a portal (Stage 7) - #222

Open
TheMeinerLP wants to merge 7 commits into
feat/feature-gatefrom
feat/portals
Open

feat: switch servers by walking into a portal (Stage 7)#222
TheMeinerLP wants to merge 7 commits into
feat/feature-gatefrom
feat/portals

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Stacked on #216 — it reuses the same FeatureGate instance the navigator uses. Implements spec Stage 7 — US-7.01 … US-7.04. 148 tests green.

The spec marks this stage Could and optional.

Coris fitted, and it kept bounding-box code out of Titan

CuboidShape handles 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), and intersect is 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-bom as its only runtime dependency, and those constraints moved Minestom a release past Titan's pin — which silently broke SitHelperTest with a NoSuchMethodError. Fixed by excluding that BOM from the coris dependency. Verified: :common:runtimeClasspath resolves net.minestom:minestom:2026.06.05-26.1.2, the version aonyx-bom prescribes, so NFR-001 holds on this branch.

Coris is @ApiStatus.Experimental.

The movement check

PlayerMoveEvent fires constantly, so a loop over every portal testing every bound would run players × portals times per packet.

Portals are bucketed by chunk column into an immutable Map<Long, List<Portal>> built once at load. A movement packs chunkX/chunkZ into one long, 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_INSIDE holds 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 @CsvSource table for block-inclusivity — min corner inside, 4.999 inside, 5.0 and -0.001 outside 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, real FeatureGate over an in-memory Togglz repo): an unreachable target leaves player.getPosition() unchanged and sends exactly one SystemChatPacket; a throwing delivery does the same; a stage-internal portal 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 is COOLING_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

  • Portals are not instance-scoped. Titan runs one lobby instance today, but if seasonal lobbies ever run several at once, portal coordinates would apply to all of them.
  • Config is read at boot; there is no reload command (the spec does not ask for one).
  • The CloudNet reachability implementation in :bridge compiles 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.

@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner August 28, 2026 09:31
@github-actions

This comment has been minimized.

@github-actions

This comment has been minimized.

@github-actions

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.
@TheMeinerLP

Copy link
Copy Markdown
Contributor Author

Review findings addressed — 158 tests green

The merge blocker was real, and worse than reported

Rebased onto the current feat/feature-gate. The stale common/utils/TitanFeatures import was in six files, not the three the review found — the three sources plus three test files that would have failed :common:compileTestJava the same silent way. Git merges new files without conflict, so nothing would have flagged any of them.

The <prefix> finding was wrong, and the agent proved it rather than fixing a non-bug

The review claimed MiniMessage.miniMessage() has no prefix tag and players would read a literal <prefix>. Probed before changing anything:

PROBE-IMPL=net.kyori.adventure.text.minimessage.MiniMessageImpl
PROBE-OUT=Titan hi          // from deserialize("<prefix> <red>hi")

MiniMessage.miniMessage() is not a plain default instance — Adventure seeds it via Services.service(MiniMessage.Provider.class), and common/src/main/resources/META-INF/services/net.kyori.adventure.text.minimessage.MiniMessage$Provider registers TitanMiniMessageImpl. I verified that file myself. So TitanMiniMessageImpl already is the reuse path, OLF-L2-04 is satisfied, and it is the same pattern MapCommand, AppCommand and AppConfigImpl use. Building a separate MiniMessage instance in PortalService would have been the deviation.

A regression test was added anyway, because the guarantee is real but invisible and fails silently: rendersTheShippedMessages drives both shipped templates through the real refusal paths, captures the actual SystemChatPacket, and asserts no <prefix>, no <red>, and a Titan prefix. Its worth was checked — deleting the service file makes it fail with the prefix tag was not resolved. That is the failure mode that matters: an unmerged service file in the shaded jar breaks the message with nothing else going red.

Injection layer settled: ext() is correct

From CloudNet driver-api 4.0.0-RC17 (InjectionLayerProvider.java):

InjectionLayerProvider.ext = child(boot, "ext").asUncloseable();

ext is a child injector of boot, so every binding boot has — CloudServiceProvider included — is visible through it. The Javadoc settles intent: boot() is "all bindings which were used during the current runtime component initialization"; ext() is "for all kinds of external component injection (like plugins)". A Minestom extension is exactly that.

Both resolve today, so feat/build-servers using boot() is not a correctness bug — but ext() is the documented contract. #219 is being routed to ext() to settle the collision.

PORTAL_COOLDOWN: fix attempted, reverted, documented

The per-portal version was built and its own test failed, exposing that the cheap fix does not work: one tag pair only remembers the most recently tried portal, so A→B→A re-fires A and reintroduces the message spam the debounce exists to prevent — just across two portals instead of one.

A correct per-portal cooldown needs an expiry per portal id per player with eviction on quit, which is a lifecycle change well past "minor". Reverted, and Tags.PORTAL_COOLDOWN now carries a "Known limitation (US-7.03)" note naming the behaviour and what a real fix costs. User impact is bounded: the switch is refused either way, only the explanation goes missing.

Coris exposure recorded

Portal.java's class Javadoc now names it: Shape is @ApiStatus.Experimental and sits in the public signature of Portal.region(), so a Coris minor bump can move Titan's own API. Accepted — in-house library, same team — with the exit named: contains(Point) is the only thing portal code asks of it, so wrapping it later is cheap.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant