Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
8d9963d
docs(openspec): propose lobby-portals
TheMeinerLP Sep 29, 2026
6e459c5
docs(portal): document lobby portals
TheMeinerLP Sep 29, 2026
a13f560
docs(openspec): tick docs task of lobby-portals
TheMeinerLP Sep 29, 2026
5583d64
feat(portal): detect disc crossings by segment and plane
TheMeinerLP Sep 29, 2026
80463c2
feat(portal): detect box crossings with the slab method
TheMeinerLP Sep 29, 2026
df3abc0
feat(portal): validate portals with named reasons
TheMeinerLP Sep 29, 2026
072d96c
feat(portal): expose portals as a bean type and document the package
TheMeinerLP Sep 29, 2026
4d66b58
docs(openspec): tick core tasks of lobby-portals
TheMeinerLP Sep 29, 2026
d777fee
Merge branch 'feat/lobby-portals-core' into feat/lobby-portals
TheMeinerLP Sep 29, 2026
31563b9
feat(portal): add the portal column skeleton
TheMeinerLP Sep 29, 2026
9131371
feat(portal): pin that aves gson load does not swallow adapter errors
TheMeinerLP Sep 29, 2026
83e46ce
feat(portal): load, validate and write lobby portals from map.json
TheMeinerLP Sep 29, 2026
ce37cd5
feat(portal): detect portal entries along the move segment
TheMeinerLP Sep 29, 2026
8e15e06
feat(portal): send players to their task when they enter a portal
TheMeinerLP Sep 29, 2026
fbb888d
docs(openspec): tick column tasks of lobby-portals
TheMeinerLP Sep 29, 2026
fd47662
feat(portal): provide the active lobby's portals as a bean
TheMeinerLP Sep 29, 2026
e60b0d7
docs(openspec): tick map tasks of lobby-portals
TheMeinerLP Sep 29, 2026
9e25cb9
Merge branch 'feat/lobby-portals-column' into feat/lobby-portals
TheMeinerLP Sep 29, 2026
2271b45
test(portal): wire the portal column into the cloudnet and local vari…
TheMeinerLP Sep 29, 2026
64a1d37
build(portal): drop the unused logback test dependency
TheMeinerLP Sep 29, 2026
7192766
feat(portal): identify portals without an id by their index
TheMeinerLP Sep 29, 2026
d79fd15
docs(openspec): tick wiring tasks of lobby-portals
TheMeinerLP Sep 29, 2026
caa9c21
docs(openspec): tick verification task of lobby-portals
TheMeinerLP Sep 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<MapProvider> with(List<Portal> portals) {
return mapProvider -> Mockito.when(mapProvider.getActiveLobby()).thenReturn(new LobbyMap("test", new Pos(0, 65, 0), List.of(), portals));
}

public static Consumer<MapProvider> empty() {
return with(List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<Event>) 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<Event>) this.titan.node()).bean(SeasonSchedule.class, new SeasonSchedule(this.clock, this.worlds)).bean(ServerStop.class, stop).bean(OnlinePlayers.class, players).build();
}

private void tickOneMinute() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -67,7 +68,7 @@ private static EventNode<Event> 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");
Expand All @@ -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<Event> titan = titanNode(scope);
Assertions.assertFalse(titan.getChildren().isEmpty(), "every feature must have attached its own node while the scope is open");

Expand All @@ -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");

Expand Down
Original file line number Diff line number Diff line change
@@ -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<MapProvider> with(List<Portal> portals) {
return mapProvider -> Mockito.when(mapProvider.getActiveLobby()).thenReturn(new LobbyMap("test", new Pos(0, 65, 0), List.of(), portals));
}

public static Consumer<MapProvider> empty() {
return with(List.of());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");
}
Expand All @@ -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 {
Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Portal> portals;

public LobbyMap(String name, Pos spawn, List<String> builders) {
this(name, spawn, builders, List.of());
}

public LobbyMap(String name, Pos spawn, List<String> builders, List<Portal> 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<Portal> portals() {
return this.portals == null ? List.of() : this.portals;
}

public static Builder lobbyMapBuilder(LobbyMap map) {
Expand All @@ -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;
}

Expand All @@ -55,6 +76,8 @@ public sealed interface Builder permits LobbyMapBuilder {

Builder author(String... author);

Builder portals(List<Portal> portals);

LobbyMap build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -24,6 +25,7 @@ final class LobbyMapBuilder implements LobbyMap.Builder {
private Pos spawn;
private String name;
private String[] author;
private List<Portal> portals = List.of();

@Override
public LobbyMap.Builder spawn(Pos spawn) {
Expand All @@ -43,9 +45,15 @@ public LobbyMap.Builder author(String... author) {
return this;
}

@Override
public LobbyMap.Builder portals(List<Portal> portals) {
this.portals = portals;
return this;
}

@Override
public LobbyMap build() {
List<String> builders = author == null ? null : List.of(author);
return new LobbyMap(name, spawn, builders);
return new LobbyMap(name, spawn, builders, portals);
}
}
Loading
Loading