Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions app/src/main/java/net/onelitefeather/titan/app/Titan.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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() {
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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}.
*
* <p>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.
*
* <p>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<PlayerMoveEvent> {

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());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,21 @@
*/
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;
import eu.cloudnetservice.modules.bridge.player.executor.ServerSelectorType;
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;

/**
Expand All @@ -45,6 +51,9 @@
* <li><b>Server switching:</b> installs a {@link ServerConnector} (used by
* {@code MessageChannelDeliver}) that connects players through the bridge
* {@link PlayerManager} / {@link PlayerExecutor}.
* <li><b>Reachability:</b> 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).
* </ul>
*/
public final class TitanBridgePermissionExtension extends Extension {
Expand All @@ -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) {
Expand Down
10 changes: 10 additions & 0 deletions common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
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).
*
* <p>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);
}
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.titan.common.deliver;

/**
* Cross-classloader holder for {@link ServiceAvailability}, the twin of
* {@link TitanServerConnector}.
*
* <p>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.
*
* <p><b>Nothing installed means nothing is reachable.</b> 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;
}
}
Loading
Loading