Skip to content

feat(portal): switch servers by walking or flying through portals - #326

Merged
TheMeinerLP merged 23 commits into
mainfrom
feat/lobby-portals
Sep 29, 2026
Merged

TheMeinerLP merged 23 commits into
mainfrom
feat/lobby-portals

Conversation

@TheMeinerLP

Copy link
Copy Markdown
Contributor

Summary

Players can now switch servers by walking or flying (elytra) through a portal. Portals are map data: a portals list in each world's map.json, so season worlds carry their own. Two shapes are supported:

  • box: axis-aligned, block-inclusive min/max
  • disc: center, radius, normal, any orientation (e.g. the big leaf ring)

The target is a CloudNet task. Each portal can have an optional permission.

"portals": [
  {"id": "survival", "task": "Survival", "shape": {"type": "box", "min": {"x": 10, "y": 64, "z": 10}, "max": {"x": 14, "y": 68, "z": 11}}},
  {"id": "elytra-ring", "task": "ElytraRace", "permission": null, "shape": {"type": "disc", "center": {"x": 0.5, "y": 72, "z": 40.5}, "radius": 5.5, "normal": {"x": 0, "y": 0, "z": 1}}}
]

How

  • Core types in core: PortalShape (Box, Disc), Portal, PortalValidator, LobbyPortals.
  • Detection is a segment test from the old to the new position. The disc math is adapted from Voyager's RingPass, the box uses the slab method. A fast elytra step through a thin ring is therefore caught.
  • A chunk-column index keeps the per-move cost to a couple of hash lookups.
  • A portal triggers once on entering, followed by a 3 s cooldown after an actual delivery.
  • LobbyMap.portals plus PortalGsonAdapter read and write the data. Invalid portals abort startup and name the world, the portal and the reason.
  • The LobbyPortals bean is provided by runtime.
  • New column features/portal (priority 900) in both the cloudnet and local variants.

Why this shape

This supersedes #222, which used an operator portals.json, Coris cuboids only, and FeatureGate / bridge ServiceAvailability from the unmerged #216. Portals belong to the world, so they live with the map. There is no reachability pre-check: CloudNet's task routing handles it, like the navigator.

Known limits

  • A single move longer than ~16 blocks that meets a portal only in a middle chunk column is missed.
  • Detection uses the feet point.
  • Map changes need a lobby restart.

Follow-up

Setup commands (/setup portal ... with a guided flow, live preview and tab completion) come with the separate change setup-portal-command.

Tests

  • DiscTest (including an 8-block elytra step over a 1-block disc), BoxTest, PortalValidatorTest
  • PortalGsonAdapterTest, LobbyMapPortalsTest, MapProviderPortalTest, LobbyPortalsBeanTest
  • PortalIndexTest, PortalTriggerTest, PortalModuleTest, PortalModuleLeakTest
  • VariantStartTest in both apps
  • ./gradlew build is green.

Not tested: a live CloudNet transfer.

OpenSpec

openspec/changes/lobby-portals

https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH

GsonFileHandler.load (aves 1.16.x) catches only IOException; a
JsonParseException of a type adapter escapes instead of yielding
Optional.empty(). MapProvider can therefore keep using the handler and
abort the start on an unreadable portal (design D6, task 2.1).

Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH
LobbyMap gets a null-safe portals() accessor and Builder.portals(...);
every copy path keeps them. PortalGsonAdapter reads and writes the
box/disc contract, MapProvider validates through PortalValidator and
aborts the start naming world, portal id and reason.

Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH
Index portals by chunk column and fire on entry only, with a 3 s
cooldown per player and a permission check that never starts a cooldown.

Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH
PortalModule listens to PlayerMoveEvent at priority 900 and delivers via
Deliver; disconnects clear the per-player state.

Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH
…ants

Stub the mocked MapProvider with an active lobby because the portal column reads its portals at start.

Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH
Messages read 'portal #<index>' instead of 'portal null', as design D6 asks.

Claude-Session: https://claude.ai/code/session_01A7aAe2E6wAEbi6jjnyPTWH
@TheMeinerLP
TheMeinerLP requested a review from a team as a code owner September 29, 2026 17:36
@github-actions

Copy link
Copy Markdown
Contributor

Test results

  303 files    303 suites   18m 9s ⏱️
  574 tests   573 ✅ 1 💤 0 ❌
1 722 runs  1 719 ✅ 3 💤 0 ❌

Results for commit caa9c21.

@TheMeinerLP
TheMeinerLP merged commit 07a9ddc into main Sep 29, 2026
8 checks passed
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