diff --git a/app/src/main/java/net/onelitefeather/titan/app/Titan.java b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
index 95959cd..3e6e2fc 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
@@ -42,6 +42,8 @@
import net.onelitefeather.titan.common.event.EntityDismountEvent;
import net.onelitefeather.titan.common.helper.BlockHandlerHelper;
import net.onelitefeather.titan.common.map.MapProvider;
+import net.onelitefeather.titan.common.portal.PortalConfigProvider;
+import net.onelitefeather.titan.common.portal.PortalService;
import net.onelitefeather.titan.common.utils.Cancelable;
import java.nio.file.Path;
@@ -57,6 +59,7 @@ public final class Titan {
private final AppConfigProvider appConfigProvider;
private final NavigationHelper navigationHelper;
private final FeatureGate featureGate;
+ private final PortalService portalService;
public Titan() {
this(Clock.system(SeasonWindowActivationStrategy.DEFAULT_ZONE), SeasonWindowActivationStrategy.DEFAULT_ZONE);
@@ -79,6 +82,10 @@ public Titan(Clock clock, ZoneId zone) {
this.appConfigProvider = AppConfigProvider.create(this.path);
this.featureGate = FeatureGate.create(LuckPermsFeatureAudience.create(), clock, zone);
this.navigationHelper = NavigationHelper.instance(this.deliver, this.featureGate);
+ // Portals share the navigator's gate and the navigator's delivery route on purpose: a
+ // destination that is gated in the navigator is gated in the portal by the same check
+ // (US-7.04), and there is one way out of the lobby, not two.
+ this.portalService = PortalService.create(PortalConfigProvider.create(this.path).getPortalConfig(), this.deliver, this.featureGate);
}
public void initialize() {
@@ -130,6 +137,7 @@ private void initListeners() {
this.eventNode.addListener(PlayerRespawnEvent.class, new RespawnListener(this.navigationHelper));
this.eventNode.addListener(PlayerMoveEvent.class, new PlayerMoveListener(this.appConfigProvider.getAppConfig(), this.mapProvider.getActiveLobby()));
+ this.eventNode.addListener(PlayerMoveEvent.class, new PortalListener(this.portalService));
this.eventNode.addListener(AsyncPlayerConfigurationEvent.class, new PlayerConfigurationListener(this.mapProvider));
this.eventNode.addListener(PlayerSpawnEvent.class, new PlayerSpawnListener(
diff --git a/app/src/main/java/net/onelitefeather/titan/app/listener/PortalListener.java b/app/src/main/java/net/onelitefeather/titan/app/listener/PortalListener.java
new file mode 100644
index 0000000..894860c
--- /dev/null
+++ b/app/src/main/java/net/onelitefeather/titan/app/listener/PortalListener.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.listener;
+
+import net.minestom.server.event.player.PlayerMoveEvent;
+import net.onelitefeather.titan.common.portal.PortalService;
+
+import java.util.function.Consumer;
+
+/**
+ * Feeds player movement to {@link PortalService}.
+ *
+ *
Deliberately empty of logic. This runs on every movement packet of every player, and every
+ * decision it could make here - is this a portal, did they just enter it, may they use it - is one
+ * the service already makes, in that order and with a cheap first step.
+ *
+ *
It reads the event's new position rather than the player's current one: the player has not
+ * been moved yet when the event fires, so the current position is where they came from.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class PortalListener implements Consumer {
+
+ private final PortalService portalService;
+
+ /**
+ * Creates the listener.
+ *
+ * @param portalService the service deciding what a movement into a portal does
+ */
+ public PortalListener(PortalService portalService) {
+ this.portalService = portalService;
+ }
+
+ @Override
+ public void accept(PlayerMoveEvent event) {
+ this.portalService.handleMove(event.getPlayer(), event.getNewPosition());
+ }
+}
diff --git a/bridge/src/main/java/net/onelitefeather/titan/bridge/TitanBridgePermissionExtension.java b/bridge/src/main/java/net/onelitefeather/titan/bridge/TitanBridgePermissionExtension.java
index 1876b3c..bb9da6d 100644
--- a/bridge/src/main/java/net/onelitefeather/titan/bridge/TitanBridgePermissionExtension.java
+++ b/bridge/src/main/java/net/onelitefeather/titan/bridge/TitanBridgePermissionExtension.java
@@ -16,7 +16,11 @@
*/
package net.onelitefeather.titan.bridge;
+import eu.cloudnetservice.driver.inject.InjectionLayer;
+import eu.cloudnetservice.driver.provider.CloudServiceProvider;
import eu.cloudnetservice.driver.registry.ServiceRegistry;
+import eu.cloudnetservice.driver.service.ServiceInfoSnapshot;
+import eu.cloudnetservice.driver.service.ServiceLifeCycle;
import eu.cloudnetservice.modules.bridge.impl.platform.minestom.MinestomPermissionChecker;
import eu.cloudnetservice.modules.bridge.player.PlayerManager;
import eu.cloudnetservice.modules.bridge.player.executor.PlayerExecutor;
@@ -24,7 +28,9 @@
import java.util.UUID;
import net.minestom.server.extensions.Extension;
import net.onelitefeather.titan.common.deliver.ServerConnector;
+import net.onelitefeather.titan.common.deliver.ServiceAvailability;
import net.onelitefeather.titan.common.deliver.TitanServerConnector;
+import net.onelitefeather.titan.common.deliver.TitanServiceAvailability;
import net.onelitefeather.titan.common.permission.TitanPermissionBridge;
/**
@@ -45,6 +51,9 @@
* Server switching: installs a {@link ServerConnector} (used by
* {@code MessageChannelDeliver}) that connects players through the bridge
* {@link PlayerManager} / {@link PlayerExecutor}.
+ * Reachability: installs a {@link ServiceAvailability} over the CloudNet service
+ * list. Portals ask it before switching a player, because the switch itself reports nothing
+ * back (US-7.03).
*
*/
public final class TitanBridgePermissionExtension extends Extension {
@@ -71,6 +80,47 @@ public void connectToServer(UUID playerId, String serviceName) {
}
}
});
+ installServiceAvailability();
+ }
+
+ private static void installServiceAvailability() {
+ TitanServiceAvailability.setAvailability(new ServiceAvailability() {
+
+ @Override
+ public boolean isTaskReachable(String taskName) {
+ CloudServiceProvider provider = serviceProvider();
+ if (provider == null) {
+ return false;
+ }
+ return provider.servicesByTask(taskName).stream().anyMatch(TitanBridgePermissionExtension::joinable);
+ }
+
+ @Override
+ public boolean isServerReachable(String serviceName) {
+ CloudServiceProvider provider = serviceProvider();
+ if (provider == null) {
+ return false;
+ }
+ return joinable(provider.serviceByName(serviceName));
+ }
+ });
+ }
+
+ /**
+ * Resolves the service list lazily and never lets a resolution failure escape: this runs on a
+ * player walking into a portal, and an exception there would leave them with neither a switch
+ * nor a message.
+ */
+ private static CloudServiceProvider serviceProvider() {
+ try {
+ return InjectionLayer.ext().instance(CloudServiceProvider.class);
+ } catch (RuntimeException exception) {
+ return null;
+ }
+ }
+
+ private static boolean joinable(ServiceInfoSnapshot snapshot) {
+ return snapshot != null && snapshot.lifeCycle() == ServiceLifeCycle.RUNNING && snapshot.connected();
}
private static PlayerExecutor playerExecutor(UUID playerId) {
diff --git a/common/build.gradle.kts b/common/build.gradle.kts
index 4b83669..9805599 100644
--- a/common/build.gradle.kts
+++ b/common/build.gradle.kts
@@ -9,6 +9,12 @@ dependencies {
implementation(libs.minestom)
implementation(libs.togglz)
implementation(libs.aves)
+ // Coris ships no runtime dependency of its own except mycelium-bom, whose
+ // constraints would outrank the aonyx-bom line Titan pins (it moves Minestom a
+ // release forward). Keep the bom out; the jar is all we want.
+ implementation(libs.coris) {
+ exclude(group = "net.onelitefeather", module = "mycelium-bom")
+ }
implementation(libs.adventure.minimessage)
// No CloudNet here anymore: anything touching the CloudNet bridge lives in the
@@ -19,7 +25,11 @@ dependencies {
testImplementation(libs.minestom)
testImplementation(libs.cyano)
testImplementation(libs.aves)
+ testImplementation(libs.coris) {
+ exclude(group = "net.onelitefeather", module = "mycelium-bom")
+ }
testImplementation(libs.junit.api)
+ testImplementation(libs.junit.params)
testImplementation(libs.junit.platform.launcher)
testRuntimeOnly(libs.junit.engine)
}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/deliver/ServiceAvailability.java b/common/src/main/java/net/onelitefeather/titan/common/deliver/ServiceAvailability.java
new file mode 100644
index 0000000..ac50a88
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/deliver/ServiceAvailability.java
@@ -0,0 +1,93 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.deliver;
+
+/**
+ * Answers whether a delivery target can be reached right now. This is the question
+ * {@link net.onelitefeather.titan.api.deliver.Deliver} cannot answer: it hands a switch request
+ * to the CloudNet bridge and returns, so a request for a task nobody is running is
+ * indistinguishable
+ * from a successful one. A portal has to know beforehand, because a failed switch leaves the player
+ * standing in the portal (US-7.03).
+ *
+ * Implemented in the CloudNet bridge extension realm (where the service list is visible) and
+ * invoked from the application through {@link TitanServiceAvailability}, exactly like
+ * {@link ServerConnector}. Only JDK types cross that classloader boundary.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public interface ServiceAvailability {
+
+ /**
+ * Returns an availability that reports everything as reachable. Only for tests and for
+ * deployments that deliberately want a portal to try regardless.
+ *
+ * @return an availability that never refuses
+ */
+ static ServiceAvailability alwaysReachable() {
+ return new ServiceAvailability() {
+
+ @Override
+ public boolean isTaskReachable(String taskName) {
+ return true;
+ }
+
+ @Override
+ public boolean isServerReachable(String serviceName) {
+ return true;
+ }
+ };
+ }
+
+ /**
+ * Returns an availability that reports nothing as reachable.
+ *
+ * @return an availability that always refuses
+ */
+ static ServiceAvailability neverReachable() {
+ return new ServiceAvailability() {
+
+ @Override
+ public boolean isTaskReachable(String taskName) {
+ return false;
+ }
+
+ @Override
+ public boolean isServerReachable(String serviceName) {
+ return false;
+ }
+ };
+ }
+
+ /**
+ * Checks whether at least one running service of the given task can take a player.
+ *
+ * @param taskName the CloudNet task a portal points at
+ * @return whether the task currently has a reachable service
+ */
+ boolean isTaskReachable(String taskName);
+
+ /**
+ * Checks whether the named service is running and connected.
+ *
+ * @param serviceName the CloudNet service a portal points at
+ * @return whether that service is currently reachable
+ */
+ boolean isServerReachable(String serviceName);
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/deliver/TitanServiceAvailability.java b/common/src/main/java/net/onelitefeather/titan/common/deliver/TitanServiceAvailability.java
new file mode 100644
index 0000000..cdddf09
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/deliver/TitanServiceAvailability.java
@@ -0,0 +1,84 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.deliver;
+
+/**
+ * Cross-classloader holder for {@link ServiceAvailability}, the twin of
+ * {@link TitanServerConnector}.
+ *
+ *
The CloudNet service list lives in the bridge extension classloader; the application cannot
+ * reference those classes. This holder lives on the shared application classloader, the bridge
+ * extension installs the real source, and the application asks through JDK types only.
+ *
+ *
Nothing installed means nothing is reachable. That is not a pessimistic guess: the
+ * same missing bridge that leaves this holder empty also leaves {@link TitanServerConnector}
+ * empty, so a delivery would be dropped on the floor. Reporting "unreachable" makes a portal say
+ * so instead of appearing to work (US-7.03).
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class TitanServiceAvailability {
+
+ private static volatile ServiceAvailability availability;
+
+ private TitanServiceAvailability() {
+ }
+
+ /**
+ * Installs the availability source. Called by the bridge extension once the bridge is up.
+ *
+ * @param serviceAvailability the source backed by the CloudNet service list
+ */
+ public static void setAvailability(ServiceAvailability serviceAvailability) {
+ availability = serviceAvailability;
+ }
+
+ /**
+ * Returns a view on whatever source is installed at the time of each call, so a portal built
+ * before the bridge extension loaded still sees the bridge answers afterwards.
+ *
+ * @return a live view on the installed availability source
+ */
+ public static ServiceAvailability holder() {
+ return new ServiceAvailability() {
+
+ @Override
+ public boolean isTaskReachable(String taskName) {
+ ServiceAvailability current = availability;
+ return current != null && current.isTaskReachable(taskName);
+ }
+
+ @Override
+ public boolean isServerReachable(String serviceName) {
+ ServiceAvailability current = availability;
+ return current != null && current.isServerReachable(serviceName);
+ }
+ };
+ }
+
+ /**
+ * Returns whether a source has been installed at all. Used for logging: "no portal target is
+ * reachable" reads very differently from "the bridge is not there yet".
+ *
+ * @return whether the bridge installed an availability source
+ */
+ public static boolean isInstalled() {
+ return availability != null;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java b/common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java
new file mode 100644
index 0000000..231a501
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.minestom.server.coordinate.Point;
+import net.onelitefeather.coris.shape.Shape;
+import net.onelitefeather.deliver.DeliverType;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Nullable;
+
+/**
+ * A validated portal: a region of the lobby, the server it hands players to, and the navigator
+ * feature that decides who may use it.
+ *
+ *
This is the shape a portal has after {@link PortalDefinition#resolve()} accepted it. A
+ * definition an operator mistyped never becomes a {@link Portal}, so nothing downstream has to
+ * re-check the configuration.
+ *
+ *
The region is a Coris {@link Shape}: containment, normalisation of the two corners and the
+ * block-inclusive bounds all come from the org's shape library rather than from a bounding box
+ * written here (OLF-L2-04).
+ *
+ *
Known exposure: Coris marks {@code Shape}
+ * {@link org.jetbrains.annotations.ApiStatus.Experimental},
+ * and it sits in the public signature of {@link #region()}. A Coris minor bump that changes
+ * {@code Shape} therefore changes Titan's own API with it. Accepted for now, because Coris is an
+ * in-house library released by the same team; if that stops holding, hide the region behind a
+ * Titan-owned type - {@link #contains(Point)} is already the only thing portal code asks of it.
+ *
+ * @param id the operator-facing id, used in logs and to recognise re-entry
+ * @param region the area a player has to be standing in
+ * @param type whether {@link #target()} names a CloudNet task or a single service
+ * @param target the task or service a player is delivered to
+ * @param feature the navigator feature guarding the same destination, or {@code null} when the
+ * destination is open to everyone
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public record Portal(String id, Shape region, DeliverType type, String target,
+ @Nullable TitanFeatures feature) {
+
+ /**
+ * Checks whether the given position lies inside this portal.
+ *
+ * @param position the position to test
+ * @return whether the position is inside the region
+ */
+ @Contract(pure = true)
+ public boolean contains(Point position) {
+ return this.region.intersect(position);
+ }
+
+ /**
+ * Returns whether using this portal is guarded by a feature.
+ *
+ * @return whether a feature has to admit the player first
+ */
+ @Contract(pure = true)
+ public boolean isGated() {
+ return this.feature != null;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java
new file mode 100644
index 0000000..ff5680b
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java
@@ -0,0 +1,103 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import java.util.List;
+
+/**
+ * Everything about portals that an operator can change without a code change (US-7.02): the
+ * portals themselves, the two refusal messages, and how long a player has to wait between portal
+ * attempts.
+ *
+ *
It follows the shape of {@link net.onelitefeather.titan.common.config.AppConfig} - a sealed
+ * interface with a package-private record behind it, loaded from JSON by a provider - minus the
+ * builder. {@code AppConfig} has one because {@code /app} edits it while the server runs; portals
+ * are edited in the file and picked up on load, so a builder would be an unused surface.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public sealed interface PortalConfig permits PortalConfigImpl {
+
+ /** Name of the file portals are read from, next to {@code app.json}. */
+ String PORTAL_FILE_NAME = "portals.json";
+
+ /**
+ * Returns the configuration used when no {@value #PORTAL_FILE_NAME} exists yet: no portals,
+ * and the default messages. Writing this file out is what shows an operator the format.
+ *
+ * @return the default configuration
+ */
+ static PortalConfig defaultConfig() {
+ return PortalConfigImpl.DEFAULT;
+ }
+
+ /**
+ * Creates a configuration directly, for tests and for callers that assemble portals in code.
+ *
+ * @param portals the portal entries
+ * @param retriggerCooldownMillis the cooldown between two portal attempts by the same player
+ * @param unreachableMessage MiniMessage shown when the target cannot take the player
+ * @param deniedMessage MiniMessage shown when the feature does not admit the player
+ * @return a configuration with those values
+ */
+ static PortalConfig of(List portals, long retriggerCooldownMillis, String unreachableMessage, String deniedMessage) {
+ return new PortalConfigImpl(List.copyOf(portals), retriggerCooldownMillis, unreachableMessage, deniedMessage);
+ }
+
+ /**
+ * The configured portals, still unvalidated - see {@link PortalDefinition#resolve()}.
+ *
+ * @return the portal entries as written in the file
+ */
+ List portals();
+
+ /**
+ * How long after a portal attempt the same player is ignored by every portal.
+ *
+ * This is a debounce, not the re-entry guard: standing still in a portal is already handled
+ * by the latch in {@link PortalService}. The cooldown covers the player who walks out and
+ * straight back in - without it, a portal whose target is down would repeat its message as
+ * fast as the player can step across the edge. The window covers the player rather than the
+ * portal, which also silences a neighbouring portal for its duration - see
+ * {@link net.onelitefeather.titan.common.utils.Tags#PORTAL_COOLDOWN}.
+ *
+ *
A value of {@code 0} - which is also what a file that omits the key deserialises to -
+ * turns the debounce off and leaves the latch as the only guard, which is enough to stop a
+ * standing player from re-triggering.
+ *
+ * @return the cooldown in milliseconds
+ */
+ long retriggerCooldownMillis();
+
+ /**
+ * MiniMessage template shown when the target server cannot take the player. Knows the tags
+ * {@code } and {@code }.
+ *
+ * @return the unreachable-target message
+ */
+ String unreachableMessage();
+
+ /**
+ * MiniMessage template shown when the feature guarding the destination does not admit the
+ * player. Knows the tags {@code } and {@code }.
+ *
+ * @return the denied message
+ */
+ String deniedMessage();
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigImpl.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigImpl.java
new file mode 100644
index 0000000..b15c146
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigImpl.java
@@ -0,0 +1,59 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import org.jetbrains.annotations.Nullable;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * The record behind {@link PortalConfig} and the type the JSON file deserialises to.
+ *
+ * The compact constructor repairs rather than rejects: a file that omits a key, or was written
+ * before a key existed, must still yield a usable configuration. Rejecting would take the whole
+ * file - including the portals that are fine - out of service over a missing message template.
+ *
+ * @param portals the portal entries as written
+ * @param retriggerCooldownMillis cooldown between two portal attempts by the same player
+ * @param unreachableMessage MiniMessage shown when the target cannot take the player
+ * @param deniedMessage MiniMessage shown when the feature does not admit the player
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+record PortalConfigImpl(List portals, long retriggerCooldownMillis,
+ String unreachableMessage,
+ String deniedMessage) implements PortalConfig {
+
+ static final long DEFAULT_COOLDOWN_MILLIS = 3000L;
+
+ static final String DEFAULT_UNREACHABLE_MESSAGE = " The server behind this portal is not available right now.";
+
+ static final String DEFAULT_DENIED_MESSAGE = " This portal is not open for you.";
+
+ static final PortalConfigImpl DEFAULT = new PortalConfigImpl(List.of(), DEFAULT_COOLDOWN_MILLIS, DEFAULT_UNREACHABLE_MESSAGE, DEFAULT_DENIED_MESSAGE);
+
+ PortalConfigImpl(@Nullable List portals, long retriggerCooldownMillis, @Nullable String unreachableMessage, @Nullable String deniedMessage) {
+ // A JSON array may hold a literal null; filtering keeps that from turning into an NPE
+ // inside the copy and costs the operator only the one broken entry.
+ this.portals = portals == null ? List.of() : portals.stream().filter(Objects::nonNull).toList();
+ this.retriggerCooldownMillis = Math.max(0L, retriggerCooldownMillis);
+ this.unreachableMessage = unreachableMessage == null || unreachableMessage.isBlank() ? DEFAULT_UNREACHABLE_MESSAGE : unreachableMessage;
+ this.deniedMessage = deniedMessage == null || deniedMessage.isBlank() ? DEFAULT_DENIED_MESSAGE : deniedMessage;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigProvider.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigProvider.java
new file mode 100644
index 0000000..1e2d09d
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigProvider.java
@@ -0,0 +1,114 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import com.google.gson.Gson;
+import com.google.gson.reflect.TypeToken;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.theevilreaper.aves.file.ModernGsonFileHandler;
+import net.theevilreaper.aves.file.gson.PositionGsonAdapter;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.nio.file.Path;
+import java.util.Optional;
+
+/**
+ * Loads {@value PortalConfig#PORTAL_FILE_NAME} from the server directory, the same way
+ * {@link net.onelitefeather.titan.common.config.AppConfigProvider} loads {@code app.json}: a Gson
+ * with the aves position adapter registered, an aves file handler, and a default written out when
+ * the file does not exist yet (US-7.02).
+ *
+ * It uses the non-deprecated {@link ModernGsonFileHandler} rather than the {@code
+ * GsonFileHandler} the older providers still use, so this file does not have to be touched again
+ * when that class is removed.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class PortalConfigProvider {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PortalConfigProvider.class);
+
+ private static final TypeToken TYPE = TypeToken.get(PortalConfigImpl.class);
+
+ private final Path file;
+ private final ModernGsonFileHandler fileHandler;
+ private PortalConfig portalConfig = PortalConfig.defaultConfig();
+
+ private PortalConfigProvider(Path path) {
+ this.file = path.resolve(PortalConfig.PORTAL_FILE_NAME);
+ var typeAdapter = new PositionGsonAdapter();
+ Gson gson = new Gson().newBuilder().setPrettyPrinting().registerTypeAdapter(Pos.class, typeAdapter).registerTypeAdapter(Vec.class, typeAdapter).create();
+ this.fileHandler = new ModernGsonFileHandler(gson);
+ this.loadConfig();
+ }
+
+ /**
+ * Creates a provider reading from the given directory.
+ *
+ * @param path the directory holding {@value PortalConfig#PORTAL_FILE_NAME}
+ * @return the provider, with the configuration already loaded
+ */
+ public static PortalConfigProvider create(Path path) {
+ return new PortalConfigProvider(path);
+ }
+
+ /**
+ * Returns the loaded configuration.
+ *
+ * @return the portal configuration
+ */
+ public PortalConfig getPortalConfig() {
+ return this.portalConfig;
+ }
+
+ /**
+ * Writes the given configuration to disk and reloads it.
+ *
+ * @param config the configuration to persist
+ */
+ public void saveConfig(PortalConfig config) {
+ this.fileHandler.save(this.file, (PortalConfigImpl) config, TYPE);
+ this.loadConfig();
+ }
+
+ private void loadConfig() {
+ Optional loaded;
+ try {
+ loaded = this.fileHandler.load(this.file, TYPE);
+ } catch (RuntimeException exception) {
+ // A malformed file must not take the lobby down, and it must not look like "no
+ // portals configured" either - that reads as an empty file rather than a broken one.
+ LOGGER.error("Unable to read {}; no portal will be active until the file parses", this.file, exception);
+ this.portalConfig = PortalConfig.defaultConfig();
+ return;
+ }
+ if (loaded.isEmpty()) {
+ this.portalConfig = PortalConfig.defaultConfig();
+ this.saveDefault();
+ return;
+ }
+ this.portalConfig = loaded.get();
+ }
+
+ private void saveDefault() {
+ this.fileHandler.save(this.file, PortalConfigImpl.DEFAULT, TYPE);
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalDefinition.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalDefinition.java
new file mode 100644
index 0000000..bce9ead
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalDefinition.java
@@ -0,0 +1,148 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.coris.shape.CuboidShape;
+import net.onelitefeather.deliver.DeliverType;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.Locale;
+import java.util.Optional;
+
+/**
+ * One portal exactly as it stands in {@code portals.json}. Every field is nullable and every
+ * field is a string or a plain vector, because this is what a hand-written file deserialises to -
+ * validation happens in {@link #resolve()}, not in the constructor, so one bad entry costs its own
+ * portal and not the whole file (US-7.02).
+ *
+ * An entry that cannot be resolved is dropped with a log line naming it. Dropping is the safe
+ * direction: a portal that does not exist sends nobody anywhere, while a portal built from a
+ * half-understood entry would.
+ *
+ * @param id operator-facing id, unique within the file
+ * @param type {@code task} (default) or {@code server}
+ * @param target the CloudNet task or service to deliver to
+ * @param feature name of the {@link TitanFeatures} constant guarding the same destination in the
+ * navigator, or {@code null} for a destination open to everyone
+ * @param min one corner of the region
+ * @param max the opposite corner; corners are normalised and the region is block-inclusive
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public record PortalDefinition(@Nullable String id, @Nullable String type, @Nullable String target,
+ @Nullable String feature, @Nullable Vec min, @Nullable Vec max) {
+
+ /**
+ * Upper bound on the chunk columns a single portal may span, guarding the index against a
+ * mistyped coordinate: {@value} columns is a 1024x1024 block footprint, far beyond any lobby
+ * portal, and a region larger than that is a typo rather than a portal.
+ */
+ static final int MAX_CHUNK_COLUMNS = 4096;
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PortalDefinition.class);
+
+ /**
+ * Validates this entry and turns it into a usable {@link Portal}.
+ *
+ * @return the portal, or an empty optional when the entry is unusable - in which case the
+ * reason has been logged
+ */
+ public Optional resolve() {
+ String portalId = this.id == null ? "" : this.id.trim();
+ if (portalId.isEmpty()) {
+ LOGGER.warn("Ignoring a portal without an id");
+ return Optional.empty();
+ }
+ if (this.target == null || this.target.isBlank()) {
+ LOGGER.warn("Ignoring portal '{}': it names no target server", portalId);
+ return Optional.empty();
+ }
+ if (this.min == null || this.max == null) {
+ LOGGER.warn("Ignoring portal '{}': its region needs both a min and a max corner", portalId);
+ return Optional.empty();
+ }
+ Optional deliverType = deliverType();
+ if (deliverType.isEmpty()) {
+ LOGGER.warn("Ignoring portal '{}': unknown target type '{}', expected 'task' or 'server'", portalId, this.type);
+ return Optional.empty();
+ }
+ TitanFeatures gate;
+ if (this.feature == null || this.feature.isBlank()) {
+ gate = null;
+ } else {
+ Optional resolved = feature(this.feature);
+ if (resolved.isEmpty()) {
+ LOGGER.warn("Ignoring portal '{}': unknown feature '{}'. A portal that names a feature nobody can resolve would be ungated, which is the opposite of what was configured", portalId, this.feature);
+ return Optional.empty();
+ }
+ gate = resolved.get();
+ }
+ CuboidShape region;
+ try {
+ region = new CuboidShape(this.min, this.max);
+ } catch (IllegalArgumentException exception) {
+ LOGGER.warn("Ignoring portal '{}': its two corners are identical, so the region has no volume. Coris needs two distinct corners; use {} and {} for a single block", portalId, this.min, this.max.add(1, 1, 1));
+ return Optional.empty();
+ }
+ if (chunkColumns(region) > MAX_CHUNK_COLUMNS) {
+ LOGGER.warn("Ignoring portal '{}': its region spans more than {} chunk columns, which is a mistyped coordinate rather than a portal", portalId, MAX_CHUNK_COLUMNS);
+ return Optional.empty();
+ }
+ return Optional.of(new Portal(portalId, region, deliverType.get(), this.target.trim(), gate));
+ }
+
+ private Optional deliverType() {
+ if (this.type == null || this.type.isBlank()) {
+ return Optional.of(DeliverType.TASK);
+ }
+ String normalized = this.type.trim().toUpperCase(Locale.ROOT);
+ for (DeliverType candidate : DeliverType.values()) {
+ if (candidate.name().equals(normalized)) {
+ return Optional.of(candidate);
+ }
+ }
+ return Optional.empty();
+ }
+
+ private static Optional feature(String name) {
+ String normalized = name.trim().toUpperCase(Locale.ROOT);
+ for (TitanFeatures candidate : TitanFeatures.values()) {
+ if (candidate.name().equals(normalized)) {
+ return Optional.of(candidate);
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Counts the chunk columns a region touches - the number of buckets it would occupy in
+ * {@link PortalIndex}.
+ */
+ private static long chunkColumns(CuboidShape region) {
+ Point from = region.min();
+ Point to = region.max();
+ long columnsX = (long) to.chunkX() - from.chunkX() + 1;
+ long columnsZ = (long) to.chunkZ() - from.chunkZ() + 1;
+ return columnsX * columnsZ;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalIndex.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalIndex.java
new file mode 100644
index 0000000..619abb9
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalIndex.java
@@ -0,0 +1,161 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.minestom.server.coordinate.Point;
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Nullable;
+import org.jetbrains.annotations.Unmodifiable;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Answers "which portal is this position in" without walking the portal list.
+ *
+ * The reason this class exists is the call site: {@code PlayerMoveEvent} fires for every
+ * movement packet of every player, several times a second each. Testing every portal's bounds on
+ * every one of those is work proportional to {@code players x portals} for an answer that is
+ * almost always "none".
+ *
+ *
So portals are bucketed by chunk column once, at load time. A lookup takes the chunk
+ * coordinates already carried by the position, packs them into one {@code long} key, and does a
+ * single {@link HashMap} lookup; only the portals sharing that column - normally none, and at
+ * worst the handful an operator deliberately put in the same 16x16 area - are asked whether they
+ * contain the point. Cost per movement is therefore one hash lookup and, in the common case, no
+ * geometry at all. Y is left to the shape: portals overlap in columns far more rarely than they
+ * overlap in height, and a third dimension in the key would only spread the same few portals over
+ * more buckets.
+ *
+ *
The index is immutable. Reloading the configuration builds a new one rather than mutating
+ * this one, which is what keeps the lookup free of locks on the movement path.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class PortalIndex {
+
+ private static final PortalIndex EMPTY = new PortalIndex(Map.of(), List.of());
+
+ private final Map> byChunkColumn;
+ private final List portals;
+
+ private PortalIndex(Map> byChunkColumn, List portals) {
+ this.byChunkColumn = byChunkColumn;
+ this.portals = portals;
+ }
+
+ /**
+ * Returns the index without any portal, whose lookup is a constant {@code null}.
+ *
+ * @return the empty index
+ */
+ @Contract(pure = true)
+ public static PortalIndex empty() {
+ return EMPTY;
+ }
+
+ /**
+ * Builds an index over the given portals. Every portal is registered in each chunk column its
+ * region touches; a portal spanning several columns is therefore found from any of them.
+ *
+ * @param portals the portals to index
+ * @return an index over those portals
+ */
+ public static PortalIndex of(Collection portals) {
+ if (portals.isEmpty()) {
+ return EMPTY;
+ }
+ Map> buckets = new HashMap<>();
+ for (Portal portal : portals) {
+ Point from = portal.region().min();
+ Point to = portal.region().max();
+ for (int chunkX = from.chunkX(); chunkX <= to.chunkX(); chunkX++) {
+ for (int chunkZ = from.chunkZ(); chunkZ <= to.chunkZ(); chunkZ++) {
+ buckets.computeIfAbsent(key(chunkX, chunkZ), ignored -> new ArrayList<>()).add(portal);
+ }
+ }
+ }
+ Map> frozen = new HashMap<>(buckets.size());
+ buckets.forEach((column, bucket) -> frozen.put(column, List.copyOf(bucket)));
+ return new PortalIndex(Map.copyOf(frozen), List.copyOf(portals));
+ }
+
+ /**
+ * Returns the portal containing the given position.
+ *
+ * @param position the position to look up, usually a player's new position
+ * @return the portal the position is inside, or {@code null} when it is inside none. When
+ * regions overlap, the first portal registered for the column wins - overlapping
+ * portals are a configuration mistake, not a supported layout.
+ */
+ @Contract(pure = true)
+ public @Nullable Portal portalAt(Point position) {
+ if (this.byChunkColumn.isEmpty()) {
+ return null;
+ }
+ List candidates = this.byChunkColumn.get(key(position.chunkX(), position.chunkZ()));
+ if (candidates == null) {
+ return null;
+ }
+ for (Portal portal : candidates) {
+ if (portal.contains(position)) {
+ return portal;
+ }
+ }
+ return null;
+ }
+
+ /**
+ * Returns every indexed portal, in configuration order.
+ *
+ * @return the indexed portals
+ */
+ @Contract(pure = true)
+ public @Unmodifiable List portals() {
+ return this.portals;
+ }
+
+ /**
+ * Returns whether this index holds no portal at all.
+ *
+ * @return whether the index is empty
+ */
+ @Contract(pure = true)
+ public boolean isEmpty() {
+ return this.portals.isEmpty();
+ }
+
+ /**
+ * Returns how many chunk columns hold at least one portal. Exposed for tests, which assert
+ * that the index really is sparse rather than one bucket holding everything.
+ *
+ * @return the number of occupied chunk columns
+ */
+ @Contract(pure = true)
+ public int occupiedColumns() {
+ return this.byChunkColumn.size();
+ }
+
+ private static long key(int chunkX, int chunkZ) {
+ return ((long) chunkX << 32) | (chunkZ & 0xFFFFFFFFL);
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalOutcome.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalOutcome.java
new file mode 100644
index 0000000..28d7918
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalOutcome.java
@@ -0,0 +1,66 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import org.jetbrains.annotations.Contract;
+
+/**
+ * What a single movement did at a portal. The listener ignores the value; it exists so the
+ * decision is observable - "nothing happened" has four different reasons, and a test that could
+ * only look at whether a player was delivered would not be able to tell them apart.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public enum PortalOutcome {
+
+ /** The position is not inside any portal. */
+ NO_PORTAL,
+
+ /**
+ * The player is inside the same portal they were already inside on the previous movement.
+ * This is the re-entry latch: a portal fires on entering, not on standing.
+ */
+ ALREADY_INSIDE,
+
+ /** The player entered a portal, but too soon after their last portal attempt. */
+ COOLING_DOWN,
+
+ /** The feature guarding the destination does not admit this player (US-7.04). */
+ DENIED_FEATURE,
+
+ /** No service behind the target could take the player, so nobody was sent (US-7.03). */
+ TARGET_UNREACHABLE,
+
+ /** The delivery was attempted and threw. The player stays where they are (US-7.03). */
+ DELIVERY_FAILED,
+
+ /** The player was handed to the target server (US-7.01). */
+ DELIVERED;
+
+ /**
+ * Returns whether this outcome means the player was told something. Used by tests to pin down
+ * that a refusal is never silent.
+ *
+ * @return whether the player receives a message for this outcome
+ */
+ @Contract(pure = true)
+ public boolean isReported() {
+ return this == DENIED_FEATURE || this == TARGET_UNREACHABLE || this == DELIVERY_FAILED;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java
new file mode 100644
index 0000000..0ade345
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java
@@ -0,0 +1,208 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.minimessage.MiniMessage;
+import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder;
+import net.minestom.server.coordinate.Point;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.deliver.DeliverComponent;
+import net.onelitefeather.deliver.DeliverType;
+import net.onelitefeather.titan.api.deliver.Deliver;
+import net.onelitefeather.titan.common.deliver.ServiceAvailability;
+import net.onelitefeather.titan.common.deliver.TitanServiceAvailability;
+import net.onelitefeather.titan.common.feature.FeatureGate;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import net.onelitefeather.titan.common.utils.Tags;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Clock;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * Decides what happens when a player moves into a portal, and is the only place that decides it.
+ *
+ * The order is fixed and each step can stop the delivery:
+ *
+ *
+ * - Where are they - {@link PortalIndex} answers, in one hash lookup, whether the new
+ * position is inside a portal at all. This runs on every movement, so it has to be the cheap
+ * step (US-7.01).
+ * - Did they just arrive - a portal fires on entering, not on standing inside. The
+ * latch is {@link Tags#PORTAL_INSIDE}, plus {@link Tags#PORTAL_COOLDOWN} as a debounce for
+ * stepping in and out. Without both, a player whose switch was refused would set the portal
+ * off again on their very next movement packet, several times a second.
+ * - May they - if the portal names a feature, {@link FeatureGate} decides, exactly as
+ * it decides for the navigator entry to the same destination. There is no second permission
+ * path (US-7.04).
+ * - Is anybody home - {@link ServiceAvailability} is asked before the switch, because
+ * {@link Deliver} cannot report a failure afterwards. An unreachable target leaves the player
+ * standing where they are, with a message (US-7.03).
+ *
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class PortalService {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(PortalService.class);
+
+ private final PortalIndex index;
+ private final Deliver deliver;
+ private final FeatureGate featureGate;
+ private final ServiceAvailability availability;
+ private final PortalConfig config;
+ private final Clock clock;
+
+ private PortalService(PortalIndex index, Deliver deliver, FeatureGate featureGate, ServiceAvailability availability, PortalConfig config, Clock clock) {
+ this.index = index;
+ this.deliver = deliver;
+ this.featureGate = featureGate;
+ this.availability = availability;
+ this.config = config;
+ this.clock = clock;
+ }
+
+ /**
+ * Creates a service over the given configuration, resolving and indexing its portals.
+ * Unusable entries are dropped with a log line and simply do not exist afterwards.
+ *
+ * @param config the portal configuration
+ * @param deliver the delivery route players are handed to
+ * @param featureGate the gate that also guards the navigator entries
+ * @param availability the source that knows whether a target can take a player
+ * @param clock the time source the cooldown is measured against
+ * @return a service ready to answer movements
+ */
+ public static PortalService create(PortalConfig config, Deliver deliver, FeatureGate featureGate, ServiceAvailability availability, Clock clock) {
+ List portals = new ArrayList<>();
+ Set ids = new HashSet<>();
+ for (PortalDefinition definition : config.portals()) {
+ definition.resolve().ifPresent(portal -> {
+ if (ids.add(portal.id())) {
+ portals.add(portal);
+ } else {
+ LOGGER.warn("Ignoring a second portal with the id '{}'; ids identify a portal in the logs and in the re-entry latch, so they have to be unique", portal.id());
+ }
+ });
+ }
+ PortalIndex index = PortalIndex.of(portals);
+ LOGGER.info("Loaded {} of {} configured portals into {} chunk columns", index.portals().size(), config.portals().size(), index.occupiedColumns());
+ return new PortalService(index, deliver, featureGate, availability, config, clock);
+ }
+
+ /**
+ * Creates a service wired to the CloudNet bridge for reachability and to the system clock.
+ *
+ * @param config the portal configuration
+ * @param deliver the delivery route players are handed to
+ * @param featureGate the gate that also guards the navigator entries
+ * @return a service ready to answer movements
+ */
+ public static PortalService create(PortalConfig config, Deliver deliver, FeatureGate featureGate) {
+ return create(config, deliver, featureGate, TitanServiceAvailability.holder(), Clock.systemUTC());
+ }
+
+ /**
+ * Handles one movement of one player.
+ *
+ * @param player the player who moved
+ * @param position the position they moved to
+ * @return what the movement did, for logging and tests
+ */
+ public PortalOutcome handleMove(Player player, Point position) {
+ if (this.index.isEmpty()) {
+ return PortalOutcome.NO_PORTAL;
+ }
+ Portal portal = this.index.portalAt(position);
+ if (portal == null) {
+ // Leaving arms the latch again - this is the only place it is cleared.
+ player.removeTag(Tags.PORTAL_INSIDE);
+ return PortalOutcome.NO_PORTAL;
+ }
+ String inside = player.getTag(Tags.PORTAL_INSIDE);
+ if (portal.id().equals(inside)) {
+ return PortalOutcome.ALREADY_INSIDE;
+ }
+ // Set before deciding: whatever the decision turns out to be, the player is standing in
+ // this portal now, and a refusal must not repeat on every following movement packet.
+ player.setTag(Tags.PORTAL_INSIDE, portal.id());
+ long now = this.clock.millis();
+ Long blockedUntil = player.getTag(Tags.PORTAL_COOLDOWN);
+ // Per player, not per portal - see the limitation noted on Tags#PORTAL_COOLDOWN.
+ if (blockedUntil != null && now < blockedUntil) {
+ return PortalOutcome.COOLING_DOWN;
+ }
+ player.setTag(Tags.PORTAL_COOLDOWN, now + this.config.retriggerCooldownMillis());
+ return attempt(player, portal);
+ }
+
+ /**
+ * Returns the index behind this service, mainly so callers can log how many portals are live.
+ *
+ * @return the portal index
+ */
+ public PortalIndex index() {
+ return this.index;
+ }
+
+ private PortalOutcome attempt(Player player, Portal portal) {
+ TitanFeatures feature = portal.feature();
+ if (feature != null && !this.featureGate.isVisibleTo(feature, player.getUuid())) {
+ player.sendMessage(message(this.config.deniedMessage(), portal));
+ return PortalOutcome.DENIED_FEATURE;
+ }
+ if (!isReachable(portal)) {
+ LOGGER.debug("Portal '{}' did not deliver {}: target {} '{}' is not reachable", portal.id(), player.getUuid(), portal.type(), portal.target());
+ player.sendMessage(message(this.config.unreachableMessage(), portal));
+ return PortalOutcome.TARGET_UNREACHABLE;
+ }
+ try {
+ this.deliver.sendPlayer(player, component(player, portal));
+ } catch (RuntimeException exception) {
+ // Never swallowed: the operator gets the stack trace, the player gets a sentence.
+ LOGGER.warn("Portal '{}' failed to deliver {} to {} '{}'", portal.id(), player.getUuid(), portal.type(), portal.target(), exception);
+ player.sendMessage(message(this.config.unreachableMessage(), portal));
+ return PortalOutcome.DELIVERY_FAILED;
+ }
+ return PortalOutcome.DELIVERED;
+ }
+
+ private boolean isReachable(Portal portal) {
+ return switch (portal.type()) {
+ case TASK -> this.availability.isTaskReachable(portal.target());
+ case SERVER -> this.availability.isServerReachable(portal.target());
+ };
+ }
+
+ private static DeliverComponent component(Player player, Portal portal) {
+ if (portal.type() == DeliverType.SERVER) {
+ return DeliverComponent.serverBuilder().player(player).serverName(portal.target()).build();
+ }
+ return DeliverComponent.taskBuilder().player(player).taskName(portal.target()).build();
+ }
+
+ private static Component message(String template, Portal portal) {
+ return MiniMessage.miniMessage().deserialize(template, Placeholder.unparsed("portal", portal.id()), Placeholder.unparsed("target", portal.target()));
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/portal/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/portal/package-info.java
new file mode 100644
index 0000000..f68245e
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/portal/package-info.java
@@ -0,0 +1,20 @@
+/**
+ * Portals: configured regions of the lobby that hand a player to another server instead of
+ * asking them to open the navigator.
+ *
+ * The pieces are deliberately separate.
+ * {@link net.onelitefeather.titan.common.portal.PortalDefinition}
+ * is what an operator writes; {@link net.onelitefeather.titan.common.portal.Portal} is what
+ * survived validation; {@link net.onelitefeather.titan.common.portal.PortalIndex} answers "which
+ * portal is this position in" cheaply enough for every movement packet; and
+ * {@link net.onelitefeather.titan.common.portal.PortalService} owns the decision - gate, then
+ * reachability, then delivery.
+ *
+ *
Geometry is not written here. Regions are Coris shapes
+ * ({@link net.onelitefeather.coris.shape.CuboidShape}), which is the org's shape library
+ * (OLF-L2-04).
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.common.portal;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/common/src/main/java/net/onelitefeather/titan/common/utils/Tags.java b/common/src/main/java/net/onelitefeather/titan/common/utils/Tags.java
index 320c7fb..8c9e7bb 100644
--- a/common/src/main/java/net/onelitefeather/titan/common/utils/Tags.java
+++ b/common/src/main/java/net/onelitefeather/titan/common/utils/Tags.java
@@ -35,6 +35,29 @@ public final class Tags {
public static final Tag SIT_ARROW = Tag.UUID("SIT_ARROW");
public static final Tag SIT_PLAYER = Tag.Structure("SIT_PLAYER", Pos.class);
+ /**
+ * Id of the portal the player is currently standing in, or absent when they are standing in
+ * none. A portal fires on the transition into this tag, not while it is set - that is what
+ * keeps a player who stayed behind (a refused or failed switch) from triggering the portal
+ * again on their next movement packet.
+ *
+ * Transient: this is session state, and it must not survive into the player's NBT.
+ */
+ public static final Tag PORTAL_INSIDE = Tag.Transient("portal_inside");
+
+ /**
+ * Epoch milliseconds before which no portal reacts to this player again. Debounces a player
+ * stepping out of a portal and straight back in.
+ *
+ * Known limitation (US-7.03): the window is per player, not per portal. A player who
+ * is refused by one portal and walks straight into the one next to it gets no message from the
+ * neighbour until the window has run out - the switch is still refused, only the explanation
+ * is missing. Making it per portal means keeping an expiry per portal id per player, with the
+ * eviction that comes with it; a single tag was judged the better trade while portals are far
+ * enough apart that walking between two of them takes longer than the window.
+ */
+ public static final Tag PORTAL_COOLDOWN = Tag.Transient("portal_cooldown");
+
private Tags() {
throw new UnsupportedOperationException("This class cannot be instantiated");
}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java b/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java
index 36f52f4..c92a9d2 100644
--- a/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java
@@ -24,18 +24,22 @@
/**
* Fixture standing in for LuckPerms: a fixed set of granted permissions and group memberships.
+ *
+ * Public because the portal tests need the same fixture: a portal is gated by the same
+ * {@link FeatureGate} as the navigator entry to the same destination, so it has to be tested
+ * against the same audience rather than against a second copy of it.
*/
-final class TestFeatureAudience implements FeatureAudience {
+public final class TestFeatureAudience implements FeatureAudience {
private final Set permissions = new HashSet<>();
private final Set groups = new HashSet<>();
- TestFeatureAudience grantPermission(UUID playerId, String permission) {
+ public TestFeatureAudience grantPermission(UUID playerId, String permission) {
this.permissions.add(key(playerId, permission));
return this;
}
- TestFeatureAudience joinGroup(UUID playerId, String group) {
+ public TestFeatureAudience joinGroup(UUID playerId, String group) {
this.groups.add(key(playerId, group.toLowerCase(Locale.ROOT)));
return this;
}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalConfigProviderTest.java b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalConfigProviderTest.java
new file mode 100644
index 0000000..3777977
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalConfigProviderTest.java
@@ -0,0 +1,124 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.deliver.DeliverType;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PortalConfigProviderTest {
+
+ @Test
+ @DisplayName("a hand-written file is the whole interface: no code change adds a portal")
+ void readsHandWrittenFile(@TempDir Path directory) throws IOException {
+ Files.writeString(directory.resolve(PortalConfig.PORTAL_FILE_NAME), """
+ {
+ "portals": [
+ {
+ "id": "survival",
+ "type": "task",
+ "target": "Survival",
+ "feature": "NAVIGATOR_SURVIVAL",
+ "min": { "x": 10.0, "y": 64.0, "z": 10.0 },
+ "max": { "x": 14.0, "y": 68.0, "z": 14.0 }
+ },
+ {
+ "id": "creative",
+ "target": "MemberBuild",
+ "min": { "x": 20.0, "y": 64.0, "z": 20.0 },
+ "max": { "x": 24.0, "y": 68.0, "z": 24.0 }
+ }
+ ],
+ "retriggerCooldownMillis": 1500,
+ "unreachableMessage": "no ",
+ "deniedMessage": "not for you"
+ }""");
+
+ PortalConfig config = PortalConfigProvider.create(directory).getPortalConfig();
+
+ assertEquals(2, config.portals().size());
+ assertEquals(1500L, config.retriggerCooldownMillis());
+ assertEquals("no ", config.unreachableMessage());
+ assertEquals("not for you", config.deniedMessage());
+
+ Portal survival = config.portals().get(0).resolve().orElseThrow();
+ assertEquals("survival", survival.id());
+ assertEquals(DeliverType.TASK, survival.type());
+ assertEquals("Survival", survival.target());
+ assertEquals(TitanFeatures.NAVIGATOR_SURVIVAL, survival.feature());
+ assertTrue(survival.contains(new Pos(12.5, 66, 12.5)));
+
+ Portal creative = config.portals().get(1).resolve().orElseThrow();
+ assertEquals(DeliverType.TASK, creative.type(), "an omitted type means task");
+ assertNull(creative.feature(), "an omitted feature means ungated");
+ }
+
+ @Test
+ @DisplayName("a missing file leaves a default behind, so the format is discoverable")
+ void writesDefaultWhenMissing(@TempDir Path directory) {
+ PortalConfig config = PortalConfigProvider.create(directory).getPortalConfig();
+
+ assertTrue(config.portals().isEmpty());
+ assertTrue(Files.exists(directory.resolve(PortalConfig.PORTAL_FILE_NAME)));
+ assertEquals(PortalConfig.defaultConfig().retriggerCooldownMillis(), config.retriggerCooldownMillis());
+ }
+
+ @Test
+ @DisplayName("what the provider writes, the provider reads back")
+ void roundTripsAPortal(@TempDir Path directory) {
+ PortalConfigProvider provider = PortalConfigProvider.create(directory);
+ PortalDefinition definition = new PortalDefinition("elytra", "server", "Elytra-1", "NAVIGATOR_ELYTRA", new Vec(1, 64, 1), new Vec(5, 68, 5));
+
+ provider.saveConfig(PortalConfig.of(List.of(definition), 2000L, "gone", "nope"));
+
+ PortalConfig reloaded = PortalConfigProvider.create(directory).getPortalConfig();
+ assertEquals(1, reloaded.portals().size());
+ assertEquals(2000L, reloaded.retriggerCooldownMillis());
+ Portal portal = reloaded.portals().get(0).resolve().orElseThrow();
+ assertEquals(DeliverType.SERVER, portal.type());
+ assertEquals("Elytra-1", portal.target());
+ assertEquals(TitanFeatures.NAVIGATOR_ELYTRA, portal.feature());
+ assertTrue(portal.contains(new Pos(3.5, 66, 3.5)));
+ }
+
+ @Test
+ @DisplayName("missing keys are filled in rather than rejected")
+ void repairsPartialFile(@TempDir Path directory) throws IOException {
+ Files.writeString(directory.resolve(PortalConfig.PORTAL_FILE_NAME), """
+ { "portals": [] }""");
+
+ PortalConfig config = PortalConfigProvider.create(directory).getPortalConfig();
+
+ assertTrue(config.portals().isEmpty());
+ assertEquals(0L, config.retriggerCooldownMillis(), "an omitted cooldown means no debounce, not a broken file");
+ assertEquals(PortalConfig.defaultConfig().unreachableMessage(), config.unreachableMessage());
+ assertEquals(PortalConfig.defaultConfig().deniedMessage(), config.deniedMessage());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalDefinitionTest.java b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalDefinitionTest.java
new file mode 100644
index 0000000..9976b85
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalDefinitionTest.java
@@ -0,0 +1,132 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.deliver.DeliverType;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PortalDefinitionTest {
+
+ private static final Vec MIN = new Vec(0, 64, 0);
+ private static final Vec MAX = new Vec(4, 68, 4);
+
+ @Test
+ @DisplayName("a complete entry resolves into a portal")
+ void resolvesCompleteEntry() {
+ PortalDefinition definition = new PortalDefinition("survival", "server", "Survival-1", "NAVIGATOR_SURVIVAL", MIN, MAX);
+
+ Portal portal = definition.resolve().orElseThrow();
+
+ assertEquals("survival", portal.id());
+ assertEquals(DeliverType.SERVER, portal.type());
+ assertEquals("Survival-1", portal.target());
+ assertEquals(TitanFeatures.NAVIGATOR_SURVIVAL, portal.feature());
+ assertTrue(portal.isGated());
+ assertTrue(portal.contains(new Pos(2.5, 66, 2.5)));
+ }
+
+ @Test
+ @DisplayName("an entry without a type delivers to a task, and an entry without a feature is ungated")
+ void appliesDefaults() {
+ Portal portal = new PortalDefinition("elytra", null, "ElytraRace", null, MIN, MAX).resolve().orElseThrow();
+
+ assertEquals(DeliverType.TASK, portal.type());
+ assertNull(portal.feature());
+ assertFalse(portal.isGated());
+ }
+
+ @Test
+ @DisplayName("type and feature are read case-insensitively and trimmed")
+ void readsValuesLeniently() {
+ Portal portal = new PortalDefinition(" creative ", " Server ", " MemberBuild ", " navigator_creative ", MIN, MAX).resolve().orElseThrow();
+
+ assertEquals("creative", portal.id());
+ assertEquals(DeliverType.SERVER, portal.type());
+ assertEquals("MemberBuild", portal.target());
+ assertEquals(TitanFeatures.NAVIGATOR_CREATIVE, portal.feature());
+ }
+
+ @Test
+ @DisplayName("the corners are normalised, so a reversed region still works")
+ void normalisesCorners() {
+ Portal portal = new PortalDefinition("reversed", null, "Survival", null, MAX, MIN).resolve().orElseThrow();
+
+ assertTrue(portal.contains(new Pos(2.5, 66, 2.5)));
+ assertEquals(0, portal.region().min().blockX());
+ assertEquals(4, portal.region().max().blockX());
+ }
+
+ @Test
+ @DisplayName("an entry naming an unknown feature is dropped instead of running ungated")
+ void dropsUnknownFeature() {
+ Optional portal = new PortalDefinition("typo", null, "Survival", "NAVIGATOR_SURVIVAAL", MIN, MAX).resolve();
+
+ assertTrue(portal.isEmpty());
+ }
+
+ @Test
+ @DisplayName("an entry naming an unknown target type is dropped")
+ void dropsUnknownType() {
+ assertTrue(new PortalDefinition("typo", "lobby", "Survival", null, MIN, MAX).resolve().isEmpty());
+ }
+
+ @Test
+ @DisplayName("an entry without an id, a target or a full region is dropped")
+ void dropsIncompleteEntries() {
+ assertTrue(new PortalDefinition(null, null, "Survival", null, MIN, MAX).resolve().isEmpty(), "no id");
+ assertTrue(new PortalDefinition(" ", null, "Survival", null, MIN, MAX).resolve().isEmpty(), "blank id");
+ assertTrue(new PortalDefinition("portal", null, null, null, MIN, MAX).resolve().isEmpty(), "no target");
+ assertTrue(new PortalDefinition("portal", null, " ", null, MIN, MAX).resolve().isEmpty(), "blank target");
+ assertTrue(new PortalDefinition("portal", null, "Survival", null, null, MAX).resolve().isEmpty(), "no min corner");
+ assertTrue(new PortalDefinition("portal", null, "Survival", null, MIN, null).resolve().isEmpty(), "no max corner");
+ }
+
+ @Test
+ @DisplayName("two identical corners are dropped, because Coris needs a region with volume")
+ void dropsDegenerateRegion() {
+ assertTrue(new PortalDefinition("point", null, "Survival", null, MIN, MIN).resolve().isEmpty());
+ }
+
+ @Test
+ @DisplayName("a region spanning more chunk columns than the index accepts is dropped")
+ void dropsOversizedRegion() {
+ // 4096 columns is the limit; 64 x 65 chunks is one column row past it.
+ Vec far = new Vec(64 * 16, 68, 65 * 16);
+
+ assertTrue(new PortalDefinition("mistyped", null, "Survival", null, MIN, far).resolve().isEmpty());
+ }
+
+ @Test
+ @DisplayName("a region right at the column limit is still accepted")
+ void keepsRegionAtTheLimit() {
+ // 64 x 64 chunk columns exactly.
+ Vec far = new Vec(63 * 16 + 15, 68, 63 * 16 + 15);
+
+ assertTrue(new PortalDefinition("large", null, "Survival", null, new Vec(0, 64, 0), far).resolve().isPresent());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalIndexTest.java b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalIndexTest.java
new file mode 100644
index 0000000..76171db
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalIndexTest.java
@@ -0,0 +1,131 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.onelitefeather.coris.shape.CuboidShape;
+import net.onelitefeather.deliver.DeliverType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
+
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PortalIndexTest {
+
+ /** Blocks 0..4 on every axis, so the region sits inside chunk column (0, 0). */
+ private static final Portal SPAWN_PORTAL = portal("spawn", new Vec(0, 64, 0), new Vec(4, 68, 4));
+
+ private static Portal portal(String id, Vec min, Vec max) {
+ return new Portal(id, new CuboidShape(min, max), DeliverType.TASK, "Survival", null);
+ }
+
+ @Test
+ @DisplayName("a position in the middle of a portal finds it")
+ void findsPortalInside() {
+ PortalIndex index = PortalIndex.of(List.of(SPAWN_PORTAL));
+
+ assertSame(SPAWN_PORTAL, index.portalAt(new Pos(2.5, 66, 2.5)));
+ }
+
+ @ParameterizedTest(name = "[{index}] ({0}, {1}, {2}) is inside: {3}")
+ @DisplayName("the region is block-inclusive on both corners")
+ @CsvSource({
+ // The minimum corner itself belongs to the portal.
+ "0.0, 64.0, 0.0, true",
+ // Anywhere within block 4 still belongs to it - the max corner is inclusive.
+ "4.999, 68.999, 4.999, true",
+ // The first block past the max corner does not.
+ "5.0, 66.0, 2.5, false", "2.5, 69.0, 2.5, false", "2.5, 66.0, 5.0, false",
+ // Nor does the block before the min corner; -0.001 is block -1, not block 0.
+ "-0.001, 66.0, 2.5, false", "2.5, 63.999, 2.5, false", "2.5, 66.0, -0.001, false"})
+ void respectsRegionEdges(double x, double y, double z, boolean inside) {
+ PortalIndex index = PortalIndex.of(List.of(SPAWN_PORTAL));
+
+ Portal found = index.portalAt(new Pos(x, y, z));
+
+ assertEquals(inside, found != null, "position (" + x + ", " + y + ", " + z + ")");
+ }
+
+ @Test
+ @DisplayName("a position in the same chunk but outside the region finds nothing")
+ void missesInsideSameChunk() {
+ PortalIndex index = PortalIndex.of(List.of(SPAWN_PORTAL));
+
+ // Block 10 is still chunk column (0, 0): the bucket is hit, the geometry is not.
+ assertNull(index.portalAt(new Pos(10.5, 66, 10.5)));
+ }
+
+ @Test
+ @DisplayName("a position in a chunk without portals finds nothing")
+ void missesInOtherChunk() {
+ PortalIndex index = PortalIndex.of(List.of(SPAWN_PORTAL));
+
+ assertNull(index.portalAt(new Pos(500.5, 66, 500.5)));
+ }
+
+ @Test
+ @DisplayName("a portal crossing a chunk border is found from either side")
+ void findsPortalAcrossChunkBorder() {
+ Portal wide = portal("wide", new Vec(12, 64, 12), new Vec(20, 68, 20));
+ PortalIndex index = PortalIndex.of(List.of(wide));
+
+ assertSame(wide, index.portalAt(new Pos(13.5, 66, 13.5)), "chunk (0, 0) side");
+ assertSame(wide, index.portalAt(new Pos(18.5, 66, 18.5)), "chunk (1, 1) side");
+ assertEquals(4, index.occupiedColumns(), "the portal spans a 2x2 block of chunk columns");
+ }
+
+ @Test
+ @DisplayName("negative coordinates do not collide with positive ones in the column key")
+ void separatesNegativeAndPositiveColumns() {
+ Portal negative = portal("negative", new Vec(-40, 64, -40), new Vec(-36, 68, -36));
+ PortalIndex index = PortalIndex.of(List.of(SPAWN_PORTAL, negative));
+
+ assertSame(negative, index.portalAt(new Pos(-38.5, 66, -38.5)));
+ assertSame(SPAWN_PORTAL, index.portalAt(new Pos(2.5, 66, 2.5)));
+ assertNull(index.portalAt(new Pos(-38.5, 66, 2.5)), "mirrored coordinates are a different column");
+ }
+
+ @Test
+ @DisplayName("portals are spread over columns instead of piling into one bucket")
+ void bucketsStaySparse() {
+ List portals = List.of(SPAWN_PORTAL, portal("far", new Vec(1000, 64, 1000), new Vec(1004, 68, 1004)), portal("further", new Vec(-2000, 64, -2000), new Vec(-1996, 68, -1996)));
+
+ PortalIndex index = PortalIndex.of(portals);
+
+ assertEquals(3, index.occupiedColumns());
+ assertEquals(3, index.portals().size());
+ assertNotNull(index.portalAt(new Pos(1002.5, 66, 1002.5)));
+ }
+
+ @Test
+ @DisplayName("the empty index answers without touching anything")
+ void emptyIndexFindsNothing() {
+ assertTrue(PortalIndex.empty().isEmpty());
+ assertNull(PortalIndex.empty().portalAt(new Pos(0, 0, 0)));
+ assertEquals(0, PortalIndex.empty().occupiedColumns());
+ assertTrue(PortalIndex.of(List.of()).isEmpty());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java
new file mode 100644
index 0000000..32f4908
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java
@@ -0,0 +1,373 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.portal;
+
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import net.minestom.server.coordinate.Pos;
+import net.minestom.server.coordinate.Vec;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.server.network.packet.server.play.SystemChatPacket;
+import net.minestom.testing.Collector;
+import net.minestom.testing.Env;
+import net.minestom.testing.TestConnection;
+import net.minestom.testing.extension.MicrotusExtension;
+import net.onelitefeather.deliver.DeliverComponent;
+import net.onelitefeather.titan.api.deliver.Deliver;
+import net.onelitefeather.titan.common.deliver.ServiceAvailability;
+import net.onelitefeather.titan.common.feature.FeatureGate;
+import net.onelitefeather.titan.common.feature.ReleaseStage;
+import net.onelitefeather.titan.common.feature.TestFeatureAudience;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.togglz.core.activation.DefaultActivationStrategyProvider;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.manager.FeatureManagerBuilder;
+import org.togglz.core.repository.FeatureState;
+import org.togglz.core.repository.mem.InMemoryStateRepository;
+import org.togglz.core.user.NoOpUserProvider;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertInstanceOf;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+@ExtendWith(MicrotusExtension.class)
+class PortalServiceTest {
+
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+ private static final long COOLDOWN = 3000L;
+
+ /** Blocks 0..4 on every axis. */
+ private static final Vec MIN = new Vec(0, 64, 0);
+ private static final Vec MAX = new Vec(4, 68, 4);
+
+ private static final Pos INSIDE = new Pos(2.5, 64, 2.5);
+ private static final Pos ALSO_INSIDE = new Pos(3.5, 64, 3.5);
+ private static final Pos OUTSIDE = new Pos(30.5, 64, 30.5);
+
+ @Test
+ @DisplayName("walking into a portal delivers the player to the configured target")
+ void deliversOnEntry(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+
+ assertEquals(1, fixture.deliver.components.size());
+ DeliverComponent component = fixture.deliver.components.getFirst();
+ assertEquals(fixture.player.getUuid(), component.playerId());
+ assertInstanceOf(DeliverComponent.TaskComponent.class, component);
+ assertEquals("Survival", ((DeliverComponent.TaskComponent) component).taskName());
+ }
+
+ @Test
+ @DisplayName("a portal pointing at a single service delivers to that service")
+ void deliversToNamedServer(Env env) {
+ Fixture fixture = new Fixture(env, new PortalDefinition("build", "server", "Build-1", null, MIN, MAX));
+
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+
+ DeliverComponent component = fixture.deliver.components.getFirst();
+ assertInstanceOf(DeliverComponent.ServerDeliverComponent.class, component);
+ assertEquals("Build-1", ((DeliverComponent.ServerDeliverComponent) component).gameServer());
+ }
+
+ @Test
+ @DisplayName("moving outside a portal does nothing at all")
+ void ignoresMovementOutside(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+
+ assertEquals(PortalOutcome.NO_PORTAL, fixture.move(OUTSIDE));
+
+ assertTrue(fixture.deliver.components.isEmpty());
+ fixture.chat.assertEmpty();
+ }
+
+ @Test
+ @DisplayName("an unreachable target leaves the player where they are and tells them so")
+ void reportsUnreachableTarget(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+ fixture.reachable = false;
+ fixture.player.teleport(INSIDE);
+
+ assertEquals(PortalOutcome.TARGET_UNREACHABLE, fixture.move(INSIDE));
+
+ assertTrue(fixture.deliver.components.isEmpty(), "nobody is sent anywhere");
+ assertEquals(INSIDE, fixture.player.getPosition(), "the player is not moved");
+ fixture.chat.assertSingle();
+ }
+
+ @Test
+ @DisplayName("a delivery that throws is reported to the player rather than swallowed")
+ void reportsFailedDelivery(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+ fixture.deliver.explode = true;
+ fixture.player.teleport(INSIDE);
+
+ assertEquals(PortalOutcome.DELIVERY_FAILED, fixture.move(INSIDE));
+
+ assertEquals(INSIDE, fixture.player.getPosition());
+ fixture.chat.assertSingle();
+ }
+
+ @Test
+ @DisplayName("a gated portal refuses a player the navigator would refuse too")
+ void refusesPlayerOutsideTheStage(Env env) {
+ Fixture fixture = new Fixture(env, gated());
+ fixture.release(TitanFeatures.NAVIGATOR_SURVIVAL, ReleaseStage.INTERNAL);
+
+ assertEquals(PortalOutcome.DENIED_FEATURE, fixture.move(INSIDE));
+
+ assertTrue(fixture.deliver.components.isEmpty());
+ fixture.chat.assertSingle();
+ }
+
+ @Test
+ @DisplayName("a gated portal admits the player the navigator admits: same gate, same answer")
+ void admitsPlayerInsideTheStage(Env env) {
+ Fixture fixture = new Fixture(env, gated());
+ fixture.release(TitanFeatures.NAVIGATOR_SURVIVAL, ReleaseStage.INTERNAL);
+ fixture.audience.grantPermission(fixture.player.getUuid(), ReleaseStage.INTERNAL_PERMISSION);
+
+ assertTrue(fixture.gate.isVisibleTo(TitanFeatures.NAVIGATOR_SURVIVAL, fixture.player.getUuid()), "the navigator entry would be shown");
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+ assertEquals(1, fixture.deliver.components.size());
+ }
+
+ @Test
+ @DisplayName("a feature nobody enabled keeps its portal shut")
+ void refusesWhenTheFeatureIsUnconfigured(Env env) {
+ Fixture fixture = new Fixture(env, gated());
+
+ assertEquals(PortalOutcome.DENIED_FEATURE, fixture.move(INSIDE));
+ assertTrue(fixture.deliver.components.isEmpty());
+ }
+
+ @Test
+ @DisplayName("standing in a portal does not fire it again, however often the player moves")
+ void doesNotRetriggerWhileInside(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+ for (int movement = 0; movement < 20; movement++) {
+ assertEquals(PortalOutcome.ALREADY_INSIDE, fixture.move(ALSO_INSIDE));
+ }
+
+ assertEquals(1, fixture.deliver.components.size(), "one entry, one delivery");
+ }
+
+ @Test
+ @DisplayName("a player left behind by a failed switch is not messaged again on every step")
+ void doesNotRepeatTheRefusal(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+ fixture.reachable = false;
+
+ assertEquals(PortalOutcome.TARGET_UNREACHABLE, fixture.move(INSIDE));
+ for (int movement = 0; movement < 20; movement++) {
+ assertEquals(PortalOutcome.ALREADY_INSIDE, fixture.move(ALSO_INSIDE));
+ }
+
+ fixture.chat.assertSingle();
+ }
+
+ @Test
+ @DisplayName("stepping out and straight back in is debounced by the cooldown")
+ void debouncesQuickReentry(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+ assertEquals(PortalOutcome.NO_PORTAL, fixture.move(OUTSIDE));
+ fixture.clock.advance(COOLDOWN - 1);
+
+ assertEquals(PortalOutcome.COOLING_DOWN, fixture.move(INSIDE));
+ assertEquals(1, fixture.deliver.components.size());
+ }
+
+ @Test
+ @DisplayName("after the cooldown, re-entering works again - it is a debounce, not a ban")
+ void deliversAgainAfterTheCooldown(Env env) {
+ Fixture fixture = new Fixture(env, ungated());
+
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+ assertEquals(PortalOutcome.NO_PORTAL, fixture.move(OUTSIDE));
+ fixture.clock.advance(COOLDOWN);
+
+ assertEquals(PortalOutcome.DELIVERED, fixture.move(INSIDE));
+ assertEquals(2, fixture.deliver.components.size());
+ }
+
+ @Test
+ @DisplayName("an unusable entry is not a portal: the region stays empty")
+ void dropsUnusableEntries(Env env) {
+ Fixture fixture = new Fixture(env, new PortalDefinition("broken", null, "Survival", "NOT_A_FEATURE", MIN, MAX));
+
+ assertTrue(fixture.service.index().isEmpty());
+ assertEquals(PortalOutcome.NO_PORTAL, fixture.move(INSIDE));
+ assertTrue(fixture.deliver.components.isEmpty());
+ }
+
+ @Test
+ @DisplayName("a duplicate id is refused, so the latch keeps identifying one portal")
+ void dropsDuplicateIds(Env env) {
+ PortalDefinition first = new PortalDefinition("survival", null, "Survival", null, MIN, MAX);
+ PortalDefinition second = new PortalDefinition("survival", null, "Other", null, new Vec(40, 64, 40), new Vec(44, 68, 44));
+ Fixture fixture = new Fixture(env, first, second);
+
+ assertEquals(1, fixture.service.index().portals().size());
+ assertEquals(PortalOutcome.NO_PORTAL, fixture.move(new Pos(42.5, 66, 42.5)));
+ }
+
+ @Test
+ @DisplayName("the shipped messages arrive rendered: no MiniMessage tag reaches the player verbatim")
+ void rendersTheShippedMessages(Env env) {
+ PortalConfig shipped = PortalConfig.defaultConfig();
+
+ Fixture unreachable = new Fixture(env, shipped, ungated());
+ unreachable.reachable = false;
+ assertEquals(PortalOutcome.TARGET_UNREACHABLE, unreachable.move(INSIDE));
+ assertRendered(unreachable.chat);
+
+ Fixture denied = new Fixture(env, shipped, gated());
+ assertEquals(PortalOutcome.DENIED_FEATURE, denied.move(INSIDE));
+ assertRendered(denied.chat);
+ }
+
+ /**
+ * Asserts the single message the player got carries no leftover MiniMessage tag. {@code
+ * } is not a standard tag - it only resolves because {@code TitanMiniMessageImpl} is
+ * registered as the {@code MiniMessage.Provider} - so an unregistered provider, or a service
+ * file that a shaded jar dropped, shows the player the tag instead of the server name.
+ */
+ private static void assertRendered(Collector chat) {
+ chat.assertSingle(packet -> {
+ String rendered = PlainTextComponentSerializer.plainText().serialize(packet.message());
+ assertFalse(rendered.contains(""), "the prefix tag was not resolved: " + rendered);
+ assertFalse(rendered.contains(""), "the colour tag was not resolved: " + rendered);
+ assertTrue(rendered.startsWith("Titan "), "the resolved prefix opens the message: " + rendered);
+ });
+ }
+
+ private static PortalDefinition ungated() {
+ return new PortalDefinition("survival", "task", "Survival", null, MIN, MAX);
+ }
+
+ private static PortalDefinition gated() {
+ return new PortalDefinition("survival", "task", "Survival", "NAVIGATOR_SURVIVAL", MIN, MAX);
+ }
+
+ /** Everything a portal needs, with the two switches a test wants to flip. */
+ private static final class Fixture {
+
+ private final InMemoryStateRepository repository = new InMemoryStateRepository();
+ private final TestFeatureAudience audience = new TestFeatureAudience();
+ private final RecordingDeliver deliver = new RecordingDeliver();
+ private final MutableClock clock = new MutableClock(Instant.parse("2026-10-15T12:00:00Z"));
+ private final FeatureGate gate;
+ private final PortalService service;
+ private final Player player;
+ private final Collector chat;
+ private boolean reachable = true;
+
+ private Fixture(Env env, PortalDefinition... portals) {
+ this(env, PortalConfig.of(List.of(portals), COOLDOWN, "unreachable ", "denied "), portals);
+ }
+
+ private Fixture(Env env, PortalConfig messages, PortalDefinition... portals) {
+ FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(this.repository).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+ this.gate = FeatureGate.with(featureManager, this.audience, this.clock, BERLIN);
+ PortalConfig config = PortalConfig.of(List.of(portals), COOLDOWN, messages.unreachableMessage(), messages.deniedMessage());
+ ServiceAvailability availability = new ServiceAvailability() {
+
+ @Override
+ public boolean isTaskReachable(String taskName) {
+ return Fixture.this.reachable;
+ }
+
+ @Override
+ public boolean isServerReachable(String serviceName) {
+ return Fixture.this.reachable;
+ }
+ };
+ this.service = PortalService.create(config, this.deliver, this.gate, availability, this.clock);
+ Instance instance = env.createFlatInstance();
+ TestConnection connection = env.createConnection();
+ this.chat = connection.trackIncoming(SystemChatPacket.class);
+ this.player = connection.connect(instance, new Pos(0, 64, 0));
+ }
+
+ private PortalOutcome move(Pos position) {
+ return this.service.handleMove(this.player, position);
+ }
+
+ private void release(TitanFeatures feature, ReleaseStage stage) {
+ this.repository.setFeatureState(new FeatureState(feature, true).setParameter(FeatureGate.STAGE_PARAMETER, stage.id()));
+ }
+ }
+
+ /** Records what was delivered, and can be told to fail the way a broken route would. */
+ private static final class RecordingDeliver implements Deliver {
+
+ private final List components = new ArrayList<>();
+ private boolean explode;
+
+ @Override
+ public void sendPlayer(Player player, DeliverComponent component) {
+ if (this.explode) {
+ throw new IllegalStateException("the delivery route is down");
+ }
+ this.components.add(component);
+ }
+ }
+
+ /** A clock a test can push forward, so the cooldown is testable without waiting. */
+ private static final class MutableClock extends Clock {
+
+ private Instant instant;
+
+ private MutableClock(Instant instant) {
+ this.instant = instant;
+ }
+
+ private void advance(long millis) {
+ this.instant = this.instant.plusMillis(millis);
+ }
+
+ @Override
+ public ZoneId getZone() {
+ return ZoneOffset.UTC;
+ }
+
+ @Override
+ public Clock withZone(ZoneId zone) {
+ return this;
+ }
+
+ @Override
+ public Instant instant() {
+ return this.instant;
+ }
+ }
+}
diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md
index e784f32..7aa2b10 100644
--- a/docs/spec-lobby-saison-events.md
+++ b/docs/spec-lobby-saison-events.md
@@ -221,10 +221,24 @@ Bewusst grob gehalten. Ausspezifizierung erst, wenn Stufe 1–4 stehen.
| ID | Story | Akzeptanzkriterium (EARS) | Schnittstelle | Priorität | Status |
|---|---|---|---|---|---|
-| US-7.01 | Als Spieler möchte ich durch ein Portal auf einen anderen Server wechseln, statt einen Navigator zu öffnen. | When ein Spieler einen als Portal definierten Bereich betritt, shall die Lobby ihn an den hinterlegten Zielserver weiterleiten. | `Deliver`, Bereichsprüfung | Could | offen |
-| US-7.02 | Als Betreiber möchte ich Portale ohne Codeänderung definieren. | The Portale shall aus einer Konfigurationsdatei mit Bereich und Zielserver geladen werden. | JSON | Could | offen |
-| US-7.03 | Als Betreiber möchte ich, dass ein Portal mit unerreichbarem Ziel den Spieler nicht ins Leere schickt. | If der Zielserver eines Portals nicht erreichbar ist, then shall die Lobby den Spieler an Ort und Stelle lassen und ihm eine Meldung anzeigen. | `Deliver` | Could | offen |
-| US-7.04 | Als Betreiber möchte ich, dass Portale denselben Berechtigungsregeln folgen wie der Navigator. | Where ein Portal ein berechtigungspflichtiges Ziel hat, shall dieselbe Prüfung gelten wie für das entsprechende Navigator-Ziel. | `FeatureGate` | Could | offen |
+| US-7.01 | Als Spieler möchte ich durch ein Portal auf einen anderen Server wechseln, statt einen Navigator zu öffnen. | When ein Spieler einen als Portal definierten Bereich betritt, shall die Lobby ihn an den hinterlegten Zielserver weiterleiten. | `Deliver`, Bereichsprüfung | Could | umgesetzt |
+| US-7.02 | Als Betreiber möchte ich Portale ohne Codeänderung definieren. | The Portale shall aus einer Konfigurationsdatei mit Bereich und Zielserver geladen werden. | JSON | Could | umgesetzt |
+| US-7.03 | Als Betreiber möchte ich, dass ein Portal mit unerreichbarem Ziel den Spieler nicht ins Leere schickt. | If der Zielserver eines Portals nicht erreichbar ist, then shall die Lobby den Spieler an Ort und Stelle lassen und ihm eine Meldung anzeigen. | `Deliver` | Could | umgesetzt (Erreichbarkeit über die Bridge-Extension) |
+| US-7.04 | Als Betreiber möchte ich, dass Portale denselben Berechtigungsregeln folgen wie der Navigator. | Where ein Portal ein berechtigungspflichtiges Ziel hat, shall dieselbe Prüfung gelten wie für das entsprechende Navigator-Ziel. | `FeatureGate` | Could | umgesetzt |
+
+Portale liegen in `portals.json` (Bereich, Zielserver, optional das
+Navigator-Feature, das dasselbe Ziel absichert). Die Bereichsprüfung nutzt
+Coris-`CuboidShape`; die Zuordnung Position → Portal läuft über einen Index
+nach Chunk-Spalte, damit `PlayerMoveEvent` nicht jedes Portal einzeln prüft.
+Ein Portal löst beim *Betreten* aus, nicht beim Darinstehen — sonst würde ein
+Spieler, dessen Wechsel abgelehnt wurde, bei jedem Bewegungspaket erneut
+auslösen.
+
+**Einschränkung zu US-7.03:** Ob ein Ziel erreichbar ist, weiß nur CloudNet.
+Die Antwort kommt daher aus der Bridge-Extension (`ServiceAvailability`,
+denselben Weg wie `ServerConnector`). Ohne geladene Bridge gilt kein Ziel als
+erreichbar — dieselbe fehlende Bridge würde die Auslieferung ohnehin ins Leere
+laufen lassen, und eine Meldung ist dann die ehrlichere Antwort.
---
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 10ce3ec..3063f87 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -39,6 +39,10 @@ dependencyResolutionManagement {
version("luckperms", "5.6-SNAPSHOT")
version("togglz", "4.6.2")
+ // Coris: shape/area management for Minestom. Not carried by aonyx-bom
+ // (which only manages aves, guira, xerus and the falco/mycelium boms),
+ // so the version is pinned here until the bom picks it up.
+ version("coris", "0.7.1")
version("caffeine", "3.2.4")
version("tomcat-annotations-api", "6.0.53")
@@ -57,6 +61,7 @@ dependencyResolutionManagement {
library("butterfly-minestom", "net.onelitefeather", "butterfly-minestom").versionRef("butterfly")
library("togglz", "org.togglz", "togglz-core").versionRef("togglz")
+ library("coris", "net.onelitefeather", "coris").versionRef("coris")
library("caffeine", "com.github.ben-manes.caffeine", "caffeine").versionRef("caffeine")
library("tomcat-annotations-api", "org.apache.tomcat", "annotations-api").versionRef("tomcat-annotations-api")