From eb1e2d066077edf98d4e880ddb970456bea65404 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:28:53 +0200 Subject: [PATCH 1/7] build: pin coris for portal region shapes Portals need a region model. Coris is the org's shape library for Minestom (OLF-L2-04), so its CuboidShape does the containment, the corner normalisation and the block-inclusive bounds instead of a bounding box written in Titan. aonyx-bom does not carry coris, so the version is pinned in the catalog with the reason, as OLF-L1-02 requires. Coris publishes mycelium-bom as its only runtime dependency; its constraints would move Minestom a release past the version aonyx-bom pins for Titan, which breaks tests at runtime, so the bom is excluded and only the jar comes in. junit-params comes along because the region edge cases are a table, not eight copies of one test. --- common/build.gradle.kts | 10 ++++++++++ settings.gradle.kts | 5 +++++ 2 files changed, 15 insertions(+) diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 4b836698..98055990 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/settings.gradle.kts b/settings.gradle.kts index 10ce3ec6..3063f878 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") From 3600e330c7f78793195b9492456f33703455c4ae Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:29:05 +0200 Subject: [PATCH 2/7] feat(portal): read portal regions from configuration (US-7.02) A portal is a region, a target server and, optionally, the navigator feature guarding the same destination - all of it in portals.json, loaded the way app.json is loaded, so adding one is an edit and not a release. Validation is per entry and drops rather than throws: an entry that names an unknown feature, an unknown target type, no target or a region without volume is refused with a log line naming it, and the portals around it stay live. Dropping is the safe direction - a portal that does not exist sends nobody anywhere, while a portal built from a half-understood entry would, and an unresolvable feature name would otherwise leave a gated destination ungated. The lookup is the part that has to be cheap: it runs on every movement packet of every player. Portals are bucketed by chunk column once at load time, so a movement costs one hash lookup and, in the common case, no geometry at all - instead of testing every portal's bounds every time. Regions may be no larger than 4096 chunk columns; past that a coordinate is mistyped rather than large. --- .../titan/common/portal/Portal.java | 71 ++++++++ .../titan/common/portal/PortalConfig.java | 101 +++++++++++ .../titan/common/portal/PortalConfigImpl.java | 59 +++++++ .../common/portal/PortalConfigProvider.java | 114 +++++++++++++ .../titan/common/portal/PortalDefinition.java | 148 ++++++++++++++++ .../titan/common/portal/PortalIndex.java | 161 ++++++++++++++++++ .../titan/common/portal/package-info.java | 20 +++ .../portal/PortalConfigProviderTest.java | 124 ++++++++++++++ .../common/portal/PortalDefinitionTest.java | 132 ++++++++++++++ .../titan/common/portal/PortalIndexTest.java | 131 ++++++++++++++ 10 files changed, 1061 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigImpl.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfigProvider.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalDefinition.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalIndex.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/package-info.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/portal/PortalConfigProviderTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/portal/PortalDefinitionTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/portal/PortalIndexTest.java 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 00000000..9244c22c --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java @@ -0,0 +1,71 @@ +/** + * 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.utils.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). + * + * @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 00000000..d65daf06 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java @@ -0,0 +1,101 @@ +/** + * 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. + * + *

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 00000000..b15c1468 --- /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 00000000..1e2d09d7 --- /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 00000000..e9bdad28 --- /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.utils.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 00000000..619abb9d --- /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/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/portal/package-info.java new file mode 100644 index 00000000..f68245e2 --- /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/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 00000000..849d6ca4 --- /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.utils.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 00000000..899e01d1 --- /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.utils.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 00000000..76171dbd --- /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()); + } +} From 44f82bf87f44466bd4c90a288c6224a0476a9097 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:29:14 +0200 Subject: [PATCH 3/7] feat(deliver): ask the bridge whether a target can take a player Deliver hands a switch request to the CloudNet bridge and returns; a request for a task nobody is running looks exactly like a successful one. Anything that has to keep the player when the switch cannot happen - a portal, above all - has to ask beforehand. ServiceAvailability is that question, and it follows the route ServerConnector already takes: the answer lives in the bridge extension classloader, where the CloudNet service list is visible, and reaches the application through a JDK-typed holder. The bridge extension installs an implementation over the CloudNet service list, counting a service as reachable when it is RUNNING and connected. With nothing installed, nothing is reachable. That is not pessimism: the missing bridge that leaves this holder empty leaves TitanServerConnector empty too, so a delivery would be dropped on the floor either way. --- .../TitanBridgePermissionExtension.java | 50 ++++++++++ .../common/deliver/ServiceAvailability.java | 93 +++++++++++++++++++ .../deliver/TitanServiceAvailability.java | 84 +++++++++++++++++ 3 files changed, 227 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/deliver/ServiceAvailability.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/deliver/TitanServiceAvailability.java 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 1876b3c1..bb9da6d1 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/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 00000000..ac50a88a --- /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 00000000..cdddf09c --- /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; + } +} From cab1a85018ca1839668cd03fd91d081e26789c43 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:29:26 +0200 Subject: [PATCH 4/7] feat(portal): send players entering a portal to its target (US-7.01, 7.03, 7.04) Walking into a portal now hands the player to the configured server over the same Deliver route the navigator uses. What the navigator decides with a click, the portal decides with a step - and by the same rules: - Permission is the FeatureGate, not a second check. A portal names the navigator feature guarding the same destination, and the gate answers for the portal exactly as it answers for the navigator entry (US-7.04). - An unreachable target keeps the player where they are and says so. Reachability is asked before the switch, and a delivery that throws is reported to the player and logged with its stack trace rather than swallowed (US-7.03). Re-entry is the part a naive version gets wrong. PlayerMoveEvent fires several times a second, and a player who was refused - or who came back - is standing inside the region while it does. So a portal fires on the transition into a region, not on being in one: a transient tag latches the portal the player is inside and is cleared only when they leave it, and a configurable cooldown debounces stepping out and straight back in. One entry produces one delivery and, at most, one message. --- .../net/onelitefeather/titan/app/Titan.java | 8 + .../titan/app/listener/PortalListener.java | 55 +++ .../titan/common/portal/PortalOutcome.java | 66 ++++ .../titan/common/portal/PortalService.java | 207 +++++++++++ .../titan/common/utils/Tags.java | 16 + .../common/feature/TestFeatureAudience.java | 10 +- .../common/portal/PortalServiceTest.java | 337 ++++++++++++++++++ 7 files changed, 696 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/net/onelitefeather/titan/app/listener/PortalListener.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalOutcome.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java 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 95959cde..3e6e2fc7 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 00000000..894860c1 --- /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/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 00000000..28d79183 --- /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 00000000..acd30829 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java @@ -0,0 +1,207 @@ +/** + * 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.utils.Tags; +import net.onelitefeather.titan.common.utils.TitanFeatures; +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: + * + *

      + *
    1. 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).
    2. + *
    3. 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.
    4. + *
    5. 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).
    6. + *
    7. 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).
    8. + *
    + * + * @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); + 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/utils/Tags.java b/common/src/main/java/net/onelitefeather/titan/common/utils/Tags.java index 320c7fb6..df7872fb 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,22 @@ 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. + */ + 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 36f52f44..c92a9d21 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/PortalServiceTest.java b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java new file mode 100644 index 00000000..a0e89a5d --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java @@ -0,0 +1,337 @@ +/** + * 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.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.utils.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.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))); + } + + 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) { + 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, "unreachable ", "denied "); + 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; + } + } +} From 6e6d20e9b6748e767938ca861e2f87c38c800caf Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:29:55 +0200 Subject: [PATCH 5/7] docs: record what stage 7 now does, and what it depends on Marks US-7.01 to US-7.04 as implemented and notes the two decisions a reader would otherwise have to reconstruct from the code: the chunk-column index behind the movement check, and that a portal fires on entering rather than on standing inside. The reachability answer is qualified rather than claimed outright: it comes from the bridge extension, so without a loaded bridge no target counts as reachable. --- docs/spec-lobby-saison-events.md | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index e784f321..7aa2b109 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. --- From 7c538142449ad6101893409de66cbbf678c2f1be Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:13:56 +0200 Subject: [PATCH 6/7] fix(portal): follow the feature flag types to common/feature The portal sources were written against the old location of TitanFeatures and were merged in without a conflict, so nothing pointed out that :common:compileJava no longer resolves the import. Also records two things a reader would otherwise have to work out: - Portal#region() exposes Coris' Shape, which Coris marks experimental, so a Coris minor bump can move Titan's own API. Kept as is - the library is in-house - but named, with the way out if that changes. - The portal cooldown is per player, so a portal next to one that just refused stays quiet for the window. The switch is still refused either way; only the explanation is missing. Making it per portal needs an expiry per portal id per player and the eviction that comes with it. --- .../net/onelitefeather/titan/common/portal/Portal.java | 9 ++++++++- .../onelitefeather/titan/common/portal/PortalConfig.java | 4 +++- .../titan/common/portal/PortalDefinition.java | 2 +- .../titan/common/portal/PortalService.java | 3 ++- .../java/net/onelitefeather/titan/common/utils/Tags.java | 7 +++++++ .../titan/common/portal/PortalConfigProviderTest.java | 2 +- .../titan/common/portal/PortalDefinitionTest.java | 2 +- .../titan/common/portal/PortalServiceTest.java | 2 +- 8 files changed, 24 insertions(+), 7 deletions(-) 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 index 9244c22c..231a501f 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/Portal.java @@ -19,7 +19,7 @@ import net.minestom.server.coordinate.Point; import net.onelitefeather.coris.shape.Shape; import net.onelitefeather.deliver.DeliverType; -import net.onelitefeather.titan.common.utils.TitanFeatures; +import net.onelitefeather.titan.common.feature.TitanFeatures; import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; @@ -35,6 +35,13 @@ * 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 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 index d65daf06..ff5680ba 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalConfig.java @@ -73,7 +73,9 @@ static PortalConfig of(List portals, long retriggerCooldownMil *

    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. + * 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 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 index e9bdad28..bce9eadf 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalDefinition.java +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalDefinition.java @@ -20,7 +20,7 @@ import net.minestom.server.coordinate.Vec; import net.onelitefeather.coris.shape.CuboidShape; import net.onelitefeather.deliver.DeliverType; -import net.onelitefeather.titan.common.utils.TitanFeatures; +import net.onelitefeather.titan.common.feature.TitanFeatures; import org.jetbrains.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; 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 index acd30829..0ade3458 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java +++ b/common/src/main/java/net/onelitefeather/titan/common/portal/PortalService.java @@ -27,8 +27,8 @@ 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 net.onelitefeather.titan.common.utils.TitanFeatures; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -149,6 +149,7 @@ public PortalOutcome handleMove(Player player, Point position) { 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; } 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 df7872fb..8c9e7bb2 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 @@ -48,6 +48,13 @@ public final class Tags { /** * 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"); 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 index 849d6ca4..3777977a 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalConfigProviderTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalConfigProviderTest.java @@ -19,7 +19,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.onelitefeather.deliver.DeliverType; -import net.onelitefeather.titan.common.utils.TitanFeatures; +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; 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 index 899e01d1..9976b85e 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalDefinitionTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalDefinitionTest.java @@ -19,7 +19,7 @@ import net.minestom.server.coordinate.Pos; import net.minestom.server.coordinate.Vec; import net.onelitefeather.deliver.DeliverType; -import net.onelitefeather.titan.common.utils.TitanFeatures; +import net.onelitefeather.titan.common.feature.TitanFeatures; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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 index a0e89a5d..36001399 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java @@ -31,7 +31,7 @@ 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.utils.TitanFeatures; +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; From f65f3420785012b58ca3256e52888af1b445c08c Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:14:06 +0200 Subject: [PATCH 7/7] test(portal): pin that the shipped portal messages arrive rendered Both default messages open with , which is not a MiniMessage standard tag: it resolves only because TitanMiniMessageImpl is registered as the MiniMessage.Provider through META-INF/services. Lose that registration - an unmerged service file in a shaded jar, a provider that stops being loaded - and the player reads the tag instead of the server name, with nothing else failing. Verified the test earns its place by removing the service file: it fails. --- .../common/portal/PortalServiceTest.java | 38 ++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) 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 index 36001399..32f49087 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/portal/PortalServiceTest.java @@ -16,6 +16,7 @@ */ 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; @@ -50,6 +51,7 @@ 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; @@ -238,6 +240,36 @@ void dropsDuplicateIds(Env env) { 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); } @@ -260,9 +292,13 @@ private static final class Fixture { 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, "unreachable ", "denied "); + PortalConfig config = PortalConfig.of(List.of(portals), COOLDOWN, messages.unreachableMessage(), messages.deniedMessage()); ServiceAvailability availability = new ServiceAvailability() { @Override