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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 13 additions & 2 deletions docs/lobby-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -252,11 +252,22 @@ mit festem Platz - das rufen Spawn- und Respawn-Feature auf, nicht jedes Feature
`LobbyItems#stack(key)` gibt den gestempelten Stack für ein unplatziertes Item heraus, das ein
Feature selbst aushändigt (`ElytraModule`s Feuerwerk).

Der Navigator ist der einzige Sonderfall ohne Andockpunkt: Seine vier Ziele stehen fest im
Der Navigator ist der einzige Sonderfall ohne Andockpunkt: Seine Ziele stehen fest im
package-privaten `enum Destination` (s. `openspec/changes/navigator-entries-in-code/design.md`).
Nur `Destination.SLENDER` bleibt hinter der Feature-Flag `NAVIGATOR_SLENDER` versteckt, ausgewertet
über `FeatureFlags`, dem `NavigatorModule` per Konstruktor übergeben.

Der Navigator besitzt zwei geteilte Aves-Inventare: das öffentliche (unverändert) und das
Team-Inventar (öffentlich plus `Destination.BUILD`). Das Recht `titan.navigator.buildserver`
entscheidet beim Öffnen, welches sich öffnet: `PermissionService#check` liefert `ALLOWED` ->
Team-Inventar, `NOT_SET` und `DENIED` -> öffentliches. `BUILD` liegt auf Platz 7 und leitet an den
CloudNet-Task `Build` weiter (getrennt von `MemberBuild` des Creative-Ziels). Beim Klick wird das
Recht erneut geprüft; ist es inzwischen weg, gibt es keine Weiterleitung, das Inventar schließt.

Betrieb: Der CloudNet-Task `Build` muss existieren, und das LuckPerms-Recht
`titan.navigator.buildserver` wird der Team-Gruppe erteilt. In der lokalen Variante gilt
`deny-all` (`DenyAllPermissionService`), dort sieht niemand das Ziel.

## Tasks über den injizierten `Scheduler`

Ein wiederkehrender Task wird direkt über den injizierten Minestom-`Scheduler` geplant, nicht über
Expand Down Expand Up @@ -623,7 +634,7 @@ als `provides`):
| `admin` | `CommandManager.class` | - | - |
| `spawn` | `Instance.class`, `LobbySpawn.class`, `EventNode.class`, `LobbyItems.class` | `EventNode<Event>:titan` | - |
| `respawn` | `EventNode.class`, `LobbyItems.class` | `EventNode<Event>:titan` | - |
| `navigator` | `EventNode.class`, `Deliver.class`, `FeatureFlags.class` | `EventNode<Event>:titan` | `LobbyItem.class` |
| `navigator` | `EventNode.class`, `Deliver.class`, `FeatureFlags.class`, `PermissionService.class` | `EventNode<Event>:titan` | `LobbyItem.class` |
| `sit` | `EventNode.class` | `EventNode<Event>:titan` | - |
| `tickle` | `EventNode.class`, `Clock.class` | `EventNode<Event>:titan` | - |
| `elytra` | `EventNode.class`, `Scheduler.class` | `EventNode<Event>:titan` | `LobbyItem.class` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,26 +24,28 @@
import org.jetbrains.annotations.Nullable;

/**
* One destination shown in the shared navigator inventory, fixed in code as a Java {@code enum}
* since destinations change rarely. Only {@link #SLENDER} is gated behind a feature flag; the
* other three are always visible.
* One destination shown in the shared navigator inventories, fixed in code as a Java {@code enum}
* since destinations change rarely. {@link #SLENDER} is gated behind a feature flag and {@link
* #BUILD} behind a permission; the others are always visible.
*/
enum Destination {

ELYTRA_RACE(0, Material.ELYTRA, "<!i><gradient:#fcba03:#03fc8c>ElytraRace</gradient>", "ElytraRace", null), SURVIVAL(4, Material.GRASS_BLOCK, "<!i><green>Survival", "Survival", null), SLENDER(5, Material.ENDERMAN_SPAWN_EGG, "<!i><gradient:#616161:#e80000c>Slender</gradient>", "cygnus", "NAVIGATOR_SLENDER"), CREATIVE(8, Material.WOODEN_AXE, "<!i><rainbow>Creative</rainbow>", "MemberBuild", null);
ELYTRA_RACE(0, Material.ELYTRA, "<!i><gradient:#fcba03:#03fc8c>ElytraRace</gradient>", "ElytraRace", null, null), SURVIVAL(4, Material.GRASS_BLOCK, "<!i><green>Survival", "Survival", null, null), SLENDER(5, Material.ENDERMAN_SPAWN_EGG, "<!i><gradient:#616161:#e80000c>Slender</gradient>", "cygnus", "NAVIGATOR_SLENDER", null), BUILD(7, Material.SCAFFOLDING, "<!i><gold>Build", "Build", null, "titan.navigator.buildserver"), CREATIVE(8, Material.WOODEN_AXE, "<!i><rainbow>Creative</rainbow>", "MemberBuild", null, null);

private final int slot;
private final Material icon;
private final String displayName;
private final String task;
private final @Nullable String feature;
private final @Nullable String permission;

Destination(int slot, Material icon, String displayName, String task, @Nullable String feature) {
Destination(int slot, Material icon, String displayName, String task, @Nullable String feature, @Nullable String permission) {
this.slot = slot;
this.icon = icon;
this.displayName = displayName;
this.task = task;
this.feature = feature;
this.permission = permission;
}

int slot() {
Expand All @@ -59,12 +61,19 @@ String feature() {
return this.feature;
}

@Nullable
String permission() {
return this.permission;
}

// Rebuilds the icon on every call so callers never share one ItemStack instance.
ItemStack item() {
return ItemStack.builder(this.icon).customName(MiniMessage.miniMessage().deserialize(this.displayName)).build();
}

static List<Destination> visible(FeatureFlags featureFlags) {
return Arrays.stream(values()).filter(destination -> destination.feature == null || featureFlags.isActive(destination.feature)).toList();
// withPermissioned is the caller's verdict for a whole menu: the team menu passes true, the
// public one false.
static List<Destination> visible(FeatureFlags featureFlags, boolean withPermissioned) {
return Arrays.stream(values()).filter(destination -> destination.feature == null || featureFlags.isActive(destination.feature)).filter(destination -> destination.permission == null || withPermissioned).toList();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import java.util.List;
import java.util.Objects;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.minestom.server.entity.Player;
import net.minestom.server.event.Event;
import net.minestom.server.event.EventNode;
Expand All @@ -34,15 +33,17 @@
import net.onelitefeather.titan.api.deliver.Deliver;
import net.onelitefeather.titan.core.module.FeatureNode;
import net.onelitefeather.titan.core.feature.FeatureFlags;
import net.theevilreaper.aves.inventory.GlobalInventoryBuilder;
import net.onelitefeather.titan.core.permission.PermissionResult;
import net.onelitefeather.titan.core.permission.PermissionService;
import net.theevilreaper.aves.inventory.click.ClickHolder;
import net.theevilreaper.aves.inventory.layout.InventoryLayout;

/**
* The lobby's navigator: a feather in hotbar slot 4 that opens one Aves-built inventory, shared by
* every player, listing the four destinations fixed in {@link Destination}.
* The lobby's navigator: a feather in hotbar slot 4 that opens one of two Aves-built inventories,
* each shared by every player who gets it: the public one, or the team one that adds the
* permissioned {@code BUILD} destination fixed in {@link Destination}. Opening picks by permission.
*
* <p>Aves maps its click listener directly onto the built {@link GlobalInventoryBuilder} inventory
* <p>Aves maps its click listener directly onto each built {@code GlobalInventoryBuilder} inventory
* and dispatches it before any regular event node's listeners, so this click handling always
* completes before another feature could cancel the event first.
*/
Expand All @@ -59,53 +60,56 @@ public final class NavigatorModule {
private final EventNode<Event> titan;
private final Deliver deliver;
private final FeatureFlags featureFlags;
private final GlobalInventoryBuilder builder;
private final PermissionService permissions;
private final SharedNavigator publicNavigator = new SharedNavigator(false);
private final SharedNavigator teamNavigator = new SharedNavigator(true);
private FeatureNode node;
private List<Destination> appliedVisible;

public NavigatorModule(@Named(FeatureNode.TITAN_NODE) EventNode<Event> titan, Deliver deliver, FeatureFlags featureFlags) {
public NavigatorModule(@Named(FeatureNode.TITAN_NODE) EventNode<Event> titan, Deliver deliver, FeatureFlags featureFlags, PermissionService permissions) {
this.titan = Objects.requireNonNull(titan, "titan must not be null");
this.deliver = Objects.requireNonNull(deliver, "deliver must not be null");
this.featureFlags = Objects.requireNonNull(featureFlags, "featureFlags must not be null");
this.builder = new GlobalInventoryBuilder(MiniMessage.miniMessage().deserialize("<yellow>Navigator"), InventoryType.CHEST_1_ROW);
this.permissions = Objects.requireNonNull(permissions, "permissions must not be null");
}

@PostConstruct
void start() {
// Listener-less: only attached so this feature shows up in the fixed EVENT_PRIORITY order
// and the leak test; Aves handles every inventory click itself.
this.node = FeatureNode.attach(this.titan, ID, EVENT_PRIORITY);
applyLayoutIfChanged();
this.builder.register();
this.publicNavigator.applyLayoutIfChanged(this.featureFlags, this::toAvesLayout);
this.teamNavigator.applyLayoutIfChanged(this.featureFlags, this::toAvesLayout);
this.publicNavigator.register();
this.teamNavigator.register();
}

@PreDestroy
void stop() {
this.node.close();
this.builder.unregister();
this.publicNavigator.unregister();
this.teamNavigator.unregister();
}

void open(Player player) {
applyLayoutIfChanged();
player.openInventory(this.builder.getInventory());
SharedNavigator navigator = isAllowed(player, Destination.BUILD) ? this.teamNavigator : this.publicNavigator;
navigator.applyLayoutIfChanged(this.featureFlags, this::toAvesLayout);
player.openInventory(navigator.inventory());
}

// Test-only: lets a leak test assert the listener count on Aves' event node stays constant
// Test-only: lets a leak test assert the listener count on each Aves event node stays constant
// across opens.
Inventory sharedInventory() {
return this.builder.getInventory();
Inventory publicInventory() {
return this.publicNavigator.inventory();
}

// Synchronized so two threads opening the navigator at once can't observe, or trigger, half of
// a rebuild.
private synchronized void applyLayoutIfChanged() {
List<Destination> visible = Destination.visible(this.featureFlags);
if (visible.equals(this.appliedVisible)) {
return;
}
this.builder.setLayout(toAvesLayout(visible));
this.builder.invalidateLayout();
this.appliedVisible = visible;
Inventory teamInventory() {
return this.teamNavigator.inventory();
}

// NOT_SET counts as not granted, like DENIED (lobby-permissions).
private boolean isAllowed(Player player, Destination destination) {
String permission = destination.permission();
return permission == null || this.permissions.check(player.getUuid(), permission) == PermissionResult.ALLOWED;
}

private InventoryLayout toAvesLayout(List<Destination> visible) {
Expand All @@ -116,6 +120,10 @@ private InventoryLayout toAvesLayout(List<Destination> visible) {
for (Destination destination : visible) {
layout.setItem(destination.slot(), destination.item(), (player, clickedSlot, click, stack, result) -> {
result.accept(ClickHolder.cancelClick());
if (!isAllowed(player, destination)) {
player.closeInventory();
return;
}
this.deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName(destination.task()).player(player).build());
player.closeInventory();
});
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
/**
* 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.navigator;

import java.util.List;
import java.util.function.Function;
import net.kyori.adventure.text.minimessage.MiniMessage;
import net.minestom.server.inventory.Inventory;
import net.minestom.server.inventory.InventoryType;
import net.onelitefeather.titan.core.feature.FeatureFlags;
import net.theevilreaper.aves.inventory.GlobalInventoryBuilder;
import net.theevilreaper.aves.inventory.layout.InventoryLayout;

/**
* One inventory shared by every player who gets this menu, plus the layout state that belongs to
* it. Each instance rebuilds independently, so the public and the team menu never see each
* other's layout.
*/
final class SharedNavigator {

private final GlobalInventoryBuilder builder;
private final boolean withPermissioned;
private List<Destination> appliedVisible;

SharedNavigator(boolean withPermissioned) {
this.builder = new GlobalInventoryBuilder(MiniMessage.miniMessage().deserialize("<yellow>Navigator"), InventoryType.CHEST_1_ROW);
this.withPermissioned = withPermissioned;
}

void register() {
this.builder.register();
}

void unregister() {
this.builder.unregister();
}

Inventory inventory() {
return this.builder.getInventory();
}

// Synchronized so two threads opening the navigator at once can't observe, or trigger, half of
// a rebuild.
synchronized void applyLayoutIfChanged(FeatureFlags featureFlags, Function<List<Destination>, InventoryLayout> layoutFor) {
List<Destination> visible = Destination.visible(featureFlags, this.withPermissioned);
if (visible.equals(this.appliedVisible)) {
return;
}
this.builder.setLayout(layoutFor.apply(visible));
this.builder.invalidateLayout();
this.appliedVisible = visible;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,12 @@
* Plattform-Beans bekommt" - {@code provides = {LobbyItem.class}}: the navigator feather is
* collected by {@code HotbarLobbyItems} in {@code features/hotbar}.
*/
@InjectModule(name = "navigatorColumn", requires = {EventNode.class, Deliver.class, FeatureFlags.class}, requiresString = {"net.minestom.server.event.EventNode<net.minestom.server.event.Event>:titan"}, provides = {LobbyItem.class})
@InjectModule(name = "navigatorColumn", requires = {EventNode.class, Deliver.class, FeatureFlags.class, PermissionService.class}, requiresString = {"net.minestom.server.event.EventNode<net.minestom.server.event.Event>:titan"}, provides = {LobbyItem.class})
package net.onelitefeather.titan.feature.navigator;

import io.avaje.inject.InjectModule;
import net.minestom.server.event.EventNode;
import net.onelitefeather.titan.api.deliver.Deliver;
import net.onelitefeather.titan.core.feature.FeatureFlags;
import net.onelitefeather.titan.core.module.item.LobbyItem;
import net.onelitefeather.titan.core.permission.PermissionService;
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* 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.navigator;

import java.util.ArrayList;
import java.util.List;
import net.kyori.adventure.text.Component;
import net.minestom.server.entity.Player;
import net.minestom.server.event.inventory.InventoryPreClickEvent;
import net.minestom.server.instance.Instance;
import net.minestom.server.inventory.InventoryType;
import net.minestom.server.inventory.click.Click;
import net.minestom.server.item.ItemStack;
import net.minestom.server.item.Material;
import net.minestom.testing.Env;
import net.minestom.testing.extension.MicrotusExtension;
import net.theevilreaper.aves.inventory.GlobalInventoryBuilder;
import net.theevilreaper.aves.inventory.layout.InventoryLayout;
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;

/**
* Pins the assumption behind the navigator's two shared inventories: two registered Aves
* {@link GlobalInventoryBuilder}s each route a click only to their own inventory's handler.
*/
@ExtendWith(MicrotusExtension.class)
class AvesSharedInventoriesTest {

private static GlobalInventoryBuilder builderRecordingInto(String name, List<String> clicks) {
GlobalInventoryBuilder builder = new GlobalInventoryBuilder(Component.text(name), InventoryType.CHEST_1_ROW);
InventoryLayout layout = InventoryLayout.fromType(InventoryType.CHEST_1_ROW);
layout.setItem(0, ItemStack.of(Material.STONE), (player, slot, click, stack, result) -> clicks.add(name));
builder.setLayout(layout);
builder.invalidateLayout();
builder.register();
return builder;
}

@DisplayName("Two registered global inventories route a click only to their own handler")
@Test
void twoRegisteredBuildersRouteClicksToTheirOwnInventory(Env env) {
List<String> clicks = new ArrayList<>();
GlobalInventoryBuilder first = builderRecordingInto("first", clicks);
GlobalInventoryBuilder second = builderRecordingInto("second", clicks);
try {
Instance instance = env.createFlatInstance();
Player player = env.createPlayer(instance);

player.openInventory(first.getInventory());
env.process().eventHandler().call(new InventoryPreClickEvent(player.getOpenInventory(), player, new Click.Left(0)));
Assertions.assertEquals(List.of("first"), clicks, "a click in the first inventory must reach only the first handler");

player.openInventory(second.getInventory());
env.process().eventHandler().call(new InventoryPreClickEvent(player.getOpenInventory(), player, new Click.Left(0)));
Assertions.assertEquals(List.of("first", "second"), clicks, "a click in the second inventory must reach only the second handler");
} finally {
first.unregister();
second.unregister();
}
}
}
Loading
Loading