diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/ActiveLobby.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/ActiveLobby.java new file mode 100644 index 00000000..a38d7fbf --- /dev/null +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/ActiveLobby.java @@ -0,0 +1,41 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.apps.cloudnet; + +import java.util.List; +import java.util.function.Consumer; +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.titan.common.map.LobbyMap; +import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.core.portal.Portal; +import org.mockito.Mockito; + +/** + * Stubs a mocked {@link MapProvider} with an active lobby, which the portal column reads at start. + */ +public final class ActiveLobby { + + private ActiveLobby() { + } + + public static Consumer with(List portals) { + return mapProvider -> Mockito.when(mapProvider.getActiveLobby()).thenReturn(new LobbyMap("test", new Pos(0, 65, 0), List.of(), portals)); + } + + public static Consumer empty() { + return with(List.of()); + } +} diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java index 4a261d2d..f30ac779 100644 --- a/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/apps/cloudnet/VariantStartTest.java @@ -20,10 +20,15 @@ import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; +import net.minestom.server.coordinate.Vec; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; import net.onelitefeather.titan.common.map.MapProvider; import net.onelitefeather.titan.core.feature.FeatureFlags; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.LobbyPortals; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.feature.portal.PortalModule; import net.onelitefeather.titan.core.permission.PermissionService; import net.onelitefeather.titan.platform.luckperms.LuckPermsPermissionService; import net.onelitefeather.titan.runtime.variant.LoadedModules; @@ -52,7 +57,7 @@ class VariantStartTest { void theFullScopeBuildsWithNoException(Env env) { // Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, // "Permission-Plattform". - BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); Assertions.assertDoesNotThrow(scope::close, "closing a fully built scope must not throw"); } @@ -90,6 +95,26 @@ void applicationYamlCarriesTheSeasonsDefaults() throws IOException { Assertions.assertTrue(yaml.contains("seasons:"), "the merged application.yaml must contain the seasons defaults"); } + @DisplayName("variant.properties lists portalColumn, the portal column is loaded and its module starts against the real LobbyPortals bean") + @Test + @Timeout(30) + void portalColumnIsExpectedLoadedAndWiredToTheLobbyPortalsBean(Env env) { + ClassLoader loader = getClass().getClassLoader(); + VariantDescriptor descriptor = VariantDescriptor.fromClasspath(loader).orElseThrow(() -> new AssertionError("this variant must ship META-INF/titan/variant.properties")); + Portal portal = new Portal("survival", new Box(new Vec(0, 64, 0), new Vec(1, 65, 1)), "Survival", null); + + Assertions.assertTrue(descriptor.modules().contains("portalColumn"), "expected modules must include portalColumn, were: " + descriptor.modules()); + Assertions.assertTrue(LoadedModules.discover(loader).contains("portalColumn"), "the portal column must be on the classpath and load"); + + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.with(List.of(portal))).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); + try { + Assertions.assertNotNull(scope.get(PortalModule.class), "the portal module must be a bean"); + Assertions.assertEquals(List.of(portal), scope.get(LobbyPortals.class).portals(), "LobbyPortals must serve the portals of the active map"); + } finally { + scope.close(); + } + } + @DisplayName("An additionally expected but missing column aborts startup, naming it") @Test void anAdditionallyExpectedButMissingColumnAbortsNamingIt() { diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java index 4121bb40..a329992d 100644 --- a/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/feature/season/SeasonRestartWiringTest.java @@ -33,6 +33,7 @@ import net.minestom.server.timer.Scheduler; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.apps.cloudnet.ActiveLobby; import net.onelitefeather.titan.common.map.MapProvider; import net.onelitefeather.titan.core.feature.FeatureFlags; import net.onelitefeather.titan.core.module.FeatureNode; @@ -133,7 +134,7 @@ private void startScope() { ServerStop stop = this.stops::incrementAndGet; OnlinePlayers players = () -> this.online; // Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, "Permission-Plattform". - this.scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).bean(Clock.class, this.clock).bean(Scheduler.class, this.env.process().scheduler()).bean(FeatureNode.TITAN_NODE, EventNode.class, (EventNode) this.titan.node()).bean(SeasonSchedule.class, new SeasonSchedule(this.clock, this.worlds)).bean(ServerStop.class, stop).bean(OnlinePlayers.class, players).build(); + this.scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).bean(Clock.class, this.clock).bean(Scheduler.class, this.env.process().scheduler()).bean(FeatureNode.TITAN_NODE, EventNode.class, (EventNode) this.titan.node()).bean(SeasonSchedule.class, new SeasonSchedule(this.clock, this.worlds)).bean(ServerStop.class, stop).bean(OnlinePlayers.class, players).build(); } private void tickOneMinute() { diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeansWiringTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeansWiringTest.java index dcdcade5..1ff5bb32 100644 --- a/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeansWiringTest.java +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeansWiringTest.java @@ -23,6 +23,7 @@ import net.onelitefeather.titan.core.feature.FeatureFlags; import net.onelitefeather.titan.core.permission.PermissionService; import net.onelitefeather.titan.platform.luckperms.LuckPermsPermissionService; +import net.onelitefeather.titan.apps.cloudnet.ActiveLobby; import net.onelitefeather.titan.common.map.MapProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -46,7 +47,7 @@ class PlatformBeansWiringTest { void scopeResolvesSchedulerAndBuildsLobbyItems(Env env) { // Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, // "Permission-Plattform". - BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); try { Scheduler scheduler = scope.get(Scheduler.class); diff --git a/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/WiringTest.java b/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/WiringTest.java index ccd62876..a15f282a 100644 --- a/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/WiringTest.java +++ b/apps/cloudnet/src/test/java/net/onelitefeather/titan/runtime/bootstrap/WiringTest.java @@ -33,6 +33,7 @@ import net.onelitefeather.titan.core.feature.FeatureFlags; import net.onelitefeather.titan.core.permission.PermissionService; import net.onelitefeather.titan.platform.luckperms.LuckPermsPermissionService; +import net.onelitefeather.titan.apps.cloudnet.ActiveLobby; import net.onelitefeather.titan.common.map.MapProvider; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; @@ -67,7 +68,7 @@ private static EventNode titanNode(BeanScope scope) { void scopeBuildsAllSevenFeaturesAndLobbyItemsWithThreeItems(Env env) { // Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, // "Permission-Plattform". - BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); try { Assertions.assertNotNull(scope.get(ProtectionModule.class), "the protection feature must be a bean"); @@ -90,7 +91,7 @@ void scopeBuildsAllSevenFeaturesAndLobbyItemsWithThreeItems(Env env) { void closingTheScopeDetachesEveryFeatureNode(Env env) { // Named mock, not the plain mock(Type) overload - see docs/lobby-modules.md, // "Permission-Plattform". - BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(); EventNode titan = titanNode(scope); Assertions.assertFalse(titan.getChildren().isEmpty(), "every feature must have attached its own node while the scope is open"); @@ -104,7 +105,7 @@ void closingTheScopeDetachesEveryFeatureNode(Env env) { void aFailingFeatureAbortsTheBuildNamingIt(Env env) { // Avaje propagates a @PostConstruct failure unwrapped, so the failing feature's class only // appears in the original stack trace, never a wrapper naming it. - RuntimeException thrown = Assertions.assertThrows(RuntimeException.class, () -> BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class, flags -> Mockito.when(flags.isActive(Mockito.anyString())).thenAnswer(invocation -> { + RuntimeException thrown = Assertions.assertThrows(RuntimeException.class, () -> BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class, flags -> Mockito.when(flags.isActive(Mockito.anyString())).thenAnswer(invocation -> { throw new IllegalStateException("feature flag lookup failed (WiringTest)"); })).mock(PermissionService.class, LuckPermsPermissionService.QUALIFIER).build(), "a feature failing its start must abort building the scope instead of silently continuing"); diff --git a/apps/local/src/test/java/net/onelitefeather/titan/apps/local/ActiveLobby.java b/apps/local/src/test/java/net/onelitefeather/titan/apps/local/ActiveLobby.java new file mode 100644 index 00000000..94148273 --- /dev/null +++ b/apps/local/src/test/java/net/onelitefeather/titan/apps/local/ActiveLobby.java @@ -0,0 +1,41 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.apps.local; + +import java.util.List; +import java.util.function.Consumer; +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.titan.common.map.LobbyMap; +import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.core.portal.Portal; +import org.mockito.Mockito; + +/** + * Stubs a mocked {@link MapProvider} with an active lobby, which the portal column reads at start. + */ +public final class ActiveLobby { + + private ActiveLobby() { + } + + public static Consumer with(List portals) { + return mapProvider -> Mockito.when(mapProvider.getActiveLobby()).thenReturn(new LobbyMap("test", new Pos(0, 65, 0), List.of(), portals)); + } + + public static Consumer empty() { + return with(List.of()); + } +} diff --git a/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java b/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java index 739e400c..608fcb72 100644 --- a/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java +++ b/apps/local/src/test/java/net/onelitefeather/titan/apps/local/VariantStartTest.java @@ -23,10 +23,15 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import net.minestom.server.coordinate.Vec; import net.minestom.testing.Env; import net.minestom.testing.extension.MicrotusExtension; import net.onelitefeather.titan.common.map.MapProvider; import net.onelitefeather.titan.core.feature.FeatureFlags; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.LobbyPortals; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.feature.portal.PortalModule; import net.onelitefeather.titan.core.module.LobbyWorldChoice; import net.onelitefeather.titan.core.permission.PermissionService; import net.onelitefeather.titan.runtime.variant.LoadedModules; @@ -56,7 +61,7 @@ void theFullScopeBuildsWithNoException(Env env) { // Named mock matching LuckPermsPermissionService.QUALIFIER - see docs/lobby-modules.md, // "Permission-Plattform" (platform/luckperms is not always on this module's classpath, so // the name is a literal here rather than the constant). - BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).mock(PermissionService.class, "luckperms").build(); + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).mock(PermissionService.class, "luckperms").build(); Assertions.assertDoesNotThrow(scope::close, "closing a fully built scope must not throw"); } @@ -77,7 +82,7 @@ void theActiveServiceIsDenyAllWithoutTheSwitch(Env env) { // test runtime classpath (see platform/luckperms's Gson exclude). Assumptions.assumeFalse(LoadedModules.discover(getClass().getClassLoader()).contains("luckpermsPlatform"), "only meaningful without -Ptitan.luckperms"); - BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class).mock(FeatureFlags.class).build(); + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.empty()).mock(FeatureFlags.class).build(); try { Assertions.assertEquals("deny-all", scope.get(PermissionService.class).name(), "without a permission platform, the fallback deny-all service must be active"); } finally { @@ -115,6 +120,26 @@ void applicationYamlHasNoSeasonsDefaults() throws IOException { } } + @DisplayName("variant.properties lists portalColumn, the portal column is loaded and its module starts against the real LobbyPortals bean") + @Test + @Timeout(30) + void portalColumnIsExpectedLoadedAndWiredToTheLobbyPortalsBean(Env env) { + ClassLoader loader = getClass().getClassLoader(); + VariantDescriptor descriptor = VariantDescriptor.fromClasspath(loader).orElseThrow(() -> new AssertionError("this variant must ship META-INF/titan/variant.properties")); + Portal portal = new Portal("survival", new Box(new Vec(0, 64, 0), new Vec(1, 65, 1)), "Survival", null); + + Assertions.assertTrue(descriptor.modules().contains("portalColumn"), "expected modules must include portalColumn, were: " + descriptor.modules()); + Assertions.assertTrue(LoadedModules.discover(loader).contains("portalColumn"), "the portal column must be on the classpath and load"); + + BeanScope scope = BeanScope.builder().forTesting().mock(MapProvider.class, ActiveLobby.with(List.of(portal))).mock(FeatureFlags.class).mock(PermissionService.class, "luckperms").build(); + try { + Assertions.assertNotNull(scope.get(PortalModule.class), "the portal module must be a bean"); + Assertions.assertEquals(List.of(portal), scope.get(LobbyPortals.class).portals(), "LobbyPortals must serve the portals of the active map"); + } finally { + scope.close(); + } + } + @DisplayName("An additionally expected but missing column aborts startup, naming it") @Test void anAdditionallyExpectedButMissingColumnAbortsNamingIt() { diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMap.java b/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMap.java index 3618b5f8..6917c756 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMap.java +++ b/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMap.java @@ -16,14 +16,33 @@ package net.onelitefeather.titan.common.map; import net.minestom.server.coordinate.Pos; +import net.onelitefeather.titan.core.portal.Portal; import net.theevilreaper.aves.map.BaseMap; +import org.jetbrains.annotations.Nullable; import java.util.List; public final class LobbyMap extends BaseMap { + /** + * Gson builds this class without a constructor, so a map file without portals leaves it + * {@code null}. + */ + private @Nullable List portals; + public LobbyMap(String name, Pos spawn, List builders) { + this(name, spawn, builders, List.of()); + } + + public LobbyMap(String name, Pos spawn, List builders, List portals) { super(name, spawn, builders); + // Kept null when empty so a map without portals is written back without a "portals" key. + this.portals = portals == null || portals.isEmpty() ? null : List.copyOf(portals); + } + + /** Unmodifiable; empty, never {@code null}, if the map has no portals. */ + public List portals() { + return this.portals == null ? List.of() : this.portals; } public static Builder lobbyMapBuilder(LobbyMap map) { @@ -40,6 +59,8 @@ public static Builder lobbyMapBuilder(LobbyMap map) { if (map.builders() != null) { builder.author(map.builders().toArray(new String[0])); } + // Every copy path must carry the portals, or /setup map setspawn|setname|setauthor would delete them. + builder.portals(map.portals()); return builder; } @@ -55,6 +76,8 @@ public sealed interface Builder permits LobbyMapBuilder { Builder author(String... author); + Builder portals(List portals); + LobbyMap build(); } } diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMapBuilder.java b/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMapBuilder.java index 1ac91fb0..0c567c3c 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMapBuilder.java +++ b/common/src/main/java/net/onelitefeather/titan/common/map/LobbyMapBuilder.java @@ -16,6 +16,7 @@ package net.onelitefeather.titan.common.map; import net.minestom.server.coordinate.Pos; +import net.onelitefeather.titan.core.portal.Portal; import java.util.List; @@ -24,6 +25,7 @@ final class LobbyMapBuilder implements LobbyMap.Builder { private Pos spawn; private String name; private String[] author; + private List portals = List.of(); @Override public LobbyMap.Builder spawn(Pos spawn) { @@ -43,9 +45,15 @@ public LobbyMap.Builder author(String... author) { return this; } + @Override + public LobbyMap.Builder portals(List portals) { + this.portals = portals; + return this; + } + @Override public LobbyMap build() { List builders = author == null ? null : List.of(author); - return new LobbyMap(name, spawn, builders); + return new LobbyMap(name, spawn, builders, portals); } } diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapGson.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapGson.java new file mode 100644 index 00000000..4514e086 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapGson.java @@ -0,0 +1,35 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Portal; +import net.theevilreaper.aves.file.gson.PositionGsonAdapter; + +/** The one Gson configuration for {@code map.json}, shared by the provider and its tests. */ +final class MapGson { + + private MapGson() { + } + + static Gson create() { + var positionAdapter = new PositionGsonAdapter(); + return new GsonBuilder().registerTypeAdapter(Pos.class, positionAdapter).registerTypeAdapter(Vec.class, positionAdapter).registerTypeAdapter(Portal.class, new PortalGsonAdapter()).create(); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java b/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java index 6d65efc9..acae70e6 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java +++ b/common/src/main/java/net/onelitefeather/titan/common/map/MapProvider.java @@ -16,17 +16,16 @@ package net.onelitefeather.titan.common.map; import com.google.gson.Gson; +import com.google.gson.JsonParseException; import net.minestom.server.coordinate.Point; -import net.minestom.server.coordinate.Pos; -import net.minestom.server.coordinate.Vec; import net.minestom.server.event.instance.InstanceChunkLoadEvent; import net.minestom.server.instance.Clock; import net.minestom.server.instance.InstanceContainer; import net.minestom.server.instance.LightingChunk; import net.minestom.server.instance.anvil.AnvilLoader; import net.minestom.server.utils.chunk.ChunkUtils; +import net.onelitefeather.titan.core.portal.PortalValidator; import net.theevilreaper.aves.file.GsonFileHandler; -import net.theevilreaper.aves.file.gson.PositionGsonAdapter; import net.theevilreaper.aves.map.BaseMap; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.UnmodifiableView; @@ -57,8 +56,7 @@ private MapProvider(@NotNull Path path, @NotNull InstanceContainer instance, Fun // Relight each chunk as it loads so unexplored regions light up (anvil chunks otherwise // stay dark until a block update triggers a relight). this.instance.eventNode().addListener(InstanceChunkLoadEvent.class, event -> LightingChunk.relight(event.getInstance(), List.of(event.getChunk()))); - var typeAdapter = new PositionGsonAdapter(); - this.gson = new Gson().newBuilder().registerTypeAdapter(Pos.class, typeAdapter).registerTypeAdapter(Vec.class, typeAdapter).create(); + this.gson = MapGson.create(); this.fileHandler = new GsonFileHandler(this.gson); this.loadMapData(); } @@ -74,7 +72,7 @@ public void saveMap(@NotNull BaseMap baseMap) { } private void loadMapData() { - var lobbyData = this.fileHandler.load(this.mapPool.getMapEntry().path().resolve(MapEntry.MAP_FILE_NAME), LobbyMap.class); + var lobbyData = this.readLobbyData(); // LightingChunk computes and sends sky/block light; plain DynamicChunks send none, leaving // the lobby pitch black. Must be set before the AnvilLoader loads any chunk. this.instance.setChunkSupplier(LightingChunk::new); @@ -98,6 +96,24 @@ private void loadMapData() { } + /** + * Aves' handler lets a parse error of the portal adapter escape (pinned by + * GsonFileHandlerLoadTest), so an unreadable or invalid portal aborts the start here instead of + * silently loading a map without portals. + */ + private Optional readLobbyData() { + Path worldDirectory = this.mapPool.getMapEntry().path(); + String world = worldDirectory.getFileName().toString(); + Optional lobbyData; + try { + lobbyData = this.fileHandler.load(worldDirectory.resolve(MapEntry.MAP_FILE_NAME), LobbyMap.class); + } catch (JsonParseException exception) { + throw new IllegalStateException("Invalid portals in world '" + world + "': " + exception.getMessage(), exception); + } + lobbyData.ifPresent(map -> PortalValidator.requireValid(world, map.portals())); + return lobbyData; + } + private void loadChunk(@NotNull InstanceContainer instance, @NotNull T pos) { if (!ChunkUtils.isLoaded(instance, pos)) { instance.loadChunk(pos); diff --git a/common/src/main/java/net/onelitefeather/titan/common/map/PortalGsonAdapter.java b/common/src/main/java/net/onelitefeather/titan/common/map/PortalGsonAdapter.java new file mode 100644 index 00000000..52fe5022 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/map/PortalGsonAdapter.java @@ -0,0 +1,138 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.portal.PortalShape; + +import java.lang.reflect.Type; + +/** + * Reads and writes the {@code portals} entries of a {@code map.json} (see the lobby-portals + * contract): the {@code shape.type} discriminator picks {@code box} or {@code disc}. Unknown + * fields are ignored, an unknown type or a missing geometry field is a {@link JsonParseException}. + * A missing {@code id} or {@code task} is left to the {@code PortalValidator}, which reports it + * together with every other problem. + */ +final class PortalGsonAdapter implements JsonDeserializer, JsonSerializer { + + private static final String BOX = "box"; + private static final String DISC = "disc"; + + @Override + public Portal deserialize(JsonElement json, Type type, JsonDeserializationContext context) throws JsonParseException { + JsonObject object = asObject(json, "portal"); + String id = string(object, "id"); + try { + return new Portal(id, shape(object.get("shape")), string(object, "task"), string(object, "permission")); + } catch (JsonParseException exception) { + throw new JsonParseException("portal '" + id + "': " + exception.getMessage(), exception); + } catch (RuntimeException exception) { + // getAsString/getAsDouble on an element of the wrong JSON type. + throw new JsonParseException("portal '" + id + "': malformed value (" + exception.getMessage() + ")", exception); + } + } + + private static PortalShape shape(JsonElement element) { + if (element == null || element.isJsonNull()) { + throw new JsonParseException("missing 'shape'"); + } + JsonObject shape = asObject(element, "'shape'"); + String kind = string(shape, "type"); + if (kind == null) { + throw new JsonParseException("missing 'shape.type'"); + } + return switch (kind) { + case BOX -> new Box(point(shape, "min"), point(shape, "max")); + case DISC -> + new Disc(point(shape, "center"), number(shape, "radius", null), point(shape, "normal")); + default -> throw new JsonParseException("unknown shape type '" + kind + "'"); + }; + } + + @Override + public JsonElement serialize(Portal portal, Type type, JsonSerializationContext context) { + JsonObject object = new JsonObject(); + object.addProperty("id", portal.id()); + object.addProperty("task", portal.task()); + if (portal.permission() != null) { + object.addProperty("permission", portal.permission()); + } + object.add("shape", shape(portal.shape())); + return object; + } + + private static JsonObject shape(PortalShape shape) { + JsonObject object = new JsonObject(); + switch (shape) { + case Box box -> { + object.addProperty("type", BOX); + object.add("min", point(box.min())); + object.add("max", point(box.max())); + } + case Disc disc -> { + object.addProperty("type", DISC); + object.add("center", point(disc.center())); + object.addProperty("radius", disc.radius()); + object.add("normal", point(disc.normal())); + } + } + return object; + } + + private static JsonObject point(Vec vec) { + JsonObject object = new JsonObject(); + object.addProperty("x", vec.x()); + object.addProperty("y", vec.y()); + object.addProperty("z", vec.z()); + return object; + } + + private static Vec point(JsonObject parent, String name) { + JsonObject point = asObject(parent.get(name), "'" + name + "'"); + return new Vec(number(point, "x", name), number(point, "y", name), number(point, "z", name)); + } + + private static double number(JsonObject object, String name, String parent) { + JsonElement value = object.get(name); + if (value == null || value.isJsonNull()) { + throw new JsonParseException("missing '" + (parent == null ? name : parent + "." + name) + "'"); + } + return value.getAsDouble(); + } + + private static String string(JsonObject object, String name) { + JsonElement value = object.get(name); + return value == null || value.isJsonNull() ? null : value.getAsString(); + } + + private static JsonObject asObject(JsonElement element, String what) { + if (element == null || !element.isJsonObject()) { + throw new JsonParseException("missing or malformed " + what); + } + return element.getAsJsonObject(); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/GsonFileHandlerLoadTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/GsonFileHandlerLoadTest.java new file mode 100644 index 00000000..3b4c5a51 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/map/GsonFileHandlerLoadTest.java @@ -0,0 +1,64 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonParseException; +import net.theevilreaper.aves.file.GsonFileHandler; +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 static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; + +/** + * Pins the Aves behaviour the portal loading relies on: a parse error of a type adapter must + * surface from {@code load}, not turn into an empty result that would silently start a lobby + * without spawn and portals (design D6). + */ +class GsonFileHandlerLoadTest { + + @TempDir + Path directory; + + @DisplayName("GsonFileHandler.load lets a JsonParseException of an adapter escape instead of returning empty") + @Test + void loadPropagatesAdapterParseErrors() throws IOException { + JsonParseException failure = new JsonParseException("broken adapter input"); + Gson gson = new GsonBuilder().registerTypeAdapter(Marker.class, (JsonDeserializer) (json, type, context) -> { + throw failure; + }).create(); + Path file = Files.writeString(this.directory.resolve("map.json"), "{\"marker\":{}}"); + + JsonParseException thrown = assertThrows(JsonParseException.class, () -> new GsonFileHandler(gson).load(file, Holder.class), "a parse error must not be swallowed into Optional.empty()"); + + assertSame(failure, thrown, "the adapter's own exception must reach the caller"); + } + + private static final class Marker { + } + + private static final class Holder { + private Marker marker; + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/LobbyMapPortalsTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/LobbyMapPortalsTest.java new file mode 100644 index 00000000..aa606ead --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/map/LobbyMapPortalsTest.java @@ -0,0 +1,93 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Portal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * The setup server rewrites a map through {@code lobbyMapBuilder(map)}; a copy path that dropped + * the portals would silently delete them on the next {@code setspawn}. + */ +class LobbyMapPortalsTest { + + private static final List PORTALS = List.of(new Portal("a", new Box(new Vec(0, 0, 0), new Vec(1, 1, 1)), "T", null)); + + private static LobbyMap withPortals() { + return LobbyMap.lobbyMapBuilder().name("world").spawn(new Pos(1, 2, 3)).author("alice").portals(PORTALS).build(); + } + + @DisplayName("A map built without portals reports an empty list") + @Test + void portalsDefaultToEmpty() { + assertTrue(LobbyMap.lobbyMapBuilder().build().portals().isEmpty(), "portals() must never be null"); + assertTrue(new LobbyMap("world", null, null).portals().isEmpty(), "the three-argument constructor stays usable"); + } + + @DisplayName("The builder keeps the given portals") + @Test + void builderKeepsPortals() { + assertEquals(PORTALS, withPortals().portals()); + } + + @DisplayName("Copying a map keeps its portals") + @Test + void copyKeepsPortals() { + assertEquals(PORTALS, LobbyMap.lobbyMapBuilder(withPortals()).build().portals()); + } + + @DisplayName("Setting the spawn on a copy keeps the portals") + @Test + void settingSpawnKeepsPortals() { + LobbyMap copy = LobbyMap.lobbyMapBuilder(withPortals()).spawn(new Pos(9, 9, 9)).build(); + + assertEquals(PORTALS, copy.portals(), "setspawn must not delete portals"); + assertEquals(new Pos(9, 9, 9), copy.spawn()); + } + + @DisplayName("Setting the name on a copy keeps the portals") + @Test + void settingNameKeepsPortals() { + LobbyMap copy = LobbyMap.lobbyMapBuilder(withPortals()).name("winter").build(); + + assertEquals(PORTALS, copy.portals(), "setname must not delete portals"); + assertEquals("winter", copy.name()); + } + + @DisplayName("Setting the author on a copy keeps the portals") + @Test + void settingAuthorKeepsPortals() { + LobbyMap copy = LobbyMap.lobbyMapBuilder(withPortals()).author("bob").build(); + + assertEquals(PORTALS, copy.portals(), "setauthor must not delete portals"); + assertEquals(List.of("bob"), copy.builders()); + } + + @DisplayName("Copying a null map yields an empty builder result") + @Test + void copyOfNullHasNoPortals() { + assertTrue(LobbyMap.lobbyMapBuilder(null).build().portals().isEmpty()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderPortalTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderPortalTest.java new file mode 100644 index 00000000..4758d890 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/map/MapProviderPortalTest.java @@ -0,0 +1,133 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import net.minestom.server.instance.InstanceContainer; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Portal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@ExtendWith(MicrotusExtension.class) +class MapProviderPortalTest { + + private static final String BOX = "{\"type\":\"box\",\"min\":{\"x\":0,\"y\":0,\"z\":0},\"max\":{\"x\":1,\"y\":1,\"z\":1}}"; + private static final String DISC = "{\"type\":\"disc\",\"center\":{\"x\":0,\"y\":0,\"z\":0},\"radius\":%s,\"normal\":{\"x\":0,\"y\":0,\"z\":%s}}"; + + @TempDir + Path base; + + @DisplayName("A map with an unknown shape type aborts the start instead of loading without portals") + @Test + void unknownShapeTypeAbortsTheStart(Env env) throws IOException { + world("world", """ + {"name":"world","portals":[{"id":"p","task":"T","shape":{"type":"sphere"}}]}"""); + + assertThrows(RuntimeException.class, () -> load(env), "an unreadable portal must not fall back to an empty map"); + } + + static Stream invalidPortals() { + return Stream.of( + Arguments.of("unknown shape", "{\"id\":\"p\",\"task\":\"T\",\"shape\":{\"type\":\"sphere\"}}", "sphere"), Arguments.of("radius 0", "{\"id\":\"p\",\"task\":\"T\",\"shape\":" + DISC.formatted(0, 1) + "}", "radius"), Arguments.of("min above max", "{\"id\":\"p\",\"task\":\"T\",\"shape\":{\"type\":\"box\",\"min\":{\"x\":5,\"y\":0,\"z\":0},\"max\":{\"x\":1,\"y\":1,\"z\":1}}}", "min.x"), Arguments.of("normal 0", "{\"id\":\"p\",\"task\":\"T\",\"shape\":" + DISC.formatted(2, 0) + "}", "normal"), Arguments.of("missing task", "{\"id\":\"p\",\"shape\":" + BOX + "}", "task"), Arguments.of("blank task", "{\"id\":\"p\",\"task\":\" \",\"shape\":" + BOX + "}", "task"), Arguments.of("missing id", "{\"task\":\"T\",\"shape\":" + BOX + "}", "id"), Arguments.of("duplicate id", "{\"id\":\"p\",\"task\":\"T\",\"shape\":" + BOX + "},{\"id\":\"p\",\"task\":\"T\",\"shape\":" + BOX + "}", "duplicate")); + } + + @DisplayName("An invalid portal aborts the start with world, portal id and reason") + @ParameterizedTest(name = "{0}") + @MethodSource("invalidPortals") + void invalidPortalAbortsTheStart(String label, String portalJson, String reason, Env env) throws IOException { + world("summer", "{\"name\":\"summer\",\"portals\":[" + portalJson + "]}"); + + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> load(env, "summer"), label + " must abort the start"); + + String message = failure.getMessage(); + assertTrue(message.contains("summer"), "the message must name the world, was: " + message); + assertTrue(message.contains(reason), "the message must give the reason '" + reason + "', was: " + message); + if (label.contains("missing id")) { + assertTrue(message.contains("portal #0"), "the message must identify the portal by its index, was: " + message); + } else { + assertTrue(message.contains("'p'"), "the message must name the portal id, was: " + message); + } + } + + @DisplayName("A valid portal starts and is part of the active lobby") + @Test + void validPortalStarts(Env env) throws IOException { + world("world", "{\"name\":\"world\",\"portals\":[{\"id\":\"p\",\"task\":\"T\",\"shape\":" + BOX + "}]}"); + + MapProvider provider = load(env); + + assertEquals(List.of(new Portal("p", new Box(new Vec(0, 0, 0), new Vec(1, 1, 1)), "T", null)), provider.getActiveLobby().portals()); + } + + @DisplayName("A map without portals loads with spawn, name and builders unchanged") + @Test + void mapWithoutPortalsLoadsUnchanged(Env env) throws IOException { + world("world", "{\"name\":\"world\",\"spawn\":{\"x\":1.5,\"y\":65,\"z\":2.5,\"yaw\":90,\"pitch\":0},\"builders\":[\"alice\"]}"); + + LobbyMap lobby = load(env).getActiveLobby(); + + assertEquals("world", lobby.name()); + assertEquals(new Pos(1.5, 65, 2.5, 90, 0), lobby.spawn()); + assertEquals(List.of("alice"), lobby.builders()); + assertTrue(lobby.portals().isEmpty(), "a map without portals must have none"); + } + + @DisplayName("Saving a map writes its portals and the next load reads them again") + @Test + void saveWritesPortals(Env env) throws IOException { + world("world", "{\"name\":\"world\",\"portals\":[{\"id\":\"p\",\"task\":\"T\",\"shape\":" + BOX + "}]}"); + MapProvider provider = load(env); + + provider.saveMap(LobbyMap.lobbyMapBuilder(provider.getActiveLobby()).spawn(new Pos(3, 70, 3)).build()); + + assertEquals(1, provider.getActiveLobby().portals().size(), "a spawn change must keep the portal"); + assertTrue(Files.readString(this.base.resolve("worlds").resolve("world").resolve(MapEntry.MAP_FILE_NAME)).contains("\"portals\""), "the file must still hold the portals"); + } + + private MapProvider load(Env env) { + return load(env, "world"); + } + + private MapProvider load(Env env, String world) { + InstanceContainer instance = env.process().instance().createInstanceContainer(); + return MapProvider.create(this.base, instance, Optional.of(world)); + } + + private void world(String name, String json) throws IOException { + Path directory = Files.createDirectories(this.base.resolve("worlds").resolve(name)); + Files.writeString(directory.resolve(MapEntry.MAP_FILE_NAME), json); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/map/PortalGsonAdapterTest.java b/common/src/test/java/net/onelitefeather/titan/common/map/PortalGsonAdapterTest.java new file mode 100644 index 00000000..71906f62 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/map/PortalGsonAdapterTest.java @@ -0,0 +1,179 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.common.map; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PortalGsonAdapterTest { + + /** The contract of design D1, verbatim, wrapped in a map file. */ + private static final String CONTRACT = """ + {"name":"world","portals":[ + {"id": "survival", "task": "Survival", "shape": {"type": "box", "min": {"x": 10, "y": 64, "z": 10}, "max": {"x": 14, "y": 68, "z": 11}}}, + {"id": "elytra-ring", "task": "ElytraRace", "permission": null, "shape": {"type": "disc", "center": {"x": 0.5, "y": 72, "z": 40.5}, "radius": 5.5, "normal": {"x": 0, "y": 0, "z": 1}}} + ]}"""; + + private final Gson gson = MapGson.create(); + + private LobbyMap read(String json) { + return this.gson.fromJson(json, LobbyMap.class); + } + + private static String map(String portalJson) { + return "{\"name\":\"world\",\"portals\":[" + portalJson + "]}"; + } + + @DisplayName("The box of the contract is read with its block coordinates") + @Test + void readsBox() { + Portal portal = read(CONTRACT).portals().get(0); + + assertEquals("survival", portal.id()); + assertEquals("Survival", portal.task()); + assertEquals(new Box(new Vec(10, 64, 10), new Vec(14, 68, 11)), portal.shape(), "min and max must be read as Vec"); + } + + @DisplayName("The disc of the contract is read with center, radius and normal") + @Test + void readsDisc() { + Portal portal = read(CONTRACT).portals().get(1); + + assertEquals(new Disc(new Vec(0.5, 72, 40.5), 5.5, new Vec(0, 0, 1)), portal.shape()); + } + + @DisplayName("A missing permission and an explicit null both mean everyone") + @Test + void missingAndNullPermissionAreNull() { + List portals = read(CONTRACT).portals(); + + assertNull(portals.get(0).permission(), "missing permission"); + assertNull(portals.get(1).permission(), "explicit null permission"); + } + + @DisplayName("A permission is read as given") + @Test + void readsPermission() { + Portal portal = read(map(""" + {"id":"a","task":"T","permission":"titan.portal.a","shape":{"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":1,"y":1,"z":1}}}""")).portals().get(0); + + assertEquals("titan.portal.a", portal.permission()); + } + + @DisplayName("Unknown fields on the portal and on the shape are ignored") + @Test + void ignoresUnknownFields() { + Portal portal = read(map(""" + {"id":"a","task":"T","color":"red","shape":{"type":"box","note":1,"min":{"x":0,"y":0,"z":0},"max":{"x":1,"y":1,"z":1}}}""")).portals().get(0); + + assertInstanceOf(Box.class, portal.shape(), "a newer file must not break an older lobby"); + } + + @DisplayName("A hand-written normal is normalised when the portal is read") + @Test + void normalIsNormalised() { + Portal portal = read(map(""" + {"id":"a","task":"T","shape":{"type":"disc","center":{"x":0,"y":0,"z":0},"radius":1,"normal":{"x":0,"y":0,"z":5}}}""")).portals().get(0); + + Disc disc = assertInstanceOf(Disc.class, portal.shape()); + assertEquals(1.0, disc.normal().length(), 1e-9, "the lobby normalises the normal on load"); + } + + @DisplayName("A map without portals yields an empty list") + @Test + void mapWithoutPortalsIsEmpty() { + LobbyMap lobbyMap = read("{\"name\":\"world\"}"); + + assertTrue(lobbyMap.portals().isEmpty(), "a missing list must read as empty, never null"); + } + + @DisplayName("Reading and writing the contract again yields the same portals") + @Test + void roundTripKeepsPortals() { + LobbyMap original = read(CONTRACT); + + LobbyMap again = read(this.gson.toJson(original)); + + assertEquals(original.portals(), again.portals()); + } + + @DisplayName("A written box follows the contract layout") + @Test + void writesTheBoxContract() { + LobbyMap lobbyMap = LobbyMap.lobbyMapBuilder().portals(List.of(new Portal("a", new Box(new Vec(1, 2, 3), new Vec(4, 5, 6)), "T", "perm"))).build(); + + JsonObject portal = this.gson.toJsonTree(lobbyMap).getAsJsonObject().getAsJsonArray("portals").get(0).getAsJsonObject(); + + assertEquals("a", portal.get("id").getAsString()); + assertEquals("T", portal.get("task").getAsString()); + assertEquals("perm", portal.get("permission").getAsString()); + JsonObject shape = portal.getAsJsonObject("shape"); + assertEquals("box", shape.get("type").getAsString()); + assertEquals(1.0, shape.getAsJsonObject("min").get("x").getAsDouble()); + assertEquals(6.0, shape.getAsJsonObject("max").get("z").getAsDouble()); + } + + @DisplayName("A written disc follows the contract layout") + @Test + void writesTheDiscContract() { + LobbyMap lobbyMap = LobbyMap.lobbyMapBuilder().portals(List.of(new Portal("a", new Disc(new Vec(1, 2, 3), 4.5, new Vec(0, 1, 0)), "T", null))).build(); + + JsonObject portal = this.gson.toJsonTree(lobbyMap).getAsJsonObject().getAsJsonArray("portals").get(0).getAsJsonObject(); + + JsonElement permission = portal.get("permission"); + assertTrue(permission == null || permission.isJsonNull(), "no permission is written as absent or null"); + JsonObject shape = portal.getAsJsonObject("shape"); + assertEquals("disc", shape.get("type").getAsString()); + assertEquals(4.5, shape.get("radius").getAsDouble()); + assertEquals(1.0, shape.getAsJsonObject("normal").get("y").getAsDouble()); + assertEquals(3.0, shape.getAsJsonObject("center").get("z").getAsDouble()); + } + + @DisplayName("An unknown shape type is a read error that names the type") + @Test + void unknownTypeIsAnError() { + JsonParseException failure = assertThrows(JsonParseException.class, () -> read(map(""" + {"id":"a","task":"T","shape":{"type":"sphere"}}"""))); + + assertTrue(failure.getMessage().contains("sphere"), "the message must name the type, was: " + failure.getMessage()); + } + + @DisplayName("A missing required field is a read error") + @ParameterizedTest(name = "{0}") + @ValueSource(strings = {"{\"id\":\"a\",\"task\":\"T\"}", "{\"id\":\"a\",\"task\":\"T\",\"shape\":{}}", "{\"id\":\"a\",\"task\":\"T\",\"shape\":{\"type\":\"box\",\"max\":{\"x\":1,\"y\":1,\"z\":1}}}", "{\"id\":\"a\",\"task\":\"T\",\"shape\":{\"type\":\"box\",\"min\":{\"x\":1,\"y\":1}, \"max\":{\"x\":1,\"y\":1,\"z\":1}}}", "{\"id\":\"a\",\"task\":\"T\",\"shape\":{\"type\":\"disc\",\"center\":{\"x\":0,\"y\":0,\"z\":0},\"normal\":{\"x\":0,\"y\":0,\"z\":1}}}", "{\"id\":\"a\",\"task\":\"T\",\"shape\":{\"type\":\"disc\",\"center\":{\"x\":0,\"y\":0,\"z\":0},\"radius\":1}}", "{\"id\":\"a\",\"task\":\"T\",\"shape\":{\"type\":\"disc\",\"radius\":1,\"normal\":{\"x\":0,\"y\":0,\"z\":1}}}" + }) + void missingRequiredFieldIsAnError(String portalJson) { + assertThrows(JsonParseException.class, () -> read(map(portalJson)), "a portal without a required field must not load half-built"); + } +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/Box.java b/core/src/main/java/net/onelitefeather/titan/core/portal/Box.java new file mode 100644 index 00000000..5960e172 --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/Box.java @@ -0,0 +1,54 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; + +/** + * An axis-parallel box. {@code min} and {@code max} are block coordinates, both inclusive, so the + * box covers {@code [min, max + 1]} per axis in world coordinates. + */ +public record Box(Vec min, Vec max) implements PortalShape { + + @Override + public boolean crossedBy(Point from, Point to) { + double[] window = {0.0, 1.0}; + return narrow(window, from.x(), to.x(), min.x(), max.x() + 1) && narrow(window, from.y(), to.y(), min.y(), max.y() + 1) && narrow(window, from.z(), to.z(), min.z(), max.z() + 1); + } + + /** + * Slab method: narrows the segment's parameter window to the part between {@code lo} and + * {@code hi} on one axis. A step without movement on the axis (a zero-length step included) is + * either inside the slab or not, hence containment. + */ + private static boolean narrow(double[] window, double from, double to, double lo, double hi) { + double delta = to - from; + if (delta == 0.0) { + return from >= lo && from <= hi; + } + double ta = (lo - from) / delta; + double tb = (hi - from) / delta; + window[0] = Math.max(window[0], Math.min(ta, tb)); + window[1] = Math.min(window[1], Math.max(ta, tb)); + return window[0] <= window[1]; + } + + @Override + public HorizontalBounds horizontalBounds() { + return new HorizontalBounds(min.x(), min.z(), max.x() + 1, max.z() + 1); + } +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/Disc.java b/core/src/main/java/net/onelitefeather/titan/core/portal/Disc.java new file mode 100644 index 00000000..cd7b2345 --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/Disc.java @@ -0,0 +1,64 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Vec; + +/** + * A flat circle of the given radius (rim inclusive) in the plane through {@code center} with the + * given normal. + * + *

The segment/plane test is Voyager's {@code RingPass}: intersecting the step between two ticks + * with the plane, rather than sampling positions, is what keeps a fast elytra step from skipping a + * one-block-thin disc. + */ +public record Disc(Vec center, double radius, Vec normal) implements PortalShape { + + /** How close to zero the plane/step dot product may be before the step counts as parallel. */ + private static final double PARALLEL_EPSILON = 1e-8; + + /** + * The normal as a unit vector; hand-written data need not be one. A zero-length normal is + * returned as is, since rejecting it is the validator's job. + */ + @Override + public Vec normal() { + return normal.length() > 0 ? normal.normalize() : normal; + } + + @Override + public boolean crossedBy(Point from, Point to) { + Vec unitNormal = normal(); + Vec step = new Vec(to.x() - from.x(), to.y() - from.y(), to.z() - from.z()); + double denominator = unitNormal.dot(step); + if (Math.abs(denominator) < PARALLEL_EPSILON) { + return false; + } + double t = unitNormal.dot(new Vec(center.x() - from.x(), center.y() - from.y(), center.z() - from.z())) / denominator; + if (t < 0.0 || t > 1.0) { + return false; + } + Vec hit = new Vec(from.x() + step.x() * t, from.y() + step.y() * t, from.z() + step.z() * t); + // The rim is inclusive and gets no tolerance: one would quietly enlarge every disc. + return hit.distance(center) <= radius; + } + + @Override + public HorizontalBounds horizontalBounds() { + return new HorizontalBounds(center.x() - radius, center.z() - radius, center.x() + radius, center.z() + radius); + } +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/HorizontalBounds.java b/core/src/main/java/net/onelitefeather/titan/core/portal/HorizontalBounds.java new file mode 100644 index 00000000..ffdbb58e --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/HorizontalBounds.java @@ -0,0 +1,20 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +/** An axis-parallel rectangle in world X/Z coordinates. */ +public record HorizontalBounds(double minX, double minZ, double maxX, double maxZ) { +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/LobbyPortals.java b/core/src/main/java/net/onelitefeather/titan/core/portal/LobbyPortals.java new file mode 100644 index 00000000..37784c5f --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/LobbyPortals.java @@ -0,0 +1,31 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import java.util.List; + +/** + * The portals of the active lobby world. + * + *

A dedicated type rather than a bare {@code List}, which would be an ambiguous bean for + * the DI container to wire. + */ +@FunctionalInterface +public interface LobbyPortals { + + /** Unmodifiable; empty, never {@code null}, if the world has no portals. */ + List portals(); +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/Portal.java b/core/src/main/java/net/onelitefeather/titan/core/portal/Portal.java new file mode 100644 index 00000000..9093f761 --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/Portal.java @@ -0,0 +1,30 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import org.jetbrains.annotations.Nullable; + +/** + * A place in a lobby world that sends a player to a CloudNet task when walked or flown through. + * Plain data read from the map file; {@link PortalValidator} decides whether it is usable. + * + * @param id unique within its world + * @param shape the area that triggers the portal + * @param task name of the CloudNet task to send the player to + * @param permission required permission, or {@code null} for everyone + */ +public record Portal(String id, PortalShape shape, String task, @Nullable String permission) { +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/PortalProblem.java b/core/src/main/java/net/onelitefeather/titan/core/portal/PortalProblem.java new file mode 100644 index 00000000..31210092 --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/PortalProblem.java @@ -0,0 +1,34 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import org.jetbrains.annotations.Nullable; + +/** + * One reason a {@link Portal} is unusable. + * + * @param portalId the portal's id as written, which may be blank or missing when that is the + * problem + * @param index the portal's position in the list, to find it when it has no id + * @param reason what is wrong, in words that name the offending field + */ +public record PortalProblem(@Nullable String portalId, int index, String reason) { + + /** {@code portal 'id'}, or {@code portal #index} when the id is missing or blank. */ + public String portalLabel() { + return portalId == null || portalId.isBlank() ? "portal #" + index : "portal '" + portalId + "'"; + } +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/PortalShape.java b/core/src/main/java/net/onelitefeather/titan/core/portal/PortalShape.java new file mode 100644 index 00000000..60f512cb --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/PortalShape.java @@ -0,0 +1,30 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Point; + +/** + * The area of a {@link Portal}: what a movement between two positions has to cross to trigger it. + */ +public sealed interface PortalShape permits Box, Disc { + + /** Whether the straight segment from {@code from} to {@code to} touches this shape. */ + boolean crossedBy(Point from, Point to); + + /** Smallest axis-parallel rectangle in X/Z that contains the shape, for the column index. */ + HorizontalBounds horizontalBounds(); +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/PortalValidator.java b/core/src/main/java/net/onelitefeather/titan/core/portal/PortalValidator.java new file mode 100644 index 00000000..25ca7f1b --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/PortalValidator.java @@ -0,0 +1,99 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Vec; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * Checks portals in one place so that the lobby (failing at start) and the setup editor (reporting + * in chat) reject the same data for the same reasons. + */ +public final class PortalValidator { + + private PortalValidator() { + } + + /** Every problem of every portal; empty if all are usable. */ + public static List problems(List portals) { + List problems = new ArrayList<>(); + Set seenIds = new HashSet<>(); + for (int index = 0; index < portals.size(); index++) { + Portal portal = portals.get(index); + String id = portal.id(); + if (isBlank(id)) { + problems.add(new PortalProblem(id, index, "id must not be blank")); + } else if (!seenIds.add(id)) { + problems.add(new PortalProblem(id, index, "duplicate id")); + } + if (isBlank(portal.task())) { + problems.add(new PortalProblem(id, index, "task must not be blank")); + } + switch (portal.shape()) { + case null -> problems.add(new PortalProblem(id, index, "shape is missing")); + case Box box -> checkBox(id, index, box, problems); + case Disc disc -> checkDisc(id, index, disc, problems); + } + } + return problems; + } + + /** + * Fails with a message that names the world, each offending portal and its reason, so an + * operator can fix the map file without a debugger. + * + * @throws IllegalStateException if any portal is unusable + */ + public static void requireValid(String world, List portals) { + List problems = problems(portals); + if (problems.isEmpty()) { + return; + } + String details = problems.stream().map(problem -> problem.portalLabel() + ": " + problem.reason()).collect(Collectors.joining("; ")); + throw new IllegalStateException("Invalid portals in world '" + world + "': " + details); + } + + private static void checkBox(String id, int index, Box box, List problems) { + checkAxis(id, index, "x", box.min().x(), box.max().x(), problems); + checkAxis(id, index, "y", box.min().y(), box.max().y(), problems); + checkAxis(id, index, "z", box.min().z(), box.max().z(), problems); + } + + private static void checkAxis(String id, int index, String axis, double min, double max, List problems) { + if (min > max) { + problems.add(new PortalProblem(id, index, "min." + axis + " (" + min + ") is greater than max." + axis + " (" + max + ")")); + } + } + + private static void checkDisc(String id, int index, Disc disc, List problems) { + if (!(disc.radius() > 0)) { + problems.add(new PortalProblem(id, index, "radius must be greater than 0 but was " + disc.radius())); + } + Vec normal = disc.normal(); + if (normal.length() == 0) { + problems.add(new PortalProblem(id, index, "normal must not have length 0")); + } + } + + private static boolean isBlank(String value) { + return value == null || value.isBlank(); + } +} diff --git a/core/src/main/java/net/onelitefeather/titan/core/portal/package-info.java b/core/src/main/java/net/onelitefeather/titan/core/portal/package-info.java new file mode 100644 index 00000000..c1e0f37a --- /dev/null +++ b/core/src/main/java/net/onelitefeather/titan/core/portal/package-info.java @@ -0,0 +1,21 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * Portals as map data: their shapes, the crossing tests, the validation and the + * {@link net.onelitefeather.titan.core.portal.LobbyPortals} bean. Lives in {@code core} because + * columns see only {@code core}; loading (common) and triggering (features/portal) are elsewhere. + */ +package net.onelitefeather.titan.core.portal; diff --git a/core/src/test/java/net/onelitefeather/titan/core/portal/BoxTest.java b/core/src/test/java/net/onelitefeather/titan/core/portal/BoxTest.java new file mode 100644 index 00000000..c7c0f8b3 --- /dev/null +++ b/core/src/test/java/net/onelitefeather/titan/core/portal/BoxTest.java @@ -0,0 +1,117 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Vec; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class BoxTest { + + /** Blocks 10..14 x 64..68 x 10..11 inclusive, so world [10,15] x [64,69] x [10,12]. */ + private static final Box BOX = new Box(new Vec(10, 64, 10), new Vec(14, 68, 11)); + + private static void assertCrossed(Vec from, Vec to, String why) { + assertTrue(BOX.crossedBy(from, to), why); + } + + private static void assertNotCrossed(Vec from, Vec to, String why) { + assertFalse(BOX.crossedBy(from, to), why); + } + + @Test + void countsASegmentThroughTheBox() { + assertCrossed(new Vec(12, 66, 5), new Vec(12, 66, 20), "entering and leaving the box is a crossing"); + } + + @Test + void countsASegmentEndingInsideTheBox() { + assertCrossed(new Vec(12, 66, 5), new Vec(12, 66, 11), "ending inside is a crossing"); + } + + @Test + void countsASegmentStartingInsideTheBox() { + assertCrossed(new Vec(12, 66, 11), new Vec(12, 66, 20), "starting inside is a crossing"); + } + + @Test + void countsASegmentInsideTheBoxOnBothEnds() { + assertCrossed(new Vec(11, 65, 10.5), new Vec(13, 67, 11.5), "staying inside is a crossing"); + } + + @Test + void rejectsASegmentPassingBesideTheBox() { + assertNotCrossed(new Vec(8, 66, 5), new Vec(8, 66, 20), "a segment two blocks beside the box misses it"); + } + + @Test + void rejectsADiagonalSegmentThatPassesTheCornerOutside() { + // Inside the x slab for t in [0.5, 0.75] but inside the z slab for t in [0.35, 0.45]: never both. + assertNotCrossed(new Vec(0, 66, 19), new Vec(20, 66, -1), "slab intervals do not overlap, so it misses"); + } + + @Test + void countsAnAxisParallelSegmentInsideTheSlab() { + assertCrossed(new Vec(5, 66, 11), new Vec(20, 66, 11), "moving along x inside the y and z slabs hits"); + } + + @Test + void rejectsAnAxisParallelSegmentOutsideTheSlab() { + assertNotCrossed(new Vec(5, 70, 11), new Vec(20, 70, 11), "moving along x above the y slab misses"); + } + + @Test + void countsAGrazingSegmentAlongAnEdge() { + assertCrossed(new Vec(5, 64, 10), new Vec(20, 64, 10), "touching the lower edge counts"); + } + + @Test + void countsAGrazingSegmentThroughACorner() { + assertCrossed(new Vec(9, 63, 9), new Vec(11, 65, 11), "passing through the lowest corner counts"); + } + + @Test + void countsAPositionOnTheInclusiveMaxPlusOneFace() { + assertCrossed(new Vec(15, 66, 11), new Vec(16, 66, 11), "max + 1 is inclusive"); + } + + @Test + void rejectsAPositionJustBeyondMaxPlusOne() { + assertNotCrossed(new Vec(15.0001, 66, 11), new Vec(16, 66, 11), "max + 1.0001 is outside"); + } + + @Test + void countsAZeroLengthStepInsideTheBox() { + Vec standingStill = new Vec(12, 66, 11); + + assertCrossed(standingStill, standingStill, "standing inside the box is containment"); + } + + @Test + void rejectsAZeroLengthStepOutsideTheBox() { + Vec standingStill = new Vec(20, 66, 11); + + assertNotCrossed(standingStill, standingStill, "standing outside the box is no crossing"); + } + + @Test + void boundsTheBoxIncludingTheMaxBlock() { + assertEquals(new HorizontalBounds(10, 10, 15, 12), BOX.horizontalBounds(), "x 10..15, z 10..12 in world coordinates"); + } +} diff --git a/core/src/test/java/net/onelitefeather/titan/core/portal/DiscTest.java b/core/src/test/java/net/onelitefeather/titan/core/portal/DiscTest.java new file mode 100644 index 00000000..89af9593 --- /dev/null +++ b/core/src/test/java/net/onelitefeather/titan/core/portal/DiscTest.java @@ -0,0 +1,153 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Vec; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class DiscTest { + + /** Facing +z, centred on the origin's column. */ + private static final Disc FLAT = new Disc(new Vec(0, 64, 100), 5.0, new Vec(0, 0, 1)); + + /** Tilted in the xy plane so that all three dot-product terms stay alive. */ + private static final Disc TILTED = new Disc(new Vec(10, 70, -20), 4.0, new Vec(0.6, 0.8, 0)); + + private static void assertCrossed(Disc disc, Vec from, Vec to, String why) { + assertTrue(disc.crossedBy(from, to), why); + } + + private static void assertNotCrossed(Disc disc, Vec from, Vec to, String why) { + assertFalse(disc.crossedBy(from, to), why); + } + + @Test + void countsAPassStraightThroughTheCentre() { + assertCrossed(FLAT, new Vec(0, 64, 99), new Vec(0, 64, 101), "a pass through the centre must count"); + } + + @Test + void countsAPassExactlyOnTheRim() { + assertCrossed(FLAT, new Vec(5, 64, 99), new Vec(5, 64, 101), "the rim is inclusive"); + } + + @Test + void rejectsAPassJustOutsideTheRim() { + assertNotCrossed(FLAT, new Vec(5.001, 64, 99), new Vec(5.001, 64, 101), "outside the rim must not count"); + } + + @Test + void rejectsMovementParallelToThePlane() { + assertNotCrossed(FLAT, new Vec(0, 64, 99), new Vec(10, 64, 99), "a step in the plane never crosses it"); + } + + @Test + void rejectsAPlaneTheSegmentStopsShortOf() { + assertNotCrossed(FLAT, new Vec(0, 64, 90), new Vec(0, 64, 95), "the plane at z=100 is not reached"); + } + + @Test + void rejectsAPlaneAlreadyBehindTheSegment() { + assertNotCrossed(FLAT, new Vec(0, 64, 101), new Vec(0, 64, 105), "moving away from the plane must not count"); + } + + @Test + void countsABackwardsPass() { + assertCrossed(FLAT, new Vec(0, 64, 101), new Vec(0, 64, 99), "direction is not checked"); + } + + @Test + void countsAPassThroughATiltedDiscsCentre() { + assertCrossed(TILTED, new Vec(9.4, 69.2, -20), new Vec(10.6, 70.8, -20), "tilted centre pass must count"); + } + + @Test + void countsAnOffCentreHitOnATiltedDisc() { + assertCrossed(TILTED, new Vec(9.4, 69.2, -17), new Vec(10.6, 70.8, -17), "3 < radius 4"); + } + + @Test + void rejectsAnOffCentreMissOnATiltedDisc() { + assertNotCrossed(TILTED, new Vec(9.4, 69.2, -15), new Vec(10.6, 70.8, -15), "5 > radius 4"); + } + + @Test + void rejectsAZeroLengthStep() { + Vec standingStill = new Vec(0, 64, 100); + + assertNotCrossed(FLAT, standingStill, standingStill, "a stationary player must not trigger (no NaN)"); + } + + @Test + void countsARimPassWithACombinedXAndYOffset() { + // 3-4-5 triangle: an offset of (3, -4) is exactly radius 5 away. + assertCrossed(FLAT, new Vec(3, 60, 99), new Vec(3, 60, 101), "distance 5 on the rim counts"); + } + + @Test + void rejectsAPassJustOutsideTheRimWithACombinedXAndYOffset() { + assertNotCrossed(FLAT, new Vec(3.001, 60, 99), new Vec(3.001, 60, 101), "both offsets must feed the distance"); + } + + @Test + void countsARimPassOnATiltedDiscThroughAGenuineZStep() { + assertCrossed(TILTED, new Vec(9.4, 69.2, -18), new Vec(10.6, 70.8, -14), "interpolated z lands on the rim"); + } + + @Test + void rejectsAPassJustOutsideTheRimOnATiltedDiscThroughAGenuineZStep() { + assertNotCrossed(TILTED, new Vec(9.4, 69.2, -18), new Vec(10.6, 70.8, -13.996), "interpolated z is past the rim"); + } + + @Test + void detectsAnEightBlockElytraStepThatSkipsAOneBlockThinDisc() { + Disc thin = new Disc(new Vec(0.5, 72, 40.5), 5.5, new Vec(0, 0, 1)); + + assertCrossed(thin, new Vec(0.5, 72, 36.5), new Vec(0.5, 72, 44.5), "a fast step over the plane must be detected although neither end is near it"); + } + + @Test + void detectsAStepThroughADiscWhoseNormalIsNotAUnitVector() { + Disc handWritten = new Disc(new Vec(0, 64, 100), 5.0, new Vec(0, 0, 4)); + + assertCrossed(handWritten, new Vec(0, 64, 99), new Vec(0, 64, 101), "normal is normalised before use"); + } + + @Test + void exposesTheNormalAsAUnitVector() { + Disc handWritten = new Disc(new Vec(0, 64, 100), 5.0, new Vec(0, 3, 4)); + + assertEquals(new Vec(0, 0.6, 0.8), handWritten.normal(), "the normal must be normalised"); + } + + @Test + void keepsAZeroLengthNormalForTheValidatorToReject() { + Disc broken = new Disc(new Vec(0, 64, 100), 5.0, Vec.ZERO); + + assertEquals(Vec.ZERO, broken.normal(), "a zero normal must not turn into NaN"); + } + + @Test + void boundsTheDiscByCenterPlusMinusRadius() { + HorizontalBounds bounds = new Disc(new Vec(10, 64, -20), 4.0, new Vec(0, 0, 1)).horizontalBounds(); + + assertEquals(new HorizontalBounds(6, -24, 14, -16), bounds, "conservative rectangle around the centre"); + } +} diff --git a/core/src/test/java/net/onelitefeather/titan/core/portal/PortalValidatorTest.java b/core/src/test/java/net/onelitefeather/titan/core/portal/PortalValidatorTest.java new file mode 100644 index 00000000..42f47f9f --- /dev/null +++ b/core/src/test/java/net/onelitefeather/titan/core/portal/PortalValidatorTest.java @@ -0,0 +1,184 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.core.portal; + +import net.minestom.server.coordinate.Vec; +import org.junit.jupiter.api.Test; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class PortalValidatorTest { + + private static final Box VALID_BOX = new Box(new Vec(10, 64, 10), new Vec(14, 68, 11)); + private static final Disc VALID_DISC = new Disc(new Vec(0.5, 72, 40.5), 5.5, new Vec(0, 0, 1)); + + private static Portal box(String id) { + return new Portal(id, VALID_BOX, "Survival", null); + } + + private static Portal withShape(PortalShape shape) { + return new Portal("p", shape, "Survival", null); + } + + private static List problemsOf(Portal... portals) { + return PortalValidator.problems(List.of(portals)); + } + + @Test + void reportsNothingForValidPortals() { + List problems = problemsOf(box("survival"), new Portal("ring", VALID_DISC, "ElytraRace", "titan.portal")); + + assertTrue(problems.isEmpty(), "valid portals must yield no problems but got " + problems); + } + + @Test + void reportsNothingForNoPortals() { + assertTrue(PortalValidator.problems(List.of()).isEmpty(), "an empty list is valid"); + } + + @Test + void reportsARadiusOfZero() { + List problems = problemsOf(withShape(new Disc(new Vec(0, 64, 0), 0, new Vec(0, 0, 1)))); + + assertEquals(List.of(new PortalProblem("p", 0, "radius must be greater than 0 but was 0.0")), problems); + } + + @Test + void reportsANegativeRadius() { + List problems = problemsOf(withShape(new Disc(new Vec(0, 64, 0), -2, new Vec(0, 0, 1)))); + + assertEquals(List.of(new PortalProblem("p", 0, "radius must be greater than 0 but was -2.0")), problems); + } + + @Test + void reportsAZeroLengthNormal() { + List problems = problemsOf(withShape(new Disc(new Vec(0, 64, 0), 3, Vec.ZERO))); + + assertEquals(List.of(new PortalProblem("p", 0, "normal must not have length 0")), problems); + } + + @Test + void reportsMinAboveMaxNamingTheAxis() { + List problems = problemsOf(withShape(new Box(new Vec(0, 70, 0), new Vec(5, 64, 5)))); + + assertEquals(List.of(new PortalProblem("p", 0, "min.y (70.0) is greater than max.y (64.0)")), problems); + } + + @Test + void reportsEveryAxisWhereMinIsAboveMax() { + List problems = problemsOf(withShape(new Box(new Vec(9, 0, 9), new Vec(1, 5, 1)))); + + assertEquals(List.of( + new PortalProblem("p", 0, "min.x (9.0) is greater than max.x (1.0)"), new PortalProblem("p", 0, "min.z (9.0) is greater than max.z (1.0)")), problems); + } + + @Test + void acceptsMinEqualToMax() { + List problems = problemsOf(withShape(new Box(new Vec(3, 64, 3), new Vec(3, 64, 3)))); + + assertTrue(problems.isEmpty(), "a single-block box is valid but got " + problems); + } + + @Test + void reportsABlankId() { + List problems = problemsOf(new Portal(" ", VALID_BOX, "Survival", null)); + + assertEquals(List.of(new PortalProblem(" ", 0, "id must not be blank")), problems); + } + + @Test + void reportsAMissingId() { + List problems = problemsOf(new Portal(null, VALID_BOX, "Survival", null)); + + assertEquals(List.of(new PortalProblem(null, 0, "id must not be blank")), problems); + } + + @Test + void reportsABlankTask() { + List problems = problemsOf(new Portal("p", VALID_BOX, "", null)); + + assertEquals(List.of(new PortalProblem("p", 0, "task must not be blank")), problems); + } + + @Test + void reportsAMissingShape() { + List problems = problemsOf(new Portal("p", null, "Survival", null)); + + assertEquals(List.of(new PortalProblem("p", 0, "shape is missing")), problems); + } + + @Test + void reportsADuplicateIdOnTheSecondOccurrence() { + List problems = problemsOf(box("same"), box("same")); + + assertEquals(List.of(new PortalProblem("same", 1, "duplicate id")), problems); + } + + @Test + void reportsSeveralProblemsAtOnce() { + Portal broken = new Portal("", new Disc(new Vec(0, 64, 0), 0, Vec.ZERO), " ", null); + + List problems = problemsOf(broken); + + assertEquals(List.of( + new PortalProblem("", 0, "id must not be blank"), new PortalProblem("", 0, "task must not be blank"), new PortalProblem("", 0, "radius must be greater than 0 but was 0.0"), new PortalProblem("", 0, "normal must not have length 0")), problems); + } + + @Test + void requireValidAcceptsValidPortals() { + PortalValidator.requireValid("lobby", List.of(box("survival"))); + } + + @Test + void requireValidNamesWorldIdAndReason() { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> PortalValidator.requireValid("worlds/winter", List.of(withShape(new Disc(new Vec(0, 64, 0), 0, new Vec(0, 0, 1)))))); + + String message = failure.getMessage(); + assertTrue(message.contains("worlds/winter"), "message must name the world: " + message); + assertTrue(message.contains("'p'"), "message must name the portal id: " + message); + assertTrue(message.contains("radius must be greater than 0"), "message must name the reason: " + message); + } + + @Test + void requireValidListsEveryProblem() { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> PortalValidator.requireValid("lobby", List.of(box("a"), box("a"), new Portal("b", VALID_BOX, "", null)))); + + String message = failure.getMessage(); + assertTrue(message.contains("duplicate id"), "message must name the duplicate: " + message); + assertTrue(message.contains("task must not be blank"), "message must name the blank task: " + message); + } + + @Test + void requireValidIdentifiesAPortalWithoutIdByItsIndex() { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> PortalValidator.requireValid("lobby", List.of(box("a"), new Portal(null, VALID_BOX, "Survival", null)))); + + String message = failure.getMessage(); + assertTrue(message.contains("portal #1: id must not be blank"), "message must identify the portal by its index: " + message); + assertFalse(message.contains("'null'"), "message must not print a null id: " + message); + } + + @Test + void requireValidIdentifiesABlankIdPortalByItsIndex() { + IllegalStateException failure = assertThrows(IllegalStateException.class, () -> PortalValidator.requireValid("lobby", List.of(new Portal(" ", VALID_BOX, "Survival", null)))); + + assertTrue(failure.getMessage().contains("portal #0: id must not be blank"), "message must identify the portal by its index: " + failure.getMessage()); + } +} diff --git a/docs/lobby-modules.md b/docs/lobby-modules.md index abc5c1b4..8ca0c2cf 100644 --- a/docs/lobby-modules.md +++ b/docs/lobby-modules.md @@ -183,6 +183,7 @@ heutigen neun Features, in Hunderterschritten mit Platz dazwischen: | sit | 500 | | tickle | 600 | | elytra | 700 | +| portal | 900 | (`hotbar` und `admin` reagieren nicht über einen eigenen Feature-Node auf ein Event, das mit einem anderen Feature kollidieren könnte, und tragen deshalb kein `EVENT_PRIORITY`.) @@ -382,6 +383,38 @@ prüft jede Minute und nach jedem Verlassen, ob die gestartete von der gewünsch und stoppt die Lobby erst, wenn kein Spieler mehr online ist. Ein belegter Dienst kann daher länger warten; ein Betreiber kann ihn mit `/stop` sofort beenden. +## Portale (`portal`) + +Portale sind Kartendaten: Die Liste `portals` steht in der `map.json` der Welt (auch der einer +Saisonwelt), auf oberster Ebene neben `spawn`. Die Column `portal` schickt Spieler, die eine Form +betreten, an einen CloudNet-Task. + +```json +"portals": [ + {"id": "survival", "task": "Survival", "shape": {"type": "box", "min": {"x": 10, "y": 64, "z": 10}, "max": {"x": 14, "y": 68, "z": 11}}}, + {"id": "elytra-ring", "task": "ElytraRace", "permission": null, "shape": {"type": "disc", "center": {"x": 0.5, "y": 72, "z": 40.5}, "radius": 5.5, "normal": {"x": 0, "y": 0, "z": 1}}} +] +``` + +- `id` (eindeutig je Welt), `task` (Name des CloudNet-Tasks) und `shape` sind Pflicht; + `permission` ist optional, fehlend oder `null` heißt "für alle". +- `box`: `min` und `max` sind Blockkoordinaten, beide einschließlich; die Box deckt `[min, max + 1]` + je Achse ab. +- `disc`: `center`, `radius` (Rand einschließlich) und `normal` (Richtung der Ebene, beliebig + ausgerichtet, muss kein Einheitsvektor sein). +- Ausgelöst wird beim Gehen und beim Fliegen: Die Bewegung zählt als Strecke, ein schneller + Elytra-Schritt durch eine dünne Scheibe löst also aus. Es entscheidet der Fußpunkt. +- Ein Portal löst einmal je Betreten aus; wer drinnen bleibt, löst nicht erneut aus. Nach einer + Weiterleitung gilt 3 s Abklingzeit. +- Mit `permission` schickt das Portal nur Spieler mit diesem Recht weiter; ohne Recht passiert + nichts und keine Abklingzeit läuft. +- Ein ungültiges Portal (leere `id` oder `task`, doppelte `id`, `radius <= 0`, `min > max`) bricht + den Start mit Welt, Id und Grund ab. + +Portale ändern: Task in CloudNet anlegen, `map.json` bearbeiten, die Lobby neu starten. Die Liste +wird nur beim Start gelesen. Befehle zum Bearbeiten im Setup-Server folgen mit einer späteren +Änderung. + ## Erwartete Columns einer Variante `titan.app-variant` schreibt beim Bauen die Avaje-Modulnamen aller in eine Variante eingebundenen @@ -642,6 +675,7 @@ als `provides`): | `tickle` | `EventNode.class`, `Clock.class` | `EventNode:titan` | - | | `elytra` | `EventNode.class`, `Scheduler.class` | `EventNode:titan` | `LobbyItem.class` | | `hotbar` | `EventNode.class` | `EventNode:titan` | `LobbyItems.class` | +| `portal` | `EventNode.class`, `LobbyPortals.class`, `Deliver.class`, `PermissionService.class`, `Clock.class` | `EventNode:titan` | - | | `season` (nur `cloudnet`) | `Scheduler.class`, `Clock.class`, `EventNode.class` | `EventNode:titan` | - | `EventNode` ist in jeder Zeile der einzige `requiresString`-Eintrag, weil es der einzige diff --git a/features/portal/build.gradle.kts b/features/portal/build.gradle.kts new file mode 100644 index 00000000..4a5d1173 --- /dev/null +++ b/features/portal/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("titan.column") +} + +dependencies { + implementation(libs.slf4j.api) +} diff --git a/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalIndex.java b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalIndex.java new file mode 100644 index 00000000..126cfd0e --- /dev/null +++ b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalIndex.java @@ -0,0 +1,72 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.CoordConversion; +import net.onelitefeather.titan.core.portal.HorizontalBounds; +import net.onelitefeather.titan.core.portal.Portal; + +/** + * Portals by chunk column, so a move only tests the few portals near it instead of all of them. + * Built once; read-only afterwards, so the move listener needs no locking. + */ +final class PortalIndex { + + private static final List NONE = List.of(); + + private final Map> byColumn = new HashMap<>(); + + PortalIndex(List portals) { + for (Portal portal : portals) { + HorizontalBounds bounds = portal.shape().horizontalBounds(); + int minChunkX = CoordConversion.globalToChunk(bounds.minX()); + int maxChunkX = CoordConversion.globalToChunk(bounds.maxX()); + int minChunkZ = CoordConversion.globalToChunk(bounds.minZ()); + int maxChunkZ = CoordConversion.globalToChunk(bounds.maxZ()); + for (int chunkX = minChunkX; chunkX <= maxChunkX; chunkX++) { + for (int chunkZ = minChunkZ; chunkZ <= maxChunkZ; chunkZ++) { + this.byColumn.computeIfAbsent(CoordConversion.chunkIndex(chunkX, chunkZ), key -> new ArrayList<>()).add(portal); + } + } + } + } + + /** + * The portals in the chunk columns of both ends of a move. Both, because a step can end in a + * column that does not hold the portal it started next to, and the other way round. + */ + List candidates(Point from, Point to) { + List atFrom = column(from); + List atTo = column(to); + if (atFrom.isEmpty() && atTo.isEmpty()) { + return NONE; + } + Set merged = new LinkedHashSet<>(atFrom); + merged.addAll(atTo); + return List.copyOf(merged); + } + + private List column(Point point) { + return this.byColumn.getOrDefault(CoordConversion.chunkIndex(CoordConversion.globalToChunk(point.x()), CoordConversion.globalToChunk(point.z())), NONE); + } +} diff --git a/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalModule.java b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalModule.java new file mode 100644 index 00000000..bdd6f9cc --- /dev/null +++ b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalModule.java @@ -0,0 +1,90 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import io.avaje.inject.PostConstruct; +import io.avaje.inject.PreDestroy; +import jakarta.inject.Named; +import jakarta.inject.Singleton; +import java.time.Clock; +import java.util.List; +import java.util.Objects; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.event.player.PlayerMoveEvent; +import net.onelitefeather.deliver.DeliverComponent; +import net.onelitefeather.titan.api.deliver.Deliver; +import net.onelitefeather.titan.core.module.FeatureNode; +import net.onelitefeather.titan.core.permission.PermissionService; +import net.onelitefeather.titan.core.portal.LobbyPortals; +import net.onelitefeather.titan.core.portal.Portal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Sends a player to a CloudNet task when they walk or fly through a portal of the lobby world. + * + *

The portals are read once at start: they are map data, and a change of the map file takes + * effect with the next restart. + */ +@Singleton +public final class PortalModule { + + static final int EVENT_PRIORITY = 900; + + private static final String ID = "portal"; + private static final Logger LOGGER = LoggerFactory.getLogger(PortalModule.class); + + private final EventNode titan; + private final LobbyPortals portals; + private final Deliver deliver; + private final PermissionService permissions; + private final Clock clock; + private FeatureNode node; + + public PortalModule(@Named(FeatureNode.TITAN_NODE) EventNode titan, LobbyPortals portals, Deliver deliver, PermissionService permissions, Clock clock) { + this.titan = Objects.requireNonNull(titan, "titan"); + this.portals = Objects.requireNonNull(portals, "portals"); + this.deliver = Objects.requireNonNull(deliver, "deliver"); + this.permissions = Objects.requireNonNull(permissions, "permissions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + @PostConstruct + void start() { + List all = this.portals.portals(); + LOGGER.info("Portal column started with {} portal(s)", all.size()); + PortalTrigger trigger = new PortalTrigger(new PortalIndex(all), this.permissions, this.clock); + // The player's current position is still the old one while PlayerMoveEvent runs, so + // (position, newPosition) is the segment the player is about to travel. + this.node = FeatureNode.attach(this.titan, ID, EVENT_PRIORITY).on(PlayerMoveEvent.class, event -> { + Player player = event.getPlayer(); + trigger.onMove(player.getUuid(), player.getPosition(), event.getNewPosition()).ifPresent(portal -> deliver(player, portal)); + }).on(PlayerDisconnectEvent.class, event -> trigger.forget(event.getPlayer().getUuid())); + } + + private void deliver(Player player, Portal portal) { + LOGGER.debug("Player {} entered portal '{}', delivering to task '{}'", player.getUuid(), portal.id(), portal.task()); + this.deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName(portal.task()).player(player).build()); + } + + @PreDestroy + void stop() { + this.node.close(); + } +} diff --git a/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalTrigger.java b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalTrigger.java new file mode 100644 index 00000000..398280ca --- /dev/null +++ b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/PortalTrigger.java @@ -0,0 +1,101 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.util.HashSet; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import net.minestom.server.coordinate.Point; +import net.onelitefeather.titan.core.permission.PermissionResult; +import net.onelitefeather.titan.core.permission.PermissionService; +import net.onelitefeather.titan.core.portal.Portal; + +/** + * Decides per move whether a player enters a portal that should now send them away. + * + *

A portal fires on the transition from "not touched" to "touched" only, so standing in it + * never repeats the delivery; after a delivery the player has a fixed cooldown against every + * portal, and an entry swallowed by the cooldown is not made up for later. A rejected player + * (missing permission) gets neither a delivery nor a cooldown. State is per player and owned by + * this instance. + */ +final class PortalTrigger { + + /** Fixed on purpose: a knob nobody needs would only be one more thing to mis-set. */ + static final Duration COOLDOWN = Duration.ofSeconds(3); + + /** Per player: which portals the last move touched, and until when deliveries are blocked. */ + private static final class PlayerState { + Set touched = Set.of(); + Instant cooldownUntil = Instant.MIN; + } + + private final PortalIndex index; + private final PermissionService permissions; + private final Clock clock; + private final Map states = new ConcurrentHashMap<>(); + + PortalTrigger(PortalIndex index, PermissionService permissions, Clock clock) { + this.index = Objects.requireNonNull(index, "index"); + this.permissions = Objects.requireNonNull(permissions, "permissions"); + this.clock = Objects.requireNonNull(clock, "clock"); + } + + /** + * Handles one move from {@code from} (the player's current feet) to {@code to}. + * + * @return the portal to deliver the player to; the cooldown starts with it, so the caller has + * to deliver + */ + Optional onMove(UUID playerId, Point from, Point to) { + PlayerState state = this.states.computeIfAbsent(playerId, id -> new PlayerState()); + Set touchedNow = new HashSet<>(); + Portal entered = null; + for (Portal portal : this.index.candidates(from, to)) { + if (!portal.shape().crossedBy(from, to)) { + continue; + } + touchedNow.add(portal.id()); + if (entered == null && !state.touched.contains(portal.id()) && mayUse(playerId, portal)) { + entered = portal; + } + } + Instant now = this.clock.instant(); + boolean coolingDown = now.isBefore(state.cooldownUntil); + state.touched = touchedNow; + if (entered == null || coolingDown) { + return Optional.empty(); + } + state.cooldownUntil = now.plus(COOLDOWN); + return Optional.of(entered); + } + + /** Drops everything remembered about {@code player}; called when they disconnect. */ + void forget(UUID playerId) { + this.states.remove(playerId); + } + + private boolean mayUse(UUID playerId, Portal portal) { + return portal.permission() == null || this.permissions.check(playerId, portal.permission()) == PermissionResult.ALLOWED; + } +} diff --git a/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/package-info.java b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/package-info.java new file mode 100644 index 00000000..47a70da9 --- /dev/null +++ b/features/portal/src/main/java/net/onelitefeather/titan/feature/portal/package-info.java @@ -0,0 +1,28 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +/** + * The {@code portal} column: sends a player to a CloudNet task when they walk or fly through a + * portal of the lobby world. See {@code docs/lobby-modules.md}, "Portale". + */ +@InjectModule(name = "portalColumn", requires = {EventNode.class, LobbyPortals.class, Deliver.class, PermissionService.class, Clock.class}, requiresString = {"net.minestom.server.event.EventNode:titan"}) +package net.onelitefeather.titan.feature.portal; + +import io.avaje.inject.InjectModule; +import java.time.Clock; +import net.minestom.server.event.EventNode; +import net.onelitefeather.titan.api.deliver.Deliver; +import net.onelitefeather.titan.core.permission.PermissionService; +import net.onelitefeather.titan.core.portal.LobbyPortals; diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/AdjustableClock.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/AdjustableClock.java new file mode 100644 index 00000000..547eda0d --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/AdjustableClock.java @@ -0,0 +1,59 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneId; + +/** + * A test-only {@link Clock} whose {@link #instant()} is set by the test, so a test can drive + * {@link PortalTrigger} without wall-clock time or {@link Thread#sleep(long)}. + */ +final class AdjustableClock extends Clock { + + private final ZoneId zone; + private Instant instant; + + AdjustableClock(Instant instant, ZoneId zone) { + this.instant = instant; + this.zone = zone; + } + + void advance(Duration duration) { + this.instant = this.instant.plus(duration); + } + + void set(Instant instant) { + this.instant = instant; + } + + @Override + public ZoneId getZone() { + return this.zone; + } + + @Override + public Clock withZone(ZoneId zone) { + return new AdjustableClock(this.instant, zone); + } + + @Override + public Instant instant() { + return this.instant; + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/ColumnArchitectureTest.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/ColumnArchitectureTest.java new file mode 100644 index 00000000..79aab97c --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/ColumnArchitectureTest.java @@ -0,0 +1,47 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import static com.tngtech.archunit.lang.syntax.ArchRuleDefinition.noClasses; + +import com.tngtech.archunit.core.importer.ImportOption; +import com.tngtech.archunit.junit.AnalyzeClasses; +import com.tngtech.archunit.junit.ArchTest; +import com.tngtech.archunit.lang.ArchRule; +import net.onelitefeather.titan.core.testfixtures.architecture.ColumnArchitectureRules; + +/** + * Applies the architecture rules every column shares to this column's own package, plus the + * portal's own rule: portals are map data, the column reads no configuration. + */ +@AnalyzeClasses(packages = "net.onelitefeather.titan.feature.portal", importOptions = ImportOption.DoNotIncludeTests.class) +class ColumnArchitectureTest { + + @ArchTest + static final ArchRule featuresRegisterListenersOnlyThroughFeatureNode = ColumnArchitectureRules.FEATURES_REGISTER_LISTENERS_ONLY_THROUGH_FEATURE_NODE; + + @ArchTest + static final ArchRule classesWithPostConstructAreSingleton = ColumnArchitectureRules.CLASSES_WITH_POST_CONSTRUCT_ARE_SINGLETON; + + @ArchTest + static final ArchRule featureModulesDoNotUseBeanScope = ColumnArchitectureRules.FEATURE_MODULES_DO_NOT_USE_BEAN_SCOPE; + + @ArchTest + static final ArchRule portalDoesNotDependOnAvajeConfig = noClasses().should().dependOnClassesThat().resideInAPackage("io.avaje.config..").because("portals are map data, not configuration keys"); + + ColumnArchitectureTest() { + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/FakePermissionService.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/FakePermissionService.java new file mode 100644 index 00000000..61109a59 --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/FakePermissionService.java @@ -0,0 +1,51 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; +import net.onelitefeather.titan.core.permission.PermissionResult; +import net.onelitefeather.titan.core.permission.PermissionService; + +/** + * A test-only {@link PermissionService}: every check answers {@link PermissionResult#NOT_SET} + * until {@link #set(UUID, String, PermissionResult)} says otherwise for that player and + * permission. + */ +final class FakePermissionService implements PermissionService { + + private final Map results = new HashMap<>(); + + FakePermissionService set(UUID playerId, String permission, PermissionResult result) { + this.results.put(key(playerId, permission), result); + return this; + } + + @Override + public PermissionResult check(UUID playerId, String permission) { + return this.results.getOrDefault(key(playerId, permission), PermissionResult.NOT_SET); + } + + @Override + public String name() { + return "fake"; + } + + private static String key(UUID playerId, String permission) { + return playerId + "/" + permission; + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalIndexTest.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalIndexTest.java new file mode 100644 index 00000000..5d7293e4 --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalIndexTest.java @@ -0,0 +1,91 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.util.List; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Portal; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class PortalIndexTest { + + private static Portal box(String id, Vec min, Vec max) { + return new Portal(id, new Box(min, max), "Survival", null); + } + + @DisplayName("A portal inside one chunk column is found from a position in that column") + @Test + void findsPortalFromItsOwnColumn() { + Portal portal = box("a", new Vec(2, 64, 2), new Vec(3, 65, 3)); + PortalIndex index = new PortalIndex(List.of(portal)); + + List found = index.candidates(new Pos(8, 64, 8), new Pos(9, 64, 8)); + + Assertions.assertEquals(List.of(portal), found, "both endpoints lie in the portal's column"); + } + + @DisplayName("A portal over a chunk border is indexed in both columns") + @Test + void portalOverChunkBorderIsFoundFromBothColumns() { + Portal portal = box("border", new Vec(15, 64, 2), new Vec(16, 65, 3)); + PortalIndex index = new PortalIndex(List.of(portal)); + + Assertions.assertEquals(List.of(portal), index.candidates(new Pos(5, 64, 5), new Pos(5, 64, 5)), "the west column must know the portal"); + Assertions.assertEquals(List.of(portal), index.candidates(new Pos(20, 64, 5), new Pos(20, 64, 5)), "the east column must know the portal"); + } + + @DisplayName("A move over the chunk border finds the portal through the other endpoint's column") + @Test + void movingAcrossBorderFindsPortalThroughOtherEndpoint() { + Portal portal = box("east", new Vec(20, 64, 2), new Vec(21, 65, 3)); + PortalIndex index = new PortalIndex(List.of(portal)); + + List found = index.candidates(new Pos(10, 64, 2), new Pos(20.5, 64, 2)); + + Assertions.assertEquals(List.of(portal), found, "the destination column holds the portal"); + } + + @DisplayName("A world without portals yields an empty list") + @Test + void worldWithoutPortalsYieldsEmptyList() { + PortalIndex index = new PortalIndex(List.of()); + + Assertions.assertTrue(index.candidates(new Pos(0, 64, 0), new Pos(1, 64, 1)).isEmpty(), "no portals, no candidates"); + } + + @DisplayName("A position in a column without portals yields nothing") + @Test + void columnWithoutPortalsYieldsNothing() { + PortalIndex index = new PortalIndex(List.of(box("a", new Vec(2, 64, 2), new Vec(3, 65, 3)))); + + Assertions.assertTrue(index.candidates(new Pos(100, 64, 100), new Pos(101, 64, 100)).isEmpty(), "a far column knows no portal"); + } + + @DisplayName("A portal found from both endpoints and from several columns is returned once") + @Test + void portalIsNeverReturnedTwice() { + Portal portal = box("wide", new Vec(10, 64, 10), new Vec(40, 65, 12)); + PortalIndex index = new PortalIndex(List.of(portal)); + + List found = index.candidates(new Pos(12, 64, 10), new Pos(36, 64, 10)); + + Assertions.assertEquals(List.of(portal), found, "dedup across endpoints and columns"); + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalModuleLeakTest.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalModuleLeakTest.java new file mode 100644 index 00000000..d38abc91 --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalModuleLeakTest.java @@ -0,0 +1,103 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.event.Event; +import net.minestom.server.event.EventNode; +import net.minestom.server.event.player.PlayerMoveEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.core.module.FeatureNode; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.testfixtures.EventListenerCounter; +import net.onelitefeather.titan.core.testfixtures.TestTitanNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +@ExtendWith(MicrotusExtension.class) +class PortalModuleLeakTest { + + private final AdjustableClock clock = new AdjustableClock(Instant.parse("2026-01-01T12:00:00Z"), ZoneOffset.UTC); + private final RecordingDeliver deliver = new RecordingDeliver(); + + private PortalModule module(TestTitanNode titan) { + Portal portal = new Portal("survival", new Box(new Vec(0, 64, 0), new Vec(1, 65, 1)), "Survival", null); + return new PortalModule(titan.node(), () -> List.of(portal), this.deliver, new FakePermissionService(), this.clock); + } + + @DisplayName("After stop the module's node is gone and a move into a portal delivers nothing") + @Test + void stopRemovesTheNodeAndTheListeners(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = module(titan); + module.start(); + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + player.teleport(new Pos(10.5, 64, 0.5)).join(); + Assertions.assertEquals(1, titan.node().findChildren("titan/portal").size(), "started module must own exactly one 'titan/portal' node"); + + module.stop(); + env.process().eventHandler().call(new PlayerMoveEvent(player, new Pos(0.5, 64, 0.5), true)); + + Assertions.assertTrue(titan.node().findChildren("titan/portal").isEmpty(), "stop must detach the node"); + Assertions.assertTrue(this.deliver.deliveries().isEmpty(), "a stopped module must not react to moves"); + } + } + + @DisplayName("Moving many times never adds a listener to the module's node") + @Test + void movingDoesNotAddListeners(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = module(titan); + module.start(); + Player player = env.createPlayer(env.createFlatInstance()); + List> children = titan.node().findChildren("titan/portal"); + int before = EventListenerCounter.countListeners(children.get(0)); + + for (int i = 0; i < 50; i++) { + env.process().eventHandler().call(new PlayerMoveEvent(player, new Pos(10.5 + i, 64, 0.5), true)); + } + + Assertions.assertEquals(before, EventListenerCounter.countListeners(children.get(0)), "handling moves must not register listeners"); + module.stop(); + } + } + + @DisplayName("The portal node sits at priority 900 and coexists with a node at another priority") + @Test + void priorityIs900AndDoesNotCollide(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + FeatureNode other = FeatureNode.attach(titan.node(), "other", 200); + PortalModule module = module(titan); + + module.start(); + + Assertions.assertEquals(900, titan.node().findChildren("titan/portal").get(0).getPriority(), "priority must match the documented 900"); + module.stop(); + other.close(); + } + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalModuleTest.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalModuleTest.java new file mode 100644 index 00000000..ec6e6b7e --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalModuleTest.java @@ -0,0 +1,211 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.List; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.minestom.server.entity.Player; +import net.minestom.server.event.player.PlayerDisconnectEvent; +import net.minestom.server.event.player.PlayerMoveEvent; +import net.minestom.server.instance.Instance; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.core.permission.PermissionResult; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import net.onelitefeather.titan.core.testfixtures.TestTitanNode; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +/** Every test gets its own {@code Env}, titan node, module, deliver, permissions and clock. */ +@ExtendWith(MicrotusExtension.class) +class PortalModuleTest { + + private static final Pos OUTSIDE = new Pos(10.5, 64, 0.5); + private static final Pos INSIDE = new Pos(0.5, 64, 0.5); + private static final Pos FAR = new Pos(10.5, 64, 5.5); + + private final AdjustableClock clock = new AdjustableClock(Instant.parse("2026-01-01T12:00:00Z"), ZoneOffset.UTC); + private final RecordingDeliver deliver = new RecordingDeliver(); + private final FakePermissionService permissions = new FakePermissionService(); + + private static Portal survivalBox() { + return new Portal("survival", new Box(new Vec(0, 64, 0), new Vec(1, 65, 1)), "Survival", null); + } + + private PortalModule start(TestTitanNode titan, Portal... portals) { + PortalModule module = new PortalModule(titan.node(), () -> List.of(portals), this.deliver, this.permissions, this.clock); + module.start(); + return module; + } + + /** Fires the move event before the position changes, exactly as Minestom does. */ + private static void move(Env env, Player player, Pos to) { + env.process().eventHandler().call(new PlayerMoveEvent(player, to, true)); + player.teleport(to).join(); + env.tick(); + } + + private Player playerAt(Env env, Pos position) { + Instance instance = env.createFlatInstance(); + Player player = env.createPlayer(instance); + player.teleport(position).join(); + return player; + } + + @DisplayName("A player walking into a box is delivered to the portal's task exactly once") + @Test + void walkingIntoBoxDeliversOnce(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan, survivalBox()); + Player player = playerAt(env, OUTSIDE); + + move(env, player, INSIDE); + + Assertions.assertEquals(List.of(new RecordingDeliver.Delivery(player, "Survival")), this.deliver.deliveries(), "entering the box must deliver to 'Survival' once"); + module.stop(); + } + } + + @DisplayName("An 8-block elytra step through a 1-block-thin disc is delivered") + @Test + void elytraStepThroughThinDiscDelivers(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan, new Portal("gate", new Disc(new Vec(0, 64, 0), 3, new Vec(1, 0, 0)), "Gate", null)); + Player player = playerAt(env, new Pos(-4, 64, 0)); + + move(env, player, new Pos(4, 64, 0)); + + Assertions.assertEquals(List.of(new RecordingDeliver.Delivery(player, "Gate")), this.deliver.deliveries(), "the move segment crosses the disc although neither position is near it"); + module.stop(); + } + } + + @DisplayName("Staying in the box delivers no second time") + @Test + void stayingDoesNotDeliverAgain(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan, survivalBox()); + Player player = playerAt(env, OUTSIDE); + move(env, player, INSIDE); + this.clock.advance(Duration.ofSeconds(10)); + + move(env, player, new Pos(1.2, 64, 0.5)); + + Assertions.assertEquals(1, this.deliver.deliveries().size(), "moving within the box after the cooldown must not deliver again"); + module.stop(); + } + } + + @DisplayName("Re-entering within the cooldown delivers nothing, after advancing the clock by 3 s it delivers") + @Test + void reenteringRespectsCooldown(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan, survivalBox()); + Player player = playerAt(env, OUTSIDE); + move(env, player, INSIDE); + move(env, player, OUTSIDE); + move(env, player, FAR); + + move(env, player, INSIDE); + Assertions.assertEquals(1, this.deliver.deliveries().size(), "re-entering right away is inside the cooldown"); + + move(env, player, OUTSIDE); + move(env, player, FAR); + this.clock.advance(Duration.ofSeconds(3)); + move(env, player, INSIDE); + + Assertions.assertEquals(2, this.deliver.deliveries().size(), "re-entering 3 s after the delivery must deliver again"); + module.stop(); + } + } + + @DisplayName("A player without the permission is not delivered and starts no cooldown") + @Test + void missingPermissionDeliversNothing(Env env) { + for (PermissionResult missing : List.of(PermissionResult.NOT_SET, PermissionResult.DENIED)) { + RecordingDeliver localDeliver = new RecordingDeliver(); + try (TestTitanNode titan = TestTitanNode.attach(env)) { + Portal vip = new Portal("vip", new Box(new Vec(0, 64, 0), new Vec(1, 65, 1)), "Vip", "titan.portal.vip"); + Portal open = new Portal("survival", new Box(new Vec(20, 64, 0), new Vec(21, 65, 1)), "Survival", null); + PortalModule module = new PortalModule(titan.node(), () -> List.of(vip, open), localDeliver, this.permissions, this.clock); + module.start(); + Player player = playerAt(env, OUTSIDE); + this.permissions.set(player.getUuid(), "titan.portal.vip", missing); + + move(env, player, INSIDE); + move(env, player, new Pos(25.5, 64, 0.5)); + move(env, player, new Pos(20.5, 64, 0.5)); + + Assertions.assertEquals(List.of(new RecordingDeliver.Delivery(player, "Survival")), localDeliver.deliveries(), "with " + missing + " only the open portal delivers, immediately after the rejected one"); + module.stop(); + } + } + } + + @DisplayName("A player with the permission is delivered") + @Test + void grantedPermissionDelivers(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan, new Portal("vip", new Box(new Vec(0, 64, 0), new Vec(1, 65, 1)), "Vip", "titan.portal.vip")); + Player player = playerAt(env, OUTSIDE); + this.permissions.set(player.getUuid(), "titan.portal.vip", PermissionResult.ALLOWED); + + move(env, player, INSIDE); + + Assertions.assertEquals(List.of(new RecordingDeliver.Delivery(player, "Vip")), this.deliver.deliveries(), "ALLOWED must deliver to 'Vip'"); + module.stop(); + } + } + + @DisplayName("A world without portals delivers nobody") + @Test + void worldWithoutPortalsDeliversNothing(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan); + Player player = playerAt(env, OUTSIDE); + + move(env, player, INSIDE); + + Assertions.assertTrue(this.deliver.deliveries().isEmpty(), "no portals, no delivery"); + module.stop(); + } + } + + @DisplayName("A disconnect clears the player's state so a reconnect starts fresh") + @Test + void disconnectClearsState(Env env) { + try (TestTitanNode titan = TestTitanNode.attach(env)) { + PortalModule module = start(titan, survivalBox()); + Player player = playerAt(env, OUTSIDE); + move(env, player, INSIDE); + move(env, player, OUTSIDE); + + env.process().eventHandler().call(new PlayerDisconnectEvent(player)); + move(env, player, INSIDE); + + Assertions.assertEquals(2, this.deliver.deliveries().size(), "the state of a disconnected player is gone, cooldown included"); + module.stop(); + } + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalTriggerTest.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalTriggerTest.java new file mode 100644 index 00000000..5cf52bd2 --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/PortalTriggerTest.java @@ -0,0 +1,236 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.time.Duration; +import java.time.Instant; +import java.time.ZoneOffset; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.coordinate.Vec; +import net.onelitefeather.titan.core.permission.PermissionResult; +import net.onelitefeather.titan.core.portal.Box; +import net.onelitefeather.titan.core.portal.Disc; +import net.onelitefeather.titan.core.portal.Portal; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** Every test builds its own trigger, permissions and clock (F.I.R.S.T. - Independent). */ +class PortalTriggerTest { + + private static final Instant START = Instant.parse("2026-01-01T12:00:00Z"); + private static final Point OUTSIDE = new Pos(10.5, 64.5, 0.5); + private static final Point INSIDE = new Pos(0.5, 64.5, 0.5); + + private final AdjustableClock clock = new AdjustableClock(START, ZoneOffset.UTC); + private final List delivered = new ArrayList<>(); + private final FakePermissionService permissions = new FakePermissionService(); + + private static Portal box(String id, Vec min, Vec max, String task, String permission) { + return new Portal(id, new Box(min, max), task, permission); + } + + private static Portal openBox() { + return box("survival", new Vec(0, 64, 0), new Vec(1, 65, 1), "Survival", null); + } + + private static UUID player() { + return UUID.randomUUID(); + } + + private void move(PortalTrigger trigger, UUID player, Point from, Point to) { + trigger.onMove(player, from, to).ifPresent(portal -> this.delivered.add(portal.task())); + } + + private PortalTrigger trigger(Portal... portals) { + return new PortalTrigger(new PortalIndex(List.of(portals)), this.permissions, this.clock); + } + + @DisplayName("Walking into a box delivers the player to the portal's task exactly once") + @Test + void enteringDeliversOnce() { + PortalTrigger trigger = trigger(openBox()); + UUID player = player(); + + move(trigger, player, OUTSIDE, INSIDE); + + Assertions.assertEquals(List.of("Survival"), this.delivered, "entering 'survival' must deliver to its task once"); + } + + @DisplayName("Staying inside a box does not deliver again, even after the cooldown has passed") + @Test + void stayingDoesNotRepeat() { + PortalTrigger trigger = trigger(openBox()); + UUID player = player(); + move(trigger, player, OUTSIDE, INSIDE); + + this.clock.advance(Duration.ofSeconds(10)); + move(trigger, player, INSIDE, new Pos(1.2, 64.5, 0.5)); + + Assertions.assertEquals(1, this.delivered.size(), "staying in 'survival' 10 s after the delivery must not deliver again"); + } + + @DisplayName("Leaving and re-entering within 3 seconds does not deliver again") + @Test + void reenteringDuringCooldownDoesNothing() { + PortalTrigger trigger = trigger(openBox()); + UUID player = player(); + move(trigger, player, OUTSIDE, INSIDE); + move(trigger, player, INSIDE, OUTSIDE); + move(trigger, player, OUTSIDE, new Pos(10.5, 64.5, 5.5)); + this.clock.advance(Duration.ofSeconds(2)); + + move(trigger, player, new Pos(10.5, 64.5, 5.5), INSIDE); + + Assertions.assertEquals(1, this.delivered.size(), "re-entering 2 s after the delivery is inside the 3 s cooldown"); + } + + @DisplayName("Leaving and re-entering 3 seconds after the delivery delivers again") + @Test + void reenteringAfterCooldownDelivers() { + PortalTrigger trigger = trigger(openBox()); + UUID player = player(); + move(trigger, player, OUTSIDE, INSIDE); + move(trigger, player, INSIDE, OUTSIDE); + move(trigger, player, OUTSIDE, new Pos(10.5, 64.5, 5.5)); + this.clock.advance(Duration.ofSeconds(3)); + + move(trigger, player, new Pos(10.5, 64.5, 5.5), INSIDE); + + Assertions.assertEquals(2, this.delivered.size(), "re-entering exactly 3 s after the delivery must deliver"); + } + + @DisplayName("A touch during the cooldown is not made up for once the cooldown is over") + @Test + void touchDuringCooldownIsNotCaughtUpLater() { + PortalTrigger trigger = trigger(openBox()); + UUID player = player(); + move(trigger, player, OUTSIDE, INSIDE); + move(trigger, player, INSIDE, OUTSIDE); + move(trigger, player, OUTSIDE, new Pos(10.5, 64.5, 5.5)); + move(trigger, player, new Pos(10.5, 64.5, 5.5), INSIDE); + this.clock.advance(Duration.ofSeconds(10)); + + move(trigger, player, INSIDE, new Pos(1.2, 64.5, 0.5)); + + Assertions.assertEquals(1, this.delivered.size(), "an entry swallowed by the cooldown must not fire while the player stays"); + } + + @DisplayName("Flying through a disc delivers, and a second pass after the cooldown delivers again") + @Test + void discPassesDeliver() { + Portal disc = new Portal("gate", new Disc(new Vec(0, 64, 0), 3, new Vec(1, 0, 0)), "Gate", null); + PortalTrigger trigger = trigger(disc); + UUID player = player(); + Point west = new Pos(-2, 64, 0); + Point east = new Pos(2, 64, 0); + + move(trigger, player, west, east); + move(trigger, player, east, new Pos(4, 64, 0)); + this.clock.advance(Duration.ofSeconds(3)); + move(trigger, player, new Pos(4, 64, 0), west); + + Assertions.assertEquals(2, this.delivered.size(), "both passes through 'gate' (there and back, 3 s apart) must deliver"); + } + + @DisplayName("A step of 8 blocks that jumps over a 1-block-thin disc delivers") + @Test + void fastStepOverThinDiscDelivers() { + Portal disc = new Portal("gate", new Disc(new Vec(0, 64, 0), 3, new Vec(1, 0, 0)), "Gate", null); + PortalTrigger trigger = trigger(disc); + UUID player = player(); + + move(trigger, player, new Pos(-4, 64, 0), new Pos(4, 64, 0)); + + Assertions.assertEquals(1, this.delivered.size(), "the segment crosses the disc although neither endpoint is near it"); + } + + @DisplayName("Two players do not influence each other's cooldown") + @Test + void playersAreIndependent() { + PortalTrigger trigger = trigger(openBox()); + UUID first = player(); + UUID second = player(); + + move(trigger, first, OUTSIDE, INSIDE); + move(trigger, second, OUTSIDE, INSIDE); + + Assertions.assertEquals(2, this.delivered.size(), "the second player's entry is not blocked by the first player's cooldown"); + } + + @DisplayName("The cooldown covers every portal, not only the one just used") + @Test + void cooldownCoversAllPortals() { + Portal other = box("creative", new Vec(20, 64, 0), new Vec(21, 65, 1), "Creative", null); + PortalTrigger trigger = trigger(openBox(), other); + UUID player = player(); + move(trigger, player, OUTSIDE, INSIDE); + this.clock.advance(Duration.ofSeconds(1)); + + move(trigger, player, new Pos(25.5, 64.5, 0.5), new Pos(20.5, 64.5, 0.5)); + + Assertions.assertEquals(1, this.delivered.size(), "entering 'creative' 1 s after 'survival' is inside the cooldown"); + } + + @DisplayName("Forgetting a player clears the latch and the cooldown") + @Test + void forgetClearsState() { + PortalTrigger trigger = trigger(openBox()); + UUID player = player(); + move(trigger, player, OUTSIDE, INSIDE); + + trigger.forget(player); + move(trigger, player, OUTSIDE, INSIDE); + + Assertions.assertEquals(2, this.delivered.size(), "after the disconnect cleanup the same player starts from scratch"); + } + + @DisplayName("A player without the portal's permission is neither delivered nor put on cooldown") + @Test + void missingPermissionDoesNothingAndNoCooldown() { + for (PermissionResult missing : List.of(PermissionResult.NOT_SET, PermissionResult.DENIED)) { + this.delivered.clear(); + FakePermissionService localPermissions = new FakePermissionService(); + Portal restricted = box("vip", new Vec(0, 64, 0), new Vec(1, 65, 1), "Vip", "titan.portal.vip"); + Portal open = box("survival", new Vec(20, 64, 0), new Vec(21, 65, 1), "Survival", null); + PortalTrigger trigger = new PortalTrigger(new PortalIndex(List.of(restricted, open)), localPermissions, this.clock); + UUID player = player(); + localPermissions.set(player, "titan.portal.vip", missing); + + move(trigger, player, OUTSIDE, INSIDE); + move(trigger, player, new Pos(25.5, 64.5, 0.5), new Pos(20.5, 64.5, 0.5)); + + Assertions.assertEquals(List.of("Survival"), this.delivered, "with " + missing + " 'vip' must do nothing and leave the open portal usable at once"); + } + } + + @DisplayName("A player with the portal's permission is delivered") + @Test + void grantedPermissionDelivers() { + Portal restricted = box("vip", new Vec(0, 64, 0), new Vec(1, 65, 1), "Vip", "titan.portal.vip"); + PortalTrigger trigger = trigger(restricted); + UUID player = player(); + this.permissions.set(player, "titan.portal.vip", PermissionResult.ALLOWED); + + move(trigger, player, OUTSIDE, INSIDE); + + Assertions.assertEquals(List.of("Vip"), this.delivered, "ALLOWED must deliver to the portal's task"); + } +} diff --git a/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/RecordingDeliver.java b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/RecordingDeliver.java new file mode 100644 index 00000000..95730c90 --- /dev/null +++ b/features/portal/src/test/java/net/onelitefeather/titan/feature/portal/RecordingDeliver.java @@ -0,0 +1,45 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.feature.portal; + +import java.util.ArrayList; +import java.util.List; +import net.minestom.server.entity.Player; +import net.onelitefeather.deliver.DeliverComponent; +import net.onelitefeather.titan.api.deliver.Deliver; + +/** + * A test-only {@link Deliver} that records every delivery instead of sending one, so a test can + * assert which player was sent to which destination. + */ +final class RecordingDeliver implements Deliver { + + /** One recorded call to {@link #sendPlayer}. */ + record Delivery(Player player, String taskName) { + } + + private final List deliveries = new ArrayList<>(); + + @Override + public void sendPlayer(Player player, DeliverComponent component) { + String taskName = component instanceof DeliverComponent.TaskComponent task ? task.taskName() : null; + this.deliveries.add(new Delivery(player, taskName)); + } + + List deliveries() { + return List.copyOf(this.deliveries); + } +} diff --git a/openspec/changes/lobby-portals/.openspec.yaml b/openspec/changes/lobby-portals/.openspec.yaml new file mode 100644 index 00000000..3b4044e8 --- /dev/null +++ b/openspec/changes/lobby-portals/.openspec.yaml @@ -0,0 +1,2 @@ +schema: olf-spec-driven +created: 2026-09-29 diff --git a/openspec/changes/lobby-portals/design.md b/openspec/changes/lobby-portals/design.md new file mode 100644 index 00000000..14079bdb --- /dev/null +++ b/openspec/changes/lobby-portals/design.md @@ -0,0 +1,146 @@ +# Design + +## Context + +Motivation: siehe proposal.md. Anforderungen: `specs/lobby-portals`. + +Ist-Zustand: + +- `settings.gradle.kts` erfasst jedes Verzeichnis unter `features/` mit `build.gradle.kts` als Column; `titan.column` (buildSrc) lässt eine Column nur von `core` abhängen (plus Minestom, Aves, Avaje Inject). Die Varianten `cloudnet` und `local` nehmen jede Column automatisch auf; `local` schließt nur `season` aus. Eine neue Column braucht daher keine Änderung an Build-Dateien. +- `core` enthält `LobbySpawn` (lesen, nicht cachen), `FeatureNode` (`attach(parent, id, priority)`, Listener über `on(...)`; die Prioritäten 100 bis 800 sind vergeben, eine doppelte Position bricht den Start ab), `PermissionService`/`PermissionResult` und `net.onelitefeather.titan.api.deliver.Deliver`. `runtime` deklariert in `package-info.java` per `@InjectModule(provides = ...)`, was Columns anfordern dürfen, und stellt in `PlatformBeans` `MapProvider`, `LobbySpawn`, `Deliver`, `Clock` und den `titan`-Knoten bereit. +- `common/map`: `LobbyMap extends BaseMap` (Aves) mit Spawn, Name, Bauern; `MapProvider` liest `worlds//map.json` mit einem Gson, in dem `PositionGsonAdapter` für `Pos` und `Vec` registriert ist. Die Saison-Column wählt nur den Weltnamen (`LobbyWorldChoice`); die Karte der Saisonwelt lädt dieselbe Stelle. +- Der Navigator leitet mit `Deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName(task).player(player).build())` weiter und prüft ein Recht mit `PermissionService.check(uuid, permission) == ALLOWED` (`NOT_SET` und `DENIED` zählen als nicht erteilt). +- `SpawnBoundsListener` zeigt das Muster für `PlayerMoveEvent`: ein Listener, den das Modul mit `FeatureNode.attach(...).on(PlayerMoveEvent.class, ...)` verdrahtet. +- Vorlage für die Scheibenmathematik: Voyagers `RingPass.crosses(from, to, ring)` (`Voyager/voyager/race/src/main/java/net/elytrarace/voyager/race/collision/RingPass.java`) samt `RingPassTest`. Sie wird kopiert (keine Abhängigkeit auf Voyager), die Quelle steht im Warum-Kommentar. + +## Goals / Non-Goals + +**Goals:** +- Portale als Kartendaten mit einem stabilen JSON-Format, das der Setup-Server später schreibt. +- Reine, ohne Server testbare Formmathematik und Auslöse-Logik; Minestom nur an der Kante. +- Eine Bewegung kostet im Normalfall zwei Hash-Abfragen. + +**Non-Goals:** +- Befehle zum Bearbeiten der Portale (`setup-portal-command`), Ziel „einzelner Server“, Erreichbarkeitsprüfung/Bridge-Warnung, Nachrichten, Effekte, Neuladen zur Laufzeit. + +## Decisions + +### D1: Vertrag der Kartendaten (JSON in `map.json`) + +Die Liste `portals` steht auf oberster Ebene der `map.json`, neben `spawn`. Der Vertrag, den der Setup-Server später schreiben MUSS: + +```json +"portals": [ + {"id": "survival", "task": "Survival", "shape": {"type": "box", "min": {"x": 10, "y": 64, "z": 10}, "max": {"x": 14, "y": 68, "z": 11}}}, + {"id": "elytra-ring", "task": "ElytraRace", "permission": null, "shape": {"type": "disc", "center": {"x": 0.5, "y": 72, "z": 40.5}, "radius": 5.5, "normal": {"x": 0, "y": 0, "z": 1}}} +] +``` + +- `id` (Pflicht, eindeutig je Welt, nicht leer), `task` (Pflicht, nicht leer, Name des CloudNet-Tasks), `permission` (optional; `null` oder fehlend heißt „für alle“), `shape` (Pflicht) mit dem Unterscheidungsfeld `type`. +- `box`: `min`, `max` sind **Blockkoordinaten, beide einschließlich**. Die Box deckt in Weltkoordinaten `[min, max + 1]` je Achse ab (im Beispiel x 10 bis 15, y 64 bis 69, z 10 bis 12). Ganze Zahlen und Kommazahlen sind erlaubt; `min <= max` je Achse. +- `disc`: `center` (Weltkoordinaten), `radius > 0` (Rand einschließlich), `normal` (Richtung der Scheibenebene, beliebig ausgerichtet, Länge > 0). Die Lobby normiert `normal` beim Laden; Handeingabe muss kein Einheitsvektor sein. +- Punkte sind `{x, y, z}`, wie der vorhandene `PositionGsonAdapter` sie für `Vec` liest und schreibt. +- Unbekannte Felder werden ignoriert (Gson-Standard), damit ältere Lobbys neuere Dateien nicht brechen; ein unbekannter `type` ist dagegen ein Fehler (D6). + +Built-in geprüft: Gson liegt über Aves im Build und liest die `map.json` bereits; ein eigenes Format oder eine zweite Datei (`portals.json` wie in #222) entfällt, weil die Portale zur Welt gehören und Saisonwelten sie so von selbst mitbringen. +Test: Unit (Gson-Rundreise): das Beispiel oben wird gelesen und wieder geschrieben; ein unbekanntes Feld wird ignoriert; fehlendes `permission` und `null` ergeben dasselbe. + +### D2: Typen in `core`, Laden in `common`, Bereitstellung in `runtime` + +`core`, Paket `net.onelitefeather.titan.core.portal` (Columns sehen nur `core`): + +- `sealed interface PortalShape permits Box, Disc` mit `boolean crossedBy(Point from, Point to)` und `HorizontalBounds horizontalBounds()` (kleinstes achsenparalleles Rechteck in X/Z, für den Index; bei `disc` konservativ Mittelpunkt ± Radius). +- `record Box(Vec min, Vec max)` und `record Disc(Vec center, double radius, Vec normal)`; die Records halten nur Daten und prüfen nichts; die Prüfung liegt gebündelt im `PortalValidator` (D6). `Disc` liefert `normal` normiert (Berechnung beim ersten Gebrauch, nur bei gültiger Länge). +- `record Portal(String id, PortalShape shape, String task, @Nullable String permission)`; +- `final class PortalValidator` (D6): reine, statische Prüfung, wiederverwendbar durch `runtime` (Start) und den Setup-Editor (Chat-Fehler). +- `interface LobbyPortals { List portals(); }`: unveränderliche Liste, leer statt `null`. Eigener Typ statt `List`, wie bei `LobbySpawn`: eine nackte Liste wäre eine mehrdeutige Bean. + +`common/map`: `LobbyMap` bekommt ein Feld `portals` (`List`), einen null-sicheren Zugriff `portals()` (Gson baut ohne Konstruktor, eine fehlende Liste bleibt `null`) und `Builder.portals(List)`; `LobbyMap.lobbyMapBuilder(LobbyMap)` und jeder andere Kopierweg übernehmen die Portale mit (heute kopiert er nur Spawn, Name, Bauern); sonst würden `/setup map setspawn|setname|setauthor` des Setup-Servers Portale still löschen. Ein Rundreise-/Kopiertest sichert das ab (Aufgabe 2.2). Ein neuer `PortalGsonAdapter` (`JsonDeserializer`/`JsonSerializer`) entscheidet über `shape.type`; `MapProvider` registriert ihn im vorhandenen Gson. `Vec` liest der bereits registrierte `PositionGsonAdapter` über den `JsonDeserializationContext`. + +`runtime`: `PlatformBeans.lobbyPortals(MapProvider)` liefert `() -> mapProvider.getActiveLobby().portals()` (wie `LobbySpawn`), und `package-info.java` nimmt `LobbyPortals.class` in `provides` auf. + +Abweichung vom ersten Entwurf: Die Prüfung sitzt nicht in `PlatformBeans`, sondern beim Laden der Karte (D6). Ein Fehler dort kennt Welt, Id und Grund, und die Karte entsteht ohnehin beim Start. + +Built-in geprüft: Records und `sealed` (Java 25) tragen die Formen, Minestoms `Vec`/`Point` die Koordinaten. Verworfen: Coris `CuboidShape` (#222; zusätzliche Abhängigkeit für zwei Formen) und Portal-Typen in der Column (der Setup-Server soll sie später ohne die Column benutzen können). +SOLID: SRP (Form, Portal, Liste, Laden getrennt), DIP (die Column hängt an `LobbyPortals` aus `core`), OCP (weitere Form = neuer `permits`-Eintrag plus Adapterzweig). +Test: Unit je Record (Validierung, Normierung); Unit `LobbyMap` (Rundreise, `portals()` ohne Liste ist leer, Builder-Kopie); Integration: das Bean liefert die Portale der aktiven Karte, in einer Saisonwelt die der Saisonwelt. + +### D3: Formmathematik als Strecken-Test + +- `Disc.crossedBy(from, to)`: Voyagers `RingPass.crosses` in derselben Rechnung: `step = to - from`; `denominator = normal · step`; bei `|denominator| < 1e-8` gilt „parallel“ und das Ergebnis ist `false`; `t = normal · (center - from) / denominator` muss in `[0, 1]` liegen; der Schnittpunkt `from + step * t` darf höchstens `radius` vom Mittelpunkt entfernt sein (Rand einschließlich, keine Toleranz). Die Quelle steht im Warum-Kommentar. +- `Box.crossedBy(from, to)`: Slab-Methode auf dem Quader `[min, max + 1]`: je Achse Eintritts- und Austrittsparameter, `tNear = max(...)`, `tFar = min(...)`; getroffen, wenn `tNear <= tFar` und `[tNear, tFar]` das Segment `[0, 1]` schneidet. Liegt `to` im Quader, ist das Ergebnis `true`; das deckt „neuer Standort im Bereich“ ab. Eine Achse ohne Bewegung prüft nur, ob der Ausgangswert im Slab liegt. Auch `from` im Quader zählt als berührt; den Übergang entscheidet D4, nicht die Form. +- Die Bewegung geht vom Fußpunkt aus: alter Standort `player.getPosition()`, neuer `event.getNewPosition()` des `PlayerMoveEvent`. + +Built-in geprüft: Minestom bringt Bounding-Box-Kollision für Entitäten, aber weder einen Segment-gegen-Quader- noch einen Segment-gegen-Scheibe-Test. Ein reiner Endpunkttest (wie in #222) ist verworfen: Ein Elytra-Spieler mit mehreren Blöcken pro Tick überspringt sonst dünne Ringe. +SOLID: SRP (Formen sind zustandslose Funktionen), LSP (beide Formen erfüllen denselben Vertrag „Strecke berührt Bereich“). +Test (Unit, ohne Server, zuerst): Voyagers `RingPassTest`-Fälle übernommen (Mitte, Rand, knapp außerhalb, parallel, Ebene nicht erreicht, Ebene hinter dem Segment, rückwärts, geneigt Mitte und außermittig, Nullschritt, Rand mit gemeinsamem X- und Y-Versatz, geneigter Ring mit echtem Z-Schritt); Slab-Fälle (Segment durch die Box, endet in der Box, startet in der Box, geht daneben vorbei, achsenparallel innerhalb und außerhalb des Slabs, Streifschuss an Kante und Ecke, `max + 1` einschließlich, `max + 1.0001` außerhalb); ein **schneller Elytra-Schritt**, der eine 1 Block dünne Scheibe mit 8 Blöcken Schrittweite überspringt (beide Endpunkte weit von der Ebene) und trotzdem `true` ergibt. + +### D4: Auslöse-Logik (Übergang und Abklingzeit) als reine Klasse + +Eine paketinterne Klasse `PortalTrigger` in der Column hält je Spieler-UUID (in einer `ConcurrentHashMap` der Instanz, kein statischer Zustand) die Menge der zuletzt berührten Portal-Ids und den Zeitpunkt, bis zu dem die Abklingzeit gilt. `Optional onMove(UUID, Point from, Point to)`: + +1. Kandidaten aus dem Index (D5) holen; sind sie leer und der Zustand leer, sofort zurück. +2. Für jeden Kandidaten `crossedBy(from, to)` berechnen; das Ergebnis ist die neue Menge „berührt“. +3. Ein Portal löst aus, wenn es jetzt berührt ist und in der vorherigen Menge nicht enthalten war (Übergang, wie `PORTAL_INSIDE` in #222). Die neue Menge ersetzt die alte, **auch** bei Abklingzeit oder fehlendem Recht, damit langes Verweilen später nicht auslöst. +4. Liegt `clock.instant()` vor dem Ende der Abklingzeit, löst nichts aus. Sonst prüft der Aufrufer das Recht (D7); erst eine tatsächliche Weiterleitung setzt die Abklingzeit auf `now + 3 s`. +5. Bei `disc` ist „berührt“ nur der Schritt, der die Scheibe durchquert; der nächste Schritt löscht die Berührung, damit ein späterer Durchflug wieder auslöst. Bei `box` bleibt „berührt“, solange die Strecke die Box berührt (Verweilen). +6. Den Zustand eines Spielers entfernt das Modul bei `PlayerDisconnectEvent`. + +Die 3 Sekunden sind eine Konstante im Code (`Duration.ofSeconds(3)`), keine Konfiguration (Seltenes in Code statt Config). `java.time.Clock` kommt über den Konstruktor; `runtime` stellt die Bean bereits bereit. + +Built-in geprüft: `ConcurrentHashMap` und `java.time` genügen; Minestom hat kein Cooldown-Werkzeug für Spielerzustand. Ein Scheduler-Timer zum Löschen der Abklingzeit ist verworfen (der Zeitstempelvergleich ist einfacher und braucht keinen Thread). +SOLID: SRP (der Trigger kennt weder Minestom noch Recht noch `Deliver`), DIP (Uhr injiziert). +Test (Unit, Fake-`Clock`, frischer Trigger je Test): Betreten löst einmal aus; Verweilen löst nicht erneut aus, auch nach Ablauf der Abklingzeit; Verlassen und Wiederbetreten innerhalb von 3 s löst nicht aus; nach 3 s löst es aus; Scheibe: Durchqueren löst, ein zweiter Durchflug nach der Abklingzeit löst wieder; zwei Spieler beeinflussen sich nicht; Disconnect räumt den Zustand auf. + +### D5: Chunk-Spalten-Index + +`PortalIndex` (Column, paketintern) wird einmal beim Start aus `LobbyPortals.portals()` gebaut: Schlüssel ist der Chunk-Index der Spalte (`ChunkUtils.getChunkIndex(chunkX, chunkZ)` aus Minestom), Wert sind die Portale, deren `horizontalBounds()` die Spalte berühren. `candidates(from, to)` schlägt die Spalten **beider** Endpunkte nach und liefert die Vereinigung ohne Doppelte. Eine Bewegung in einer Spalte ohne Portal kostet zwei Hash-Abfragen und liefert eine gemeinsame leere Liste. + +Der Index wird zur Laufzeit nicht neu gebaut (Non-Goal). Wer die `map.json` ändert, startet die Lobby neu; das entspricht dem Weltwechsel der Saison. + +Built-in geprüft: `ChunkUtils` und `HashMap` genügen. Verworfen: linearer Durchlauf aller Portale je Bewegung (Bewegungen sind die häufigsten Events der Lobby) und ein Raumbaum (bei wenigen Portalen je Welt unnötig). +Grenze: Nur die Spalten der beiden Endpunkte zählen. Eine einzelne Bewegung, die mehr als eine Chunkbreite (16 Blöcke) überbrückt und ein Portal ausschließlich in einer Zwischenspalte träfe, wird nicht erkannt. Elytra-Schritte liegen im Bereich weniger Blöcke; Teleports lösen kein `PlayerMoveEvent` aus. +SOLID: SRP (der Index kennt nur Grenzen und Spalten). +Test (Unit): Ein Portal innerhalb einer Spalte wird von beiden Endpunkten gefunden; ein Portal über einer Chunkgrenze steht in beiden Spalten; eine Bewegung über die Grenze findet es über den Endpunkt in der anderen Spalte; eine Welt ohne Portale liefert die leere Liste; ein Portal kommt nicht doppelt, wenn beide Endpunkte in seinen Spalten liegen. + +### D6: Ungültige Portale brechen den Start ab; die Prüfung liegt in `core` + +`core.portal.PortalValidator` ist eine reine, statische Prüfung ohne Minestom-Server: `static List problems(List portals)` mit `record PortalProblem(String portalId, String reason)` liefert alle Probleme (Radius > 0, `normal` mit Länge > 0, `min <= max` je Achse mit Achsenname, `id` und `task` nicht leer, `id` eindeutig); `static void requireValid(String world, List portals)` wirft daraus eine `IllegalStateException` der Form `World '': portal '': ` (bei fehlender Id `portal #`). Der Setup-Editor nutzt später `problems(...)` für Chat-Fehler, die Lobby `requireValid(...)` für den Start. Der Validator steht bewusst nicht in `runtime`/`PlatformBeans`. + +Ein unbekannter `shape.type` oder ein fehlendes Pflichtfeld ist ein Lesefehler des Gson-Adapters (`JsonParseException`); `MapProvider.loadMapData` fängt ihn, ergänzt die Weltangabe und wirft ebenfalls die `IllegalStateException`. Danach ruft `MapProvider` `requireValid` für die gelesene Liste. Der Start bricht ab. Kein Überspringen mit Log (Abweichung von #222, gemäß „ungültige Werte verhindern den Start“). + +**Erste Aufgabe der Umsetzung (Risiko):** Prüfen, ob `GsonFileHandler.load` (Aves) eine `JsonParseException` schluckt und `Optional.empty()` liefert; `MapProvider` fiele dann still auf eine Karte ohne Spawn zurück. Trifft das zu, liest `MapProvider` die Datei für die Portale selbst mit dem eigenen Gson, statt den Fehler zu verlieren; der Vertrag (Abbruch mit Welt, Id, Grund) bleibt. +Built-in geprüft: Java-Ausnahmen und eine Liste genügen; ein Validierungs-Framework lohnt für sieben Regeln nicht. Verworfen: Prüfung in den Record-Konstruktoren (der Editor bräuchte Ausnahmen statt einer Problemliste) und in `PlatformBeans` (nicht wiederverwendbar). +SOLID: SRP (Daten, Prüfung, Laden getrennt), DIP. +Test (Unit, `PortalValidatorTest`): je Regel ein Test mit genauer Meldung (Welt, Id, Grund); mehrere Probleme werden alle gemeldet; ein gültiges Portal liefert die leere Liste; Integration: `MapProvider` mit einem Weltverzeichnis mit ungültiger `map.json` wirft. + +### D7: Column `features/portal` + +`PortalModule` (`@Singleton`, Muster `SpawnModule`): Der Konstruktor nimmt `EventNode` (`@Named("titan")`), `LobbyPortals`, `Deliver`, `PermissionService` und `Clock`. `@PostConstruct`: Index und `PortalTrigger` bauen, `FeatureNode.attach(titan, "portal", 900)` mit `on(PlayerMoveEvent.class, ...)` und `on(PlayerDisconnectEvent.class, ...)`; `@PreDestroy`: `node.close()`. Priorität **900**, da 100 bis 800 vergeben sind; die Reihenfolge ist hier unkritisch, weil kein anderes Feature diese Events abbricht. + +Ablauf im Listener: `player.getInstance() == null` → zurück; `trigger.onMove(uuid, player.getPosition(), event.getNewPosition())`; hat ein Portal ausgelöst und ist `permission == null` oder `permissions.check(uuid, permission) == ALLOWED`, ruft das Modul `deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName(task).player(player).build())` und meldet dem Trigger die Weiterleitung (Abklingzeit). Kein Chat, kein `Deliver`-Wrapper, keine Erreichbarkeitsprüfung (CloudNets `connectToTask` routet; `bridge-connector-warning` meldet eine fehlende Bridge). + +Beide Varianten: `Deliver` ist außerhalb von CloudNet ein No-op, und `local` ohne Permission-Plattform behandelt jedes Recht als `NOT_SET` (Portale mit Recht wirken dort nicht). Ein Grund, die Column aus `local` auszuschließen, besteht nicht: sie braucht keinen Supervisor, und `local` bleibt so dieselbe Lobby wie `cloudnet` (Anforderung „Varianten verhalten sich wie die bisherige Lobby“). + +`package-info.java`: `@InjectModule(name = "portalColumn", requires = {EventNode.class, LobbyPortals.class, Deliver.class, PermissionService.class, Clock.class}, requiresString = {"net.minestom.server.event.EventNode:titan"})`. Kein `io.avaje.config` in der Column (`ColumnArchitectureTest`), weil es keine Konfiguration gibt. + +Logging: INFO beim Start „Loaded {} portals“ (Anzahl); DEBUG bei einer Weiterleitung (Portal-Id, Task; keine Spielerdaten); nie oberhalb DEBUG pro Bewegung. Keine Metriken, keine Spans (Bewegung ist ein Hot Path, eine Weiterleitung eine Spieleraktion). Nutzertexte: keine. +Built-in geprüft: `FeatureNode`, `Deliver`, `DeliverComponent`, `PermissionService` und `Clock` sind die vorhandenen Bausteine; nichts Eigenes für Ereignisse oder Weiterleitung. +SOLID: SRP (das Modul verdrahtet, der Trigger entscheidet, der Index sucht), DIP, OCP (die Column dockt über `LobbyPortals` und `FeatureNode` an, ohne bestehenden Code zu ändern). +Test: Integration (Cyano-`Env`, Fake-`Clock`, `RecordingDeliver`, `FakePermissionService`, je Test frisch): Spieler läuft in eine Box → genau eine Weiterleitung an den Task; ein großer Schritt durch eine Scheibe → eine; Verweilen → keine zweite; Wiederbetreten in der Abklingzeit → keine, nach +3 s auf der Uhr → eine; Recht fehlt → keine und keine Abklingzeit; Welt ohne Portale → keine; nach `stop()` reagiert der Knoten nicht mehr; `ColumnArchitectureTest` und Verdrahtungstests der Varianten grün. + +## Risks / Trade-offs + +- [`GsonFileHandler.load` schluckt Parsefehler (D6)] → erste Aufgabe prüft es; bei Bedarf liest `MapProvider` die Portale selbst. +- [Sehr lange Einzelschritte überspringen eine Zwischenspalte (D5)] → dokumentierte Grenze; reale Schrittweiten sind klein. +- [Der Fußpunkt entscheidet, nicht der Körper] → ein Spieler, dessen Kopf in der Box steht, der Fußpunkt aber darunter, löst nicht aus; Portale werden in Fußhöhe gebaut. Akzeptiert. +- [Ein fehlender CloudNet-Task fällt dem Spieler nicht auf] → CloudNet meldet ihn im vorhandenen `Deliver`-Pfad; keine zusätzliche Meldung (Nicht-Ziel). +- [Eine Map-Änderung braucht einen Neustart] → gewollt (Non-Goal); der Setup-Server schreibt die Datei, die Lobby liest sie beim Start. +- [`LobbyMap` in `common` ist geteilter Code] → nur additiv (neues Feld, neuer Builder-Schritt, Null-Sicherheit); Karten ohne `portals` lesen sich unverändert (Test). + +## Migration Plan + +1. Den Task in CloudNet anlegen, den ein Portal nennt (z. B. `Survival`). +2. Portale in die `map.json` der Welt schreiben (Format D1); für Saisonwelten in deren eigene Datei. +3. `titan-cloudnet.jar` deployen (AOT-Cache neu trainieren). Ohne `portals` in den Dateien ändert sich für niemanden etwas. +4. Zurück: `portals` aus der Datei entfernen oder Revert des Squash-Commits. diff --git a/openspec/changes/lobby-portals/proposal.md b/openspec/changes/lobby-portals/proposal.md new file mode 100644 index 00000000..2c15c65d --- /dev/null +++ b/openspec/changes/lobby-portals/proposal.md @@ -0,0 +1,41 @@ +# Proposal + +## Why + +Spieler sollen die Lobby verlassen, indem sie in einen Bereich der Welt laufen oder durch einen Ring fliegen, nicht nur über den Navigator. PR #222 („portals“) wollte das mit einer `portals.json`, Coris-`CuboidShape`, FeatureGate und einer `ServiceAvailability` der Bridge lösen. Er hängt gestapelt an #216, das nie in `main` gemergt wurde. Dieser Change ersetzt ihn durch das Kleinste, das trägt: Portale sind Kartendaten der jeweiligen Welt, und ein Portal leitet an einen CloudNet-Task weiter, wie der Navigator es tut. + +## What Changes + +- Neue Column `features/portal`, in **beiden** Varianten (`cloudnet` und `local`): Sie prüft bei jeder Spielerbewegung, ob die Strecke vom alten zum neuen Standort ein Portal der Welt berührt, und leitet den Spieler an den CloudNet-Task des Portals weiter. Wie beim Navigator ist `Deliver` außerhalb von CloudNet ein No-op; eine eigene Ausnahme für `local` gibt es nicht. +- Portale sind **Kartendaten**: eine Liste `portals` in der `map.json` der Welt (`LobbyMap`). Jede Saisonwelt bringt ihre eigenen Portale mit. Das Format (Formen `box` und `disc`, optionales Recht) steht als Vertrag in `design.md`. Bis der getrennte Folge-Change `setup-portal-command` Befehle im Setup-Server liefert, wird die `map.json` von Hand bearbeitet. +- Zwei Formen: `box` (achsenparallel, blockgenau einschließlich) und `disc` (Mittelpunkt, Radius, Normalenvektor, beliebig ausgerichtet; Mathematik aus Voyagers `RingPass`). +- Erkennung über die **Strecke** zwischen altem und neuem Standort, damit auch ein schneller Elytra-Flug durch einen dünnen Ring nicht übersprungen wird. Laufen und Fliegen lösen gleichermaßen aus. +- Auslösung einmal beim Betreten, dazu eine feste Abklingzeit von 3 Sekunden je Spieler (im Code, nicht in der Konfiguration). +- Ziel ist ausschließlich ein CloudNet-Task (`Deliver` mit `DeliverComponent.taskBuilder()`). Optionales Recht je Portal: ist es gesetzt und nicht `ALLOWED`, passiert nichts und es gibt keine Meldung (wie beim Navigator-Ziel Build). Keine Erreichbarkeitsprüfung; CloudNets Task-Routing übernimmt das. +- Ein ungültiges Portal in der `map.json` (unbekannte Form, Radius kleiner oder gleich 0, Normale null, `min` größer `max`, fehlender Task, doppelte Id) verhindert den Start und nennt Welt, Portal-Id und Grund. +- Neue Typen in `core` (`net.onelitefeather.titan.core.portal`), weil Columns nur `core` sehen: `PortalShape` (`Box`, `Disc`), `Portal`, `LobbyPortals` und die wiederverwendbare Prüfung `PortalValidator`. `common` (`LobbyMap` samt Builder-Kopie, Gson) liest und schreibt sie; `runtime` (`PlatformBeans`) stellt `LobbyPortals` aus der aktiven Karte bereit. + +## Capabilities + +### New Capabilities + +- `lobby-portals`: Portale als Kartendaten, Auslösung beim Betreten durch Laufen oder Fliegen, Abklingzeit, optionales Recht, Weiterleitung an einen CloudNet-Task und Startabbruch bei ungültigen Portalen. + +### Modified Capabilities + +Keine. `lobby-modules` (Event-Reihenfolge, Column-Unabhängigkeit) und `app-variants` (gleiche Columns in beiden Varianten) gelten unverändert; die Column reiht sich mit einer festen Position ein und steht in beiden Varianten. + +## Impact + +- **Code**: neues Modul `features/portal` (von `settings.gradle.kts` automatisch erfasst, `titan.column`); `core` (neues Paket `portal`); `common` (`LobbyMap`, `LobbyMapBuilder`, `MapProvider`-Gson, neuer Adapter); `runtime` (`PlatformBeans`); `docs/lobby-modules.md` (Tabellenzeile `portal`, Format der Portale). Keine Änderung an `apps/*/build.gradle.kts`. +- **Abhängigkeiten**: keine neuen. `Deliver`, `PermissionService`, `java.time.Clock`, Minestom `Point`/`Vec` und Gson (über Aves) sind da. +- **Tests**: Unit-Tests der Formen (Voyagers `RingPass`-Fälle, Slab-Fälle, schneller Elytra-Schritt), der Auslöse-Logik mit Fake-`Clock`, des JSON-Formats und der Validierung; Integrationstest mit Cyano-`Env`; `ColumnArchitectureTest`; Starttest je Variante. +- **Nutzertexte**: keine (keine Chat-Nachricht); nur Startfehler für Betreiber (Englisch). +- **Betrieb**: Portale stehen in der `map.json` der Welt; der Ziel-Task muss in CloudNet existieren. Ohne `portals` in der Datei ändert sich nichts. +- **Ersetzt**: PR #222 wird nach dem Merge als überholt geschlossen. + +## Delivery + +PR-Titel: `feat(portal): switch servers by walking or flying through portals` + +Nicht-Ziele, jeweils eigener Change: Befehle im Setup-Server zum Anlegen und Bearbeiten (`setup-portal-command`), Weiterleitung an einen einzelnen Server statt an einen Task, Erreichbarkeitsprüfung und Warnung bei fehlender Bridge (`bridge-connector-warning`), Nachricht an den Spieler, Partikel oder Anzeige der Portale, konfigurierbare Abklingzeit, Neuladen der Portale zur Laufzeit. diff --git a/openspec/changes/lobby-portals/specs/lobby-portals/spec.md b/openspec/changes/lobby-portals/specs/lobby-portals/spec.md new file mode 100644 index 00000000..302bd79a --- /dev/null +++ b/openspec/changes/lobby-portals/specs/lobby-portals/spec.md @@ -0,0 +1,93 @@ +# Spec Delta + +## Purpose + +Legt fest, wie Spieler die Lobby über Portale verlassen: Portale sind Kartendaten der Welt, lösen beim Betreten durch Laufen oder Fliegen genau einmal aus und leiten an einen CloudNet-Task weiter. Ungültige Portale verhindern den Start. + +## ADDED Requirements + +### Requirement: Portale sind Kartendaten der Welt +Die Lobby MUSS die Portale aus der Map-Datei (`map.json`) der geladenen Welt lesen, aus der Liste `portals`. Jedes Portal MUSS eine eindeutige Id, einen CloudNet-Task, eine Form und optional ein Recht haben. Die Lobby MUSS die Formen `box` (achsenparallel, mit `min` und `max` als Blockkoordinaten, beide einschließlich) und `disc` (Mittelpunkt, Radius, Normalenvektor beliebiger Ausrichtung) unterstützen. Eine Welt ohne `portals` hat keine Portale. Jede Welt, auch eine Saisonwelt, hat nur die Portale ihrer eigenen Map-Datei. + +#### Scenario: Welt mit Portalen +- **WHEN** die Map-Datei der geladenen Welt ein Portal mit Form `box` und Task `Survival` enthält +- **THEN** kennt die Lobby dieses Portal mit genau diesem Bereich und diesem Task + +#### Scenario: Welt ohne Portale +- **WHEN** die Map-Datei keine Liste `portals` enthält +- **THEN** löst keine Bewegung ein Portal aus, und die Lobby verhält sich wie zuvor + +#### Scenario: Saisonwelt bringt eigene Portale mit +- **WHEN** die Lobby in einer Saisonwelt startet, deren Map-Datei andere Portale enthält als die der Standardwelt +- **THEN** gelten nur die Portale der Saisonwelt + +### Requirement: Betreten eines Portals leitet den Spieler an den Task weiter +Berührt die Strecke vom bisherigen zum neuen Standort eines Spielers den Bereich eines Portals, MUSS die Lobby den Spieler an den CloudNet-Task des Portals weiterleiten. Laufen und Fliegen MÜSSEN gleichermaßen auslösen. Bei `box` zählt auch ein neuer Standort im Bereich; bei `disc` zählt das Durchqueren der Scheibe innerhalb des Radius (Rand einschließlich), in beide Richtungen. Weil die Strecke geprüft wird und nicht nur der Endpunkt, MUSS auch ein Schritt auslösen, der eine dünne Scheibe zwischen zwei Bewegungen vollständig überspringt. + +#### Scenario: In eine Box laufen +- **WHEN** ein Spieler von außerhalb in den Bereich eines Portals mit Form `box` läuft +- **THEN** wird er genau einmal an den Task des Portals weitergeleitet + +#### Scenario: Durch eine Scheibe fliegen +- **WHEN** ein Spieler mit Elytra zwischen zwei Bewegungen die Ebene einer Scheibe innerhalb ihres Radius durchquert, ohne dass einer der beiden Standorte nahe an ihr liegt +- **THEN** wird er genau einmal an den Task des Portals weitergeleitet + +#### Scenario: Neben der Scheibe vorbei +- **WHEN** die Strecke die Ebene der Scheibe außerhalb ihres Radius durchquert oder parallel zur Ebene verläuft +- **THEN** wird niemand weitergeleitet + +### Requirement: Ein Portal löst nur beim Betreten aus +Die Lobby MUSS ein Portal nur beim Übergang von „nicht berührt“ zu „berührt“ auslösen. Bleibt ein Spieler im Bereich, DARF sie ihn NICHT erneut weiterleiten. + +#### Scenario: Im Bereich bleiben +- **WHEN** ein Spieler nach dem Betreten im Bereich einer Box weiterläuft, auch nach Ablauf der Abklingzeit +- **THEN** wird er nicht erneut weitergeleitet + +### Requirement: Nach einer Weiterleitung gilt eine Abklingzeit von 3 Sekunden +Nach einer Weiterleitung MUSS die Lobby dem Spieler für 3 Sekunden keine weitere Weiterleitung durch irgendein Portal auslösen. Die Abklingzeit ist fest und gilt je Spieler. Ein Betreten während der Abklingzeit löst nichts aus und wird später nicht nachgeholt. + +#### Scenario: Wiederbetreten innerhalb der Abklingzeit +- **WHEN** ein Spieler ein Portal verlässt und es innerhalb von 3 Sekunden nach der Weiterleitung erneut betritt +- **THEN** wird er nicht erneut weitergeleitet + +#### Scenario: Wiederbetreten nach der Abklingzeit +- **WHEN** ein Spieler ein Portal verlässt und es 3 Sekunden oder mehr nach der Weiterleitung erneut betritt +- **THEN** wird er erneut weitergeleitet + +#### Scenario: Abklingzeit gilt je Spieler +- **WHEN** ein Spieler weitergeleitet wurde und ein anderer Spieler innerhalb der 3 Sekunden ein Portal betritt +- **THEN** wird der andere Spieler weitergeleitet + +### Requirement: Ein Recht am Portal schützt die Weiterleitung +Hat ein Portal ein Recht, MUSS die Lobby nur Spieler weiterleiten, für die die Rechteprüfung `ALLOWED` ergibt. Bei `NOT_SET` und `DENIED` DARF nichts passieren, insbesondere keine Weiterleitung und keine Chat-Nachricht. Ein Portal ohne Recht steht allen offen. + +#### Scenario: Recht fehlt +- **WHEN** ein Spieler ohne das Recht eines Portals dessen Bereich betritt +- **THEN** wird er nicht weitergeleitet und erhält keine Nachricht + +#### Scenario: Recht vorhanden +- **WHEN** ein Spieler mit dem Recht eines Portals dessen Bereich betritt +- **THEN** wird er an den Task des Portals weitergeleitet + +#### Scenario: Abgewiesener Spieler startet keine Abklingzeit +- **WHEN** ein Spieler ohne Recht ein Portal betritt und danach mit Recht ein anderes Portal +- **THEN** wird er am zweiten Portal weitergeleitet + +### Requirement: Ungültige Portale verhindern den Start +Enthält die Map-Datei ein ungültiges Portal, DARF die Lobby NICHT starten, und die Fehlermeldung MUSS Welt, Portal-Id und Grund nennen. Ungültig sind: eine unbekannte Form, ein Radius kleiner oder gleich 0, eine Normale der Länge 0, ein `min`, das in einer Achse größer ist als `max`, ein fehlender oder leerer Task, eine fehlende Id und eine Id, die in derselben Welt doppelt vorkommt. Die Lobby DARF ein ungültiges Portal NICHT überspringen und ohne es weiterlaufen. + +#### Scenario: Unbekannte Form +- **WHEN** ein Portal die Form `sphere` nennt +- **THEN** startet die Lobby nicht, und die Fehlermeldung nennt die Welt, die Portal-Id und die unbekannte Form + +#### Scenario: Radius nicht positiv +- **WHEN** eine Scheibe den Radius 0 hat +- **THEN** startet die Lobby nicht, und die Fehlermeldung nennt Welt, Portal-Id und den Radius + +#### Scenario: Box mit vertauschten Ecken +- **WHEN** bei einer Box `min` in einer Achse größer ist als `max` +- **THEN** startet die Lobby nicht, und die Fehlermeldung nennt Welt, Portal-Id und die Achse + +#### Scenario: Doppelte Id +- **WHEN** zwei Portale derselben Welt dieselbe Id haben +- **THEN** startet die Lobby nicht, und die Fehlermeldung nennt Welt und die doppelte Id diff --git a/openspec/changes/lobby-portals/tasks.md b/openspec/changes/lobby-portals/tasks.md new file mode 100644 index 00000000..2c245603 --- /dev/null +++ b/openspec/changes/lobby-portals/tasks.md @@ -0,0 +1,49 @@ +# Tasks + +## Execution Plan + +Integrationszweig: `feat/lobby-portals` von `origin/main`. Jede Welle endet mit grünem `./gradlew build` und geprüften Diffs; vor dem Abhaken einer Aufgabe läuft `./gradlew build`. Wellen 2 zweigen vom Stand nach Welle 1 ab. + +| Wave | Agent | Task IDs | Model | May Touch | Must Not Touch | +| ---- | ----- | -------- | ----- | --------- | -------------- | +| 1 | core | 1.1–1.4 | sonnet | `core/**` (Paket `portal`, dessen Tests) | `common/**`, `runtime/**`, `features/**`, `apps/**`, `buildSrc/**`, `platform/**` | +| 2 | map | 2.1–2.5 | sonnet | `common/**` (Paket `map`, dessen Tests), `runtime/**` (`PlatformBeans`, `package-info.java`, dessen Tests) | `core/**`, `features/**`, `apps/**`, `buildSrc/**` | +| 2 | column | 3.1–3.6 | sonnet | `features/portal/**` (neu) | `core/**`, `common/**`, `runtime/**`, andere `features/**`, `apps/**`, `buildSrc/**` | +| 3 | docs | 4.1 | sonnet | `docs/lobby-modules.md` | Code, `openspec/**` | +| 4 | verify | 4.2 | haiku | read-only | alles | +| 5 | pr | 5.1 | sonnet | Git/GitHub | Code | + +Die Agenten `map` und `column` laufen parallel in getrennten Worktrees, weil sie nur über die in Welle 1 gelieferten `core`-Typen zusammenhängen; die Verdrahtung (Bean `LobbyPortals` gegen Column-Anforderung) prüft Aufgabe 3.6 nach dem Zusammenführen beider Zweige. Jeder Agent-Prompt nennt die Regeln, die für seine Aufgaben gelten: erst Vorhandenes nutzen (`Vec`/`Point`, `ChunkUtils`, Gson über Aves, `FeatureNode`, `Deliver`/`DeliverComponent`, `PermissionService`, `java.time.Clock`), Java 25 ohne Preview (Records, `sealed`), keine neuen Abhängigkeiten, keine Nutzertexte, Logs nur INFO beim Start und DEBUG je Weiterleitung, keine Metriken oder Spans, Test zuerst, schlanke Kommentare nur fürs Warum (die Herkunft aus Voyagers `RingPass` als Warum-Kommentar), Conventional Commits `feat(portal): …`. F.I.R.S.T.: injizierte `Clock` (Fake-Uhr im Test), `env.tick()` statt Warten, kein `Thread.sleep`, keine Systemzeit, je Test frische Fixtures (`PortalTrigger`, `RecordingDeliver`, `FakePermissionService`, `@TempDir` für Weltverzeichnisse), kein geteilter statischer Zustand, keine Abhängigkeit von der Testreihenfolge, Erfolg nur über Assertions mit Meldung. + +## 1. Formen und Portal-Typen in core (Welle 1) + +- [x] 1.1 Tests zuerst (Unit, `DiscTest`): Voyagers `RingPassTest`-Fälle als `Disc.crossedBy`-Tests übernehmen (Mitte, Rand, knapp außerhalb, parallel, Ebene nicht erreicht, Ebene hinter dem Segment, rückwärts, geneigt Mitte und außermittig, Nullschritt, Rand mit gemeinsamem X- und Y-Versatz, geneigter Ring mit echtem Z-Schritt) plus der schnelle Elytra-Schritt, der eine 1 Block dünne Scheibe mit 8 Blöcken Schrittweite überspringt; rot. Dann `Disc` und `PortalShape` mit der Rechnung aus `RingPass` (Quelle im Warum-Kommentar) anlegen; grün. Nachweis: `./gradlew :core:test` grün. +- [x] 1.2 Tests zuerst (Unit, `BoxTest`): Slab-Fälle (Segment durch die Box, endet in der Box, startet in der Box, geht daneben vorbei, achsenparallel innerhalb und außerhalb des Slabs, Streifschuss an Kante und Ecke, `max + 1` einschließlich, `max + 1.0001` außerhalb, Nullschritt innerhalb/außerhalb); rot. Dann `Box.crossedBy` mit der Slab-Methode auf `[min, max + 1]`; grün. Nachweis: `./gradlew :core:test` grün. +- [x] 1.3 Tests zuerst (Unit, `PortalValidatorTest`): `PortalValidator.problems(...)` liefert je Regel ein `PortalProblem` mit genauem Grund (Radius ≤ 0, Normale der Länge 0, `min` > `max` mit Achsenname, leere `id`, leerer `task`, doppelte `id`), mehrere Probleme zugleich, leere Liste bei gültigen Portalen; `requireValid` wirft `IllegalStateException` mit Welt, Id, Grund; Normierung von `normal`, `horizontalBounds()` beider Formen; rot. Dann die datenhaltenden Records `Box`, `Disc`, `Portal`, `HorizontalBounds`, `PortalProblem` und `PortalValidator`; grün. Nachweis: `./gradlew :core:test` grün, Meldungen nennen den Grund. +- [x] 1.4 `LobbyPortals` (`List portals()`, unveränderlich, leer statt `null`) anlegen und Paket `net.onelitefeather.titan.core.portal` mit `package-info` versehen. Nachweis: `./gradlew build` grün, `ColumnArchitectureTest` unverändert grün. + +## 2. Kartendaten, Laden und Bereitstellung (Welle 2, Agent map) + +- [x] 2.1 Erste Aufgabe (Risiko, D6): Prüfen, ob `GsonFileHandler.load` einen Parsefehler schluckt und `Optional.empty()` liefert. Test zuerst (Integration mit `@TempDir`): eine `map.json` mit unbekanntem `shape.type` muss `MapProvider` beim Anlegen scheitern lassen; rot oder grün dokumentiert die Antwort. Trifft es zu, liest `MapProvider` die Portale selbst mit dem eigenen Gson (Vertrag bleibt). Nachweis: Test grün; das Ergebnis der Prüfung steht im Commit-Text. +- [x] 2.2 Tests zuerst (Unit, Gson-Rundreise): das Beispiel aus design.md D1 (`box` und `disc`) wird gelesen und wieder geschrieben; fehlendes `permission` gleich `null`; unbekanntes Feld ignoriert; `normal` wird normiert; Karte ohne `portals` liefert eine leere Liste; `lobbyMapBuilder(LobbyMap)` und jeder Kopierweg behalten die Portale (Rundreise-/Kopiertest: nach `lobbyMapBuilder(map).spawn(...).build()`, `.name(...)`, `.author(...)` sind die Portale unverändert; `saveMap` schreibt sie); rot. Dann `LobbyMap.portals()`, `Builder.portals(...)` (`LobbyMapBuilder`), `PortalGsonAdapter` und die Registrierung in `MapProvider`; grün. Nachweis: `./gradlew :common:test` grün. +- [x] 2.3 Tests zuerst (Integration mit `@TempDir`): je Fehlerfall der Spezifikation (unbekannte Form, Radius 0, `min` > `max`, Normale 0, fehlender Task, fehlende Id, doppelte Id) wirft `MapProvider.create` eine `IllegalStateException`, deren Meldung Weltverzeichnis, Portal-Id und Grund nennt; ein gültiges Portal startet; rot. Dann `MapProvider.loadMapData` ruft `PortalValidator.requireValid` und packt Lesefehler des Adapters mit der Weltangabe ein; grün. Nachweis: `./gradlew :common:test` grün. +- [x] 2.4 Test zuerst (Integration): Die Bean `LobbyPortals` liefert die Portale der aktiven Karte, in einer Saisonwelt (Weltwahl über `LobbyWorldChoice`) die Portale von deren `map.json`; rot. Dann `PlatformBeans.lobbyPortals(MapProvider)` und `LobbyPortals.class` in `provides` von `runtime/.../package-info.java`; grün. Nachweis: `./gradlew :runtime:test` grün. +- [x] 2.5 Regression (Integration): Bestehende Karten ohne `portals` laden unverändert (Spawn, Name, Bauern); `./gradlew :common:test :runtime:test` grün. Nachweis: die vorhandenen Map-Tests bleiben ungeändert grün. + +## 3. Column `features/portal` (Welle 2, Agent column) + +- [x] 3.1 Column anlegen: `features/portal/build.gradle.kts` mit `id("titan.column")`, `package-info.java` mit `@InjectModule(name = "portalColumn", requires = {EventNode.class, LobbyPortals.class, Deliver.class, PermissionService.class, Clock.class}, requiresString = {"…EventNode<…Event>:titan"})`. Nachweis: `./gradlew :features:portal:compileJava` grün; `settings.gradle.kts` und `apps/*/build.gradle.kts` unverändert. +- [x] 3.2 Tests zuerst (Unit, `PortalIndexTest`): Portal in einer Spalte wird von beiden Endpunkten gefunden; Portal über der Chunkgrenze steht in beiden Spalten; Bewegung über die Grenze findet es über den Endpunkt der anderen Spalte; Welt ohne Portale liefert die leere Liste; kein Portal doppelt; rot. Dann `PortalIndex` mit `ChunkUtils.getChunkIndex`; grün. Nachweis: `./gradlew :features:portal:test` grün. +- [x] 3.3 Tests zuerst (Unit, `PortalTriggerTest`, Fake-`Clock`, frischer Trigger je Test): Betreten löst einmal aus; Verweilen löst auch nach Ablauf der Abklingzeit nicht erneut aus; Verlassen und Wiederbetreten innerhalb von 3 s löst nicht aus; nach 3 s löst es aus; Scheibe: Durchqueren löst, ein zweiter Durchflug nach der Abklingzeit löst wieder; ein Schritt, der eine 1 Block dünne Scheibe überspringt, löst aus; zwei Spieler beeinflussen sich nicht; Disconnect räumt den Zustand; eine Weiterleitung, die der Aufrufer nicht meldet (kein Recht), startet keine Abklingzeit; rot. Dann `PortalTrigger`; grün. Nachweis: `./gradlew :features:portal:test` grün, Meldungen nennen Portal und Zeitpunkt. +- [x] 3.4 Tests zuerst (Integration, Cyano-`Env`, Fake-`Clock`, `RecordingDeliver`, `FakePermissionService`): Spieler läuft in eine Box → genau eine Weiterleitung an den Task; großer Schritt durch eine Scheibe → eine; Verweilen → keine zweite; Wiederbetreten in der Abklingzeit → keine, nach Vorstellen der Uhr um 3 s → eine; Recht fehlt (`NOT_SET`, `DENIED`) → keine und keine Abklingzeit; Recht vorhanden → eine; Welt ohne Portale → keine; rot. Dann `PortalModule` (`@Singleton`, `@PostConstruct`/`@PreDestroy`, `FeatureNode.attach(titan, "portal", 900)`, `PlayerMoveEvent` und `PlayerDisconnectEvent`); grün. Nachweis: `./gradlew :features:portal:test` grün. +- [x] 3.5 Tests (Integration): nach `stop()` reagiert der Knoten nicht mehr und hinterlässt keinen Listener (Muster `NavigatorModuleLeakTest`); die Priorität 900 kollidiert mit keinem anderen Feature (Start mit allen Columns wirft nicht); `ColumnArchitectureTest` grün (kein `io.avaje.config`, Listener nur über `FeatureNode`). Nachweis: `./gradlew :features:portal:test` grün. +- [x] 3.6 Nach dem Zusammenführen von `map` und `column`: Verdrahtungstest in `apps/cloudnet` und Starttest in `apps/local` laufen mit der neuen Column grün (beide Varianten melden `portal` als gestartet, `VariantStartupCheck` findet sie). Nachweis: `./gradlew build` grün. + +## 4. Doku und Abnahme (Wellen 3 und 4) + +- [x] 4.1 `docs/lobby-modules.md`: Prioritäten-Tabelle um `portal | 900` ergänzen, Column-Tabelle um die Zeile `portal` (`EventNode.class`, `LobbyPortals.class`, `Deliver.class`, `PermissionService.class`, `Clock.class`), und einen Abschnitt „Portale“ mit dem JSON-Vertrag aus design.md D1, den Formen, der Abklingzeit von 3 s, dem Recht und dem Betriebsschritt (CloudNet-Task, Karte bearbeiten, Neustart). Nachweis: Doku nennt Format, Priorität 900 und Tabellenzeile; kein anderer Abschnitt geändert. +- [x] 4.2 Verifikation (Haiku, read-only): Jedes Szenario aus `specs/lobby-portals` Test für Test zuordnen; F.I.R.S.T.-Check (keine Sleeps, keine Systemzeit, keine geteilten statischen Zustände, frische Fixtures je Test, Assertions mit Meldungen); prüfen, dass die Herkunft aus `RingPass` im Warum-Kommentar steht, keine Abhängigkeit auf Voyager oder Coris besteht und keine Konfigurationsschlüssel hinzukamen. Nachweis: Bericht ohne Lücken. + +## 5. Pull Request + +- [ ] 5.1 Pull Request vom Integrationszweig auf `main` unter dem Titel `feat(portal): switch servers by walking or flying through portals` öffnen (Titel und Beschreibung Englisch), mit dem Hinweis, dass er PR #222 ersetzt (Variante: Portale als Kartendaten je Welt, Strecken-Erkennung, kein FeatureGate, keine Abhängigkeit von #216); danach #222 mit einem Kommentar schließen, der auf den neuen PR verweist (superseded). Nachweis: PR-URL, CI grün, #222 geschlossen. diff --git a/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java b/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java index 20156999..3780f226 100644 --- a/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java +++ b/runtime/src/main/java/net/onelitefeather/titan/runtime/bootstrap/PlatformBeans.java @@ -36,6 +36,7 @@ import net.onelitefeather.titan.core.module.FeatureNode; import net.onelitefeather.titan.core.module.LobbySpawn; import net.onelitefeather.titan.core.module.LobbyWorldChoice; +import net.onelitefeather.titan.core.portal.LobbyPortals; import net.onelitefeather.titan.common.deliver.DeliverProvider; import net.onelitefeather.titan.runtime.feature.ConfigFeatureFlags; import net.onelitefeather.titan.core.feature.FeatureFlags; @@ -87,6 +88,15 @@ public LobbySpawn lobbySpawn(MapProvider mapProvider) { return () -> mapProvider.getActiveLobby().spawn(); } + /** + * Read per call like {@link #lobbySpawn}; a seasonal world brings the portals of its own + * map.json. + */ + @Bean + public LobbyPortals lobbyPortals(MapProvider mapProvider) { + return () -> mapProvider.getActiveLobby().portals(); + } + /** A no-op outside a CloudNet service. */ @Bean public Deliver deliver() { diff --git a/runtime/src/main/java/net/onelitefeather/titan/runtime/package-info.java b/runtime/src/main/java/net/onelitefeather/titan/runtime/package-info.java index b1e641ab..018f2bb5 100644 --- a/runtime/src/main/java/net/onelitefeather/titan/runtime/package-info.java +++ b/runtime/src/main/java/net/onelitefeather/titan/runtime/package-info.java @@ -29,7 +29,7 @@ * ({@code spawn}/{@code respawn}/{@code elytra}) has moved out to {@code features/*}, and * {@code runtime}'s own main code no longer injects it anywhere. */ -@InjectModule(provides = {EventNode.class, Instance.class, LobbySpawn.class, Deliver.class, FeatureFlags.class, Clock.class, Scheduler.class, CommandManager.class, PermissionService.class}) +@InjectModule(provides = {EventNode.class, Instance.class, LobbySpawn.class, LobbyPortals.class, Deliver.class, FeatureFlags.class, Clock.class, Scheduler.class, CommandManager.class, PermissionService.class}) package net.onelitefeather.titan.runtime; import io.avaje.inject.InjectModule; @@ -43,3 +43,4 @@ import net.onelitefeather.titan.core.module.LobbySpawn; import net.onelitefeather.titan.core.module.item.LobbyItems; import net.onelitefeather.titan.core.permission.PermissionService; +import net.onelitefeather.titan.core.portal.LobbyPortals; diff --git a/runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyPortalsBeanTest.java b/runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyPortalsBeanTest.java new file mode 100644 index 00000000..d0b0dcae --- /dev/null +++ b/runtime/src/test/java/net/onelitefeather/titan/runtime/bootstrap/LobbyPortalsBeanTest.java @@ -0,0 +1,99 @@ +/** + * Copyright 2025 OneLiteFeather Network + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package net.onelitefeather.titan.runtime.bootstrap; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Optional; +import net.minestom.server.instance.InstanceContainer; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.core.module.LobbyWorldChoice; +import net.onelitefeather.titan.core.portal.LobbyPortals; +import net.onelitefeather.titan.core.portal.Portal; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.api.io.TempDir; + +/** + * The {@link LobbyPortals} bean reads the portals of whichever world the {@link MapProvider} + * loaded, so a seasonal world brings its own portals without any extra wiring. + */ +@ExtendWith(MicrotusExtension.class) +class LobbyPortalsBeanTest { + + @TempDir + Path base; + + private final PlatformBeans platformBeans = new PlatformBeans(); + + private void createWorld(String name, String portalsJson) throws IOException { + Path directory = Files.createDirectories(this.base.resolve("worlds").resolve(name)); + Files.writeString(directory.resolve("map.json"), "{\"name\":\"" + name + "\"" + portalsJson + "}"); + } + + private static String portal(String id) { + return ",\"portals\":[{\"id\":\"" + id + "\",\"task\":\"Survival\",\"shape\":{\"type\":\"box\",\"min\":{\"x\":0,\"y\":0,\"z\":0},\"max\":{\"x\":1,\"y\":1,\"z\":1}}}]"; + } + + private LobbyPortals portalsOf(Env env, Optional season) { + InstanceContainer instance = env.process().instance().createInstanceContainer(); + LobbyWorldChoice choice = () -> season; + MapProvider provider = PlatformBeans.loadMapProvider(this.base, instance, List.of(choice)); + return this.platformBeans.lobbyPortals(provider); + } + + private static List ids(LobbyPortals portals) { + return portals.portals().stream().map(Portal::id).toList(); + } + + @DisplayName("The bean lists the portals of the active lobby") + @Test + void listsThePortalsOfTheActiveLobby(Env env) throws IOException { + createWorld("world", portal("survival")); + createWorld("winter", portal("winter-gate")); + + LobbyPortals portals = portalsOf(env, Optional.empty()); + + Assertions.assertEquals(List.of("survival"), ids(portals), "the default world's portals must be served"); + } + + @DisplayName("In a season world the bean lists that world's portals") + @Test + void seasonWorldBringsItsOwnPortals(Env env) throws IOException { + createWorld("world", portal("survival")); + createWorld("winter", portal("winter-gate")); + + LobbyPortals portals = portalsOf(env, Optional.of("winter")); + + Assertions.assertEquals(List.of("winter-gate"), ids(portals), "the season world's map.json decides, not the default world's"); + } + + @DisplayName("A world without portals yields an empty list") + @Test + void worldWithoutPortalsYieldsEmptyList(Env env) throws IOException { + createWorld("world", ""); + + LobbyPortals portals = portalsOf(env, Optional.empty()); + + Assertions.assertTrue(portals.portals().isEmpty(), "no portals in the map means an empty list, not null"); + } +}