Skip to content
19 changes: 16 additions & 3 deletions app/src/main/java/net/onelitefeather/titan/app/Titan.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@
import net.onelitefeather.titan.app.listener.*;
import net.onelitefeather.titan.app.player.TitanPlayer;
import net.onelitefeather.titan.common.config.AppConfigProvider;
import net.onelitefeather.titan.common.feature.FeatureAudience;
import net.onelitefeather.titan.common.feature.FeatureGate;
import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy;
import net.onelitefeather.titan.common.deliver.DeliverProvider;
import net.onelitefeather.titan.common.navigator.BuildServerAccess;
import net.onelitefeather.titan.common.navigator.GuardedDeliver;
import net.onelitefeather.titan.common.navigator.TitanBuildServerDirectory;
import net.onelitefeather.titan.common.event.EntityDismountEvent;
import net.onelitefeather.titan.common.helper.BlockHandlerHelper;
import net.onelitefeather.titan.common.map.MapProvider;
Expand All @@ -52,7 +56,7 @@ public final class Titan {

private final Path path;
private final EventNode<Event> eventNode = EventNode.all("titan");
private final Deliver deliver = DeliverProvider.create();
private final Deliver deliver;
private final MapProvider mapProvider;
private final AppConfigProvider appConfigProvider;
private final NavigationHelper navigationHelper;
Expand All @@ -77,8 +81,17 @@ public Titan(Clock clock, ZoneId zone) {
MinecraftServer.getInstanceManager().registerInstance(instance);
this.mapProvider = MapProvider.create(this.path, instance);
this.appConfigProvider = AppConfigProvider.create(this.path);
this.featureGate = FeatureGate.create(LuckPermsFeatureAudience.create(), clock, zone);
this.navigationHelper = NavigationHelper.instance(this.deliver, this.featureGate);
FeatureAudience audience = LuckPermsFeatureAudience.create();
BuildServerAccess buildServerAccess = BuildServerAccess.defaults();
// Every path to another server runs through this Deliver, so the build server permission
// is checked here once more when a switch is requested - a click is a packet, not a proof
// that the menu ever offered the destination (US-5.03).
this.deliver = GuardedDeliver.wrap(DeliverProvider.create(), audience, buildServerAccess);
// The gate is built first: the navigator asks it whether a destination is released at all
// before the build server permission narrows the list any further.
this.featureGate = FeatureGate.create(audience, clock, zone);
this.navigationHelper = NavigationHelper.instance(
this.deliver, audience, this.featureGate, TitanBuildServerDirectory::reachableServices, buildServerAccess);
}

public void initialize() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,40 +23,85 @@
import net.minestom.server.entity.EquipmentSlot;
import net.minestom.server.entity.Player;
import net.minestom.server.inventory.InventoryType;
import net.minestom.server.inventory.click.Click;
import net.minestom.server.item.ItemStack;
import net.onelitefeather.deliver.DeliverComponent;
import net.onelitefeather.titan.api.deliver.Deliver;
import net.onelitefeather.titan.common.feature.FeatureAudience;
import net.onelitefeather.titan.common.feature.FeatureGate;
import net.onelitefeather.titan.common.feature.TitanFeatures;
import net.onelitefeather.titan.common.navigator.BuildServerAccess;
import net.onelitefeather.titan.common.navigator.BuildServerDirectory;
import net.onelitefeather.titan.common.navigator.NavigatorEntry;
import net.onelitefeather.titan.common.navigator.NavigatorLayout;
import net.onelitefeather.titan.common.utils.Items;
import net.theevilreaper.aves.inventory.InventoryLayout;
import net.theevilreaper.aves.inventory.PersonalInventoryBuilder;
import net.theevilreaper.aves.inventory.click.ClickHolder;
import net.theevilreaper.aves.inventory.util.LayoutCalculator;
import org.jetbrains.annotations.Nullable;

import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import java.util.function.Consumer;

/**
* Builds and opens the navigator menu.
*
* <p>The menu is one row and always one row, for everyone. Which entries a player is offered is
* decided per player: the four game modes are public, the build servers need
* {@value BuildServerAccess#PERMISSION} (US-5.01). The slots are not fixed — they are computed
* from the entries the player may see, so an entry that was filtered out leaves no reserved slot
* behind and a player without the permission sees exactly the menu of a lobby that has no build
* servers at all (US-5.02, NFR-005). See {@link NavigatorLayout}.
*
* <p>Both the permission and the list of reachable build servers are read while the menu is being
* drawn, not when the helper is created, so a menu opened a second time reflects a stopped server
* or a withdrawn permission (US-5.04).
*
* @author TheMeinerLP
* @version 2.0.0
* @since 1.15.0
*/
public class NavigationHelper {

private static final InventoryType NAVIGATOR_TYPE = InventoryType.CHEST_1_ROW;

/**
* The destinations every lobby offers, each paired with the feature that releases it.
* A destination is drawn only when {@link FeatureGate} admits its feature for the player
* (US-3.01 to US-3.04, US-3.06) - the build servers add their own permission check on top.
*/
private static final List<GatedEntry> PUBLIC_ENTRIES = List.of(
new GatedEntry(TitanFeatures.NAVIGATOR_ELYTRA, NavigatorEntry.task(Items.NAVIGATOR_ELYTRA_ITEM_STACK, "ElytraRace")), new GatedEntry(TitanFeatures.NAVIGATOR_SURVIVAL, NavigatorEntry.task(Items.NAVIGATOR_SURVIVAL_ITEM_STACK, "Survival")), new GatedEntry(TitanFeatures.NAVIGATOR_SLENDER, NavigatorEntry.task(Items.NAVIGATOR_SLENDER_ITEM_STACK, "cygnus")), new GatedEntry(TitanFeatures.NAVIGATOR_CREATIVE, NavigatorEntry.task(Items.NAVIGATOR_CREATIVE_ITEM_STACK, "MemberBuild")));

/**
* A public destination together with the feature flag that releases it.
*
* @param feature the feature deciding whether the destination is offered at all
* @param entry the destination as the layout sees it
*/
private record GatedEntry(TitanFeatures feature, NavigatorEntry entry) {
}

private final String inventoryName = "<yellow>Navigator";
private final Deliver deliver;
private final FeatureAudience audience;
private final FeatureGate featureGate;
private final BuildServerDirectory buildServers;
private final BuildServerAccess access;

private final LoadingCache<UUID, PersonalInventoryBuilder> inventoryBuilderLoadingCache = Caffeine.newBuilder().maximumSize(10000).expireAfterWrite(Duration.ofMinutes(5)).refreshAfterWrite(Duration.ofMinutes(1)).build(key -> createPersonalInventoryBuilder(
MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(key)));

private NavigationHelper(Deliver deliver, FeatureGate featureGate) {
private NavigationHelper(Deliver deliver, FeatureAudience audience, FeatureGate featureGate, BuildServerDirectory buildServers, BuildServerAccess access) {
this.deliver = deliver;
this.audience = audience;
this.featureGate = featureGate;
this.buildServers = buildServers;
this.access = access;
}

public void openNavigator(Player player) {
PersonalInventoryBuilder personalInventoryBuilder = inventoryBuilderLoadingCache.get(player.getUuid());
// The builder is cached per player, the layout is not: invalidating it runs the data
// layout function again, so a flag changed since the last open takes effect on this open.
personalInventoryBuilder.invalidateDataLayout();
personalInventoryBuilder.open();
}
Expand All @@ -67,62 +112,93 @@ public void setItems(Player player) {
player.getInventory().setEquipment(EquipmentSlot.CHESTPLATE, (byte) EquipmentSlot.CHESTPLATE.armorSlot(), Items.PLAYER_ELYTRA);
}

private PersonalInventoryBuilder createPersonalInventoryBuilder(Player player) {
private @Nullable PersonalInventoryBuilder createPersonalInventoryBuilder(@Nullable Player player) {
if (player == null)
return null;
PersonalInventoryBuilder inventoryBuilder = new PersonalInventoryBuilder(
MiniMessage.miniMessage().deserialize(inventoryName), InventoryType.CHEST_1_ROW, player);
inventoryBuilder.setLayout(InventoryLayout.fromType(InventoryType.CHEST_1_ROW));
MiniMessage.miniMessage().deserialize(inventoryName), NAVIGATOR_TYPE, player);
inventoryBuilder.setLayout(InventoryLayout.fromType(NAVIGATOR_TYPE));
inventoryBuilder.setDataLayoutFunction(layout -> {
InventoryLayout finalLayout = layout != null ? layout : InventoryLayout.fromType(InventoryType.CHEST_1_ROW);

finalLayout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_1_ROW), Items.NAVIGATOR_BLANK_ITEM_STACK);
// Every destination is gated (US-3.01 to US-3.04, US-3.06). A denied entry is not
// written, so its slot keeps the filler pane the whole row was just filled with.
if (isVisible(TitanFeatures.NAVIGATOR_ELYTRA, player)) {
finalLayout.setItem(0, Items.NAVIGATOR_ELYTRA_ITEM_STACK, this::clickElytra);
}
if (isVisible(TitanFeatures.NAVIGATOR_SURVIVAL, player)) {
finalLayout.setItem(4, Items.NAVIGATOR_SURVIVAL_ITEM_STACK, this::clickSurvival);
}
if (isVisible(TitanFeatures.NAVIGATOR_SLENDER, player)) {
finalLayout.setItem(5, Items.NAVIGATOR_SLENDER_ITEM_STACK, this::clickSlender);
}
if (isVisible(TitanFeatures.NAVIGATOR_CREATIVE, player)) {
finalLayout.setItem(8, Items.NAVIGATOR_CREATIVE_ITEM_STACK, this::clickCreative);
InventoryLayout finalLayout = layout != null ? layout : InventoryLayout.fromType(NAVIGATOR_TYPE);

// Blank the whole row first: every slot that no visible entry claims is filler, and
// filler is what a slot holding a hidden entry would have to look like anyway.
finalLayout.setItems(LayoutCalculator.fillRow(NAVIGATOR_TYPE), Items.NAVIGATOR_BLANK_ITEM_STACK);
for (NavigatorLayout.Placement placement : layoutFor(player.getUuid())) {
NavigatorEntry entry = placement.entry();
finalLayout.setItem(placement.slot(), entry.icon(), (clicker, slot, click, itemStack, result) -> {
this.deliver.sendPlayer(clicker, entry.toComponent(clicker.getUuid()));
result.accept(ClickHolder.cancelClick());
});
}
return finalLayout;
});
inventoryBuilder.register();
return inventoryBuilder;
}

private boolean isVisible(TitanFeatures feature, Player player) {
return this.featureGate.isVisibleTo(feature, player.getUuid());
/**
* Plans the menu for one player: which entries they are offered and where those sit.
*
* @param playerId the player the menu is drawn for
* @return the visible entries with their slots, in ascending slot order
*/
List<NavigatorLayout.Placement> layoutFor(UUID playerId) {
return NavigatorLayout.plan(entriesFor(playerId), playerId, this.audience, NAVIGATOR_TYPE.getSize());
}

private void clickElytra(Player player, int slot, Click click, ItemStack itemStack, Consumer<ClickHolder> result) {
deliver.sendPlayer(player, DeliverComponent.taskBuilder().taskName("ElytraRace").player(player).build());
result.accept(ClickHolder.cancelClick());
}

private void clickSurvival(Player player, int slot, Click click, ItemStack itemStack, Consumer<ClickHolder> result) {
deliver.sendPlayer(player, DeliverComponent.taskBuilder().player(player).taskName("Survival").build());
result.accept(ClickHolder.cancelClick());
}

private void clickSlender(Player player, int slot, Click click, ItemStack itemStack, Consumer<ClickHolder> result) {
deliver.sendPlayer(player, DeliverComponent.taskBuilder().player(player).taskName("cygnus").build());
result.accept(ClickHolder.cancelClick());
/**
* Collects every entry the navigator could offer this player. The build servers are only
* looked up for a player who holds the permission — a player who does not is not a reason to
* ask CloudNet anything, and their menu must not depend on the answer.
*
* <p>What the directory reports is taken as it stands. CloudNet is asked for the services of
* the build task by name ({@code servicesByTask}) and is the authority on which those are;
* re-deriving the membership from the service name here could only ever subtract from that
* answer, and would subtract everything for a task whose {@code nameSplitter} is not the one
* Titan is configured with. Deriving membership from a name is still what
* {@link BuildServerAccess} does for the guard, where it has to be a name, because the guard
* must also recognise a build server that is not in this list any more.
*
* @param playerId the player the menu is drawn for
* @return the public entries, followed by the reachable build servers in a stable order
*/
private List<NavigatorEntry> entriesFor(UUID playerId) {
List<NavigatorEntry> entries = new ArrayList<>(PUBLIC_ENTRIES.size());
for (GatedEntry gated : PUBLIC_ENTRIES) {
if (this.featureGate.isVisibleTo(gated.feature(), playerId)) {
entries.add(gated.entry());
}
}
if (!this.audience.hasPermission(playerId, this.access.permission())) {
return List.copyOf(entries);
}
this.buildServers.reachableServices().stream().sorted().map(service -> NavigatorEntry.restrictedServer(Items.navigatorBuildServer(service), service, this.access.permission())).forEach(entries::add);
return List.copyOf(entries);
}

private void clickCreative(Player player, int slot, Click click, ItemStack itemStack, Consumer<ClickHolder> result) {
deliver.sendPlayer(player, DeliverComponent.taskBuilder().player(player).taskName("MemberBuild").build());
result.accept(ClickHolder.cancelClick());
/**
* Creates a navigator that offers the public entries only. Used where no permission backend is
* available, which is the safe reading of "unknown player".
*
* @param deliver the delivery used to move a player on click
* @return a navigator without build servers
*/
public static NavigationHelper instance(Deliver deliver, FeatureGate featureGate) {
return instance(deliver, FeatureAudience.denyAll(), featureGate, BuildServerDirectory.empty(), BuildServerAccess.defaults());
}

public static NavigationHelper instance(Deliver deliver, FeatureGate featureGate) {
return new NavigationHelper(deliver, featureGate);
/**
* Creates a navigator that can also offer the build servers.
*
* @param deliver the delivery used to move a player on click
* @param audience the source of permission answers, asked every time the menu is drawn
* @param buildServers the currently reachable build servers
* @param access which destinations are build servers and what they require
* @return the navigator
*/
public static NavigationHelper instance(Deliver deliver, FeatureAudience audience, FeatureGate featureGate, BuildServerDirectory buildServers, BuildServerAccess access) {
return new NavigationHelper(deliver, audience, featureGate, buildServers, access);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Application-side helpers that turn Titan's domain types into what a player actually sees — the
* navigator menu above all.
*
* <p>The rules the navigator follows live in
* {@link net.onelitefeather.titan.common.navigator}; this package only wires them to Aves and to
* the running server.
*/
@NotNullByDefault
package net.onelitefeather.titan.app.helper;

import org.jetbrains.annotations.NotNullByDefault;
Loading
Loading