diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index a70ae7a7..504ba387 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -43,6 +43,12 @@ dependencies {
implementation(libs.kotlin.stdlib.jdk8)
testImplementation(platform(libs.aonyx.bom))
+ // compileOnly does not reach the test classpath, and LuckPermsFeatureAudienceTest needs the
+ // API types to stand in for a running LuckPerms. Same adventure exclude as the main source
+ // set: the API artifact pulls an adventure version Minestom does not agree with.
+ testImplementation(libs.luckperms.api) {
+ exclude(group = "net.kyori.adventure")
+ }
testImplementation(libs.minestom)
testImplementation(libs.aves)
testImplementation(libs.cyano)
@@ -87,6 +93,23 @@ tasks {
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
exclude("module-info.class", "META-INF/versions/**/module-info.class")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
+ // EXCLUDE keeps the first copy of every duplicate path and pre-empts
+ // mergeServiceFiles(), so a service file shipped by two jars would lose all but
+ // one set of entries. Titan and togglz-core both ship
+ // META-INF/services/org.togglz.core.spi.ActivationStrategy (the season window here,
+ // the built-in strategies there) and both must survive - let those paths through so
+ // the merge transformer sees every copy.
+ // ServiceFileTransformer, which mergeServiceFiles() installs, deliberately does NOT
+ // handle META-INF/services/org.codehaus.groovy.runtime.ExtensionModule - that descriptor
+ // is not a service file and is merged by GroovyExtensionModuleTransformer instead. Letting
+ // it through as INCLUDE would concatenate two copies verbatim into an unparsable file. No
+ // Groovy is on the classpath today, so keep the exception narrow and explicit rather than
+ // widening the pattern above.
+ filesMatching("META-INF/services/**") {
+ if (path != "META-INF/services/org.codehaus.groovy.runtime.ExtensionModule") {
+ duplicatesStrategy = DuplicatesStrategy.INCLUDE
+ }
+ }
}
test {
useJUnitPlatform()
diff --git a/app/src/main/java/net/onelitefeather/titan/app/Titan.java b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
index 42a23911..95959cde 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java
@@ -25,14 +25,19 @@
import net.minestom.server.event.item.PickupItemEvent;
import net.minestom.server.event.player.*;
import net.minestom.server.instance.InstanceContainer;
+import net.minestom.server.timer.TaskSchedule;
import net.onelitefeather.butterfly.minestom.Butterfly;
import net.onelitefeather.titan.api.deliver.Deliver;
import net.onelitefeather.titan.app.commands.EndCommand;
+import net.onelitefeather.titan.app.commands.SeasonCommand;
import net.onelitefeather.titan.app.commands.StopCommand;
+import net.onelitefeather.titan.app.feature.LuckPermsFeatureAudience;
import net.onelitefeather.titan.app.helper.NavigationHelper;
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.FeatureGate;
+import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy;
import net.onelitefeather.titan.common.deliver.DeliverProvider;
import net.onelitefeather.titan.common.event.EntityDismountEvent;
import net.onelitefeather.titan.common.helper.BlockHandlerHelper;
@@ -40,6 +45,8 @@
import net.onelitefeather.titan.common.utils.Cancelable;
import java.nio.file.Path;
+import java.time.Clock;
+import java.time.ZoneId;
public final class Titan {
@@ -49,8 +56,20 @@ public final class Titan {
private final MapProvider mapProvider;
private final AppConfigProvider appConfigProvider;
private final NavigationHelper navigationHelper;
+ private final FeatureGate featureGate;
public Titan() {
+ this(Clock.system(SeasonWindowActivationStrategy.DEFAULT_ZONE), SeasonWindowActivationStrategy.DEFAULT_ZONE);
+ }
+
+ /**
+ * Creates the lobby with an explicit time source, so seasons and release windows can be tested
+ * without waiting for real time (NFR-007).
+ *
+ * @param clock the time source release windows are evaluated against
+ * @param zone the zone seasons are planned in
+ */
+ public Titan(Clock clock, ZoneId zone) {
MinecraftServer.getConnectionManager().setPlayerProvider(TitanPlayer::new);
this.path = Path.of("");
BlockHandlerHelper.registerAll();
@@ -58,7 +77,8 @@ public Titan() {
MinecraftServer.getInstanceManager().registerInstance(instance);
this.mapProvider = MapProvider.create(this.path, instance);
this.appConfigProvider = AppConfigProvider.create(this.path);
- this.navigationHelper = NavigationHelper.instance(this.deliver);
+ this.featureGate = FeatureGate.create(LuckPermsFeatureAudience.create(), clock, zone);
+ this.navigationHelper = NavigationHelper.instance(this.deliver, this.featureGate);
}
public void initialize() {
@@ -66,6 +86,11 @@ public void initialize() {
initCommands();
Butterfly butterfly = Butterfly.create();
butterfly.load();
+ // Stages live in a flag file that is reloaded in the background, so a stage change is a
+ // difference between two observations rather than an event. Walk the features once a
+ // second so a transition is logged even while nobody is online (US-3.09).
+ MinecraftServer.getSchedulerManager().scheduleTask(
+ this.featureGate::pollStageTransitions, TaskSchedule.seconds(1), TaskSchedule.seconds(1));
MinecraftServer.getSchedulerManager().buildShutdownTask(this::terminate);
MinecraftServer.getSchedulerManager().buildShutdownTask(butterfly::terminate);
}
@@ -77,6 +102,7 @@ public void terminate() {
private void initCommands() {
MinecraftServer.getCommandManager().register(new EndCommand());
MinecraftServer.getCommandManager().register(new StopCommand());
+ MinecraftServer.getCommandManager().register(new SeasonCommand(this.featureGate));
}
private void initListeners() {
diff --git a/app/src/main/java/net/onelitefeather/titan/app/commands/EndCommand.java b/app/src/main/java/net/onelitefeather/titan/app/commands/EndCommand.java
index 69e2fe30..f976ea46 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/commands/EndCommand.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/commands/EndCommand.java
@@ -20,7 +20,6 @@
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.CommandContext;
-import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
public final class EndCommand extends Command {
@@ -30,12 +29,12 @@ public EndCommand() {
addSyntax(this::execute);
}
- private void execute(@NotNull CommandSender commandSender, @NotNull CommandContext commandContext) {
+ private void execute(CommandSender commandSender, CommandContext commandContext) {
MinecraftServer.stopCleanly();
System.exit(0);
}
- private boolean hasPermission(@NotNull CommandSender commandSender, @Nullable String s) {
+ private boolean hasPermission(CommandSender commandSender, @Nullable String s) {
return false;
// return commandSender.hasPermission("titan.command.end") ||
// commandSender.hasPermission("lobby.end");
diff --git a/app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java b/app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java
new file mode 100644
index 00000000..bf4ce84e
--- /dev/null
+++ b/app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java
@@ -0,0 +1,142 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.commands;
+
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.format.NamedTextColor;
+import net.kyori.adventure.util.TriState;
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.command.builder.Command;
+import net.minestom.server.command.builder.arguments.ArgumentType;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.titan.common.feature.FeatureGate;
+import net.onelitefeather.titan.common.feature.FeatureStatus;
+import net.onelitefeather.titan.common.feature.ReleaseStage;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Shows the rollout state of every feature to the team: release stage, time window and kill switch
+ * per feature (US-3.08).
+ *
+ *
Togglz ships an admin console, but it is a servlet application; a Minestom process has no
+ * servlet container, so a command is what replaces it. The command is bound to
+ * {@value ReleaseStage#INTERNAL_PERMISSION} — the same permission that defines the internal
+ * audience — and, like {@code /stop}, is always available from the server console.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class SeasonCommand extends Command {
+
+ private static final DateTimeFormatter WINDOW_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
+
+ private final FeatureGate featureGate;
+
+ /**
+ * Creates the command.
+ *
+ * @param featureGate the gate the status is read from
+ */
+ public SeasonCommand(FeatureGate featureGate) {
+ super("season");
+ this.featureGate = featureGate;
+ setCondition(SeasonCommand::canUse);
+ setDefaultExecutor((sender, context) -> sender.sendMessage(
+ Component.text("Usage: /season status", NamedTextColor.RED)));
+ addSyntax((sender, context) -> sendStatus(sender), ArgumentType.Literal("status"));
+ }
+
+ /**
+ * Renders one feature as a single chat line: name, stage, window and kill switch.
+ *
+ * @param status the feature status to render
+ * @return the line shown to the sender
+ */
+ static Component describe(FeatureStatus status) {
+ Component line = Component.text(status.feature(), NamedTextColor.WHITE).append(Component.text(" | stage ", NamedTextColor.DARK_GRAY)).append(describeStage(status)).append(Component.text(" | window ", NamedTextColor.DARK_GRAY)).append(describeWindow(status));
+ return line.append(Component.text(" | kill switch ", NamedTextColor.DARK_GRAY)).append(status.killSwitchEngaged() ? Component.text("engaged", NamedTextColor.RED) : Component.text("off", NamedTextColor.GREEN));
+ }
+
+ private static Component describeStage(FeatureStatus status) {
+ Component stage = Component.text(status.stage().id(), stageColor(status.stage()));
+ if (status.stageReadable()) {
+ return stage;
+ }
+ // The gate fell back to the narrowest stage. Say so, and name the value that was written:
+ // "intern" and "premium" are both plausible typos for the ids this project actually uses.
+ return stage.append(Component.text(" (unreadable: '" + status.unknownStage() + "' is not internal, lite or ga)", NamedTextColor.RED));
+ }
+
+ private static Component describeWindow(FeatureStatus status) {
+ if (!status.windowReadable()) {
+ // Never print "always" here: the gate is denying everyone, and a status that says the
+ // feature runs unbounded would send the operator looking in the wrong place.
+ return Component.text("unreadable: " + status.windowProblem(), NamedTextColor.RED);
+ }
+ if (!status.hasWindow()) {
+ return Component.text("always", NamedTextColor.GRAY);
+ }
+ String from = status.from() == null ? "-∞" : WINDOW_FORMAT.format(status.from());
+ String to = status.to() == null ? "∞" : WINDOW_FORMAT.format(status.to());
+ NamedTextColor color = status.withinWindow() ? NamedTextColor.GREEN : NamedTextColor.GOLD;
+ return Component.text(from + " to " + to + " (" + status.zone().getId() + ", ", color).append(Component.text(status.withinWindow() ? "open)" : "closed)", color));
+ }
+
+ private static NamedTextColor stageColor(ReleaseStage stage) {
+ return switch (stage) {
+ case INTERNAL -> NamedTextColor.RED;
+ case LITE -> NamedTextColor.GOLD;
+ case GA -> NamedTextColor.GREEN;
+ };
+ }
+
+ private static boolean canUse(CommandSender sender, @Nullable String commandString) {
+ if (!(sender instanceof Player)) {
+ return true;
+ }
+ return sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE)).test(ReleaseStage.INTERNAL_PERMISSION);
+ }
+
+ /**
+ * Builds the lines {@code /season status} prints: one header plus one line per feature.
+ *
+ * @return the rendered status, in the order the gate reports the features
+ */
+ List statusLines() {
+ List statuses = this.featureGate.statuses();
+ List lines = new ArrayList<>();
+ lines.add(Component.text("Feature rollout (" + statuses.size() + ")", NamedTextColor.YELLOW));
+ if (statuses.isEmpty()) {
+ lines.add(Component.text("No features are registered.", NamedTextColor.GRAY));
+ return List.copyOf(lines);
+ }
+ for (FeatureStatus status : statuses) {
+ lines.add(describe(status));
+ }
+ return List.copyOf(lines);
+ }
+
+ private void sendStatus(CommandSender sender) {
+ statusLines().forEach(sender::sendMessage);
+ }
+}
diff --git a/app/src/main/java/net/onelitefeather/titan/app/commands/StopCommand.java b/app/src/main/java/net/onelitefeather/titan/app/commands/StopCommand.java
index 596b0691..db90bdac 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/commands/StopCommand.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/commands/StopCommand.java
@@ -22,7 +22,6 @@
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.entity.Player;
-import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
/**
@@ -49,7 +48,7 @@ public StopCommand() {
}));
}
- private boolean canStop(@NotNull CommandSender sender, @Nullable String commandString) {
+ private boolean canStop(CommandSender sender, @Nullable String commandString) {
if (!(sender instanceof Player)) {
return true;
}
diff --git a/app/src/main/java/net/onelitefeather/titan/app/commands/package-info.java b/app/src/main/java/net/onelitefeather/titan/app/commands/package-info.java
new file mode 100644
index 00000000..6ce86441
--- /dev/null
+++ b/app/src/main/java/net/onelitefeather/titan/app/commands/package-info.java
@@ -0,0 +1,7 @@
+/**
+ * Commands the lobby registers with Minestom's command manager.
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.app.commands;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java b/app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java
new file mode 100644
index 00000000..77f56e3f
--- /dev/null
+++ b/app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java
@@ -0,0 +1,193 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.feature;
+
+import net.kyori.adventure.permission.PermissionChecker;
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.LuckPermsProvider;
+import net.luckperms.api.context.ContextManager;
+import net.luckperms.api.model.group.Group;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.query.QueryOptions;
+import net.minestom.server.MinecraftServer;
+import net.minestom.server.entity.Player;
+import net.onelitefeather.titan.app.player.TitanPlayer;
+import net.onelitefeather.titan.common.feature.FeatureAudience;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.UUID;
+import java.util.concurrent.atomic.AtomicBoolean;
+import java.util.function.BooleanSupplier;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+/**
+ * Answers the gate's questions through LuckPerms, the permission system Titan already embeds.
+ *
+ * One answer per question. A permission is answered by the online player's
+ * {@link PermissionChecker} - on a Titan lobby that is {@link TitanPlayer}, the very object
+ * {@code /season}'s command condition consults through {@link PermissionChecker#POINTER}. Reusing
+ * it rather than repeating the lookup here (OLF-L2-04) is what keeps the two answers identical: a
+ * team member who holds
+ * {@value net.onelitefeather.titan.common.feature.ReleaseStage#INTERNAL_PERMISSION}
+ * in a server-scoped context is either inside the internal audience for both the command and every
+ * feature, or outside both. The earlier version of this class read
+ * {@link User#getQueryOptions()} - the holder's stored options, which carry no server,
+ * world or dimension context - and so put the same person inside the command's internal audience
+ * and outside the gate's.
+ *
+ *
Group membership has no equivalent on the player object, so it stays on LuckPerms; it is
+ * resolved against {@link ContextManager#getQueryOptions(User)}, the same contextual options
+ * {@link TitanPlayer} evaluates permissions with. An offline player has no context to resolve, so
+ * both paths fall back to {@link ContextManager#getStaticQueryOptions()}.
+ *
+ *
When LuckPerms is not there. {@link LuckPermsProvider#get()} throws while LuckPerms is
+ * loading or after it failed to load, and the gate is asked on every navigator open. Rather than
+ * let that escape into a listener, every answer is caught and turned into {@code false} -
+ * {@link FeatureAudience#denyAll()}'s behaviour, which that method documents as the safe default.
+ * Failing closed is the right direction here because the stages only ever widen: it costs
+ * a team member the sight of an unreleased feature until the backend is back, whereas failing open
+ * would promote every {@code internal} and {@code lite} feature to the whole server at the one
+ * moment nobody can revoke it. {@code ga} features are unaffected either way -
+ * {@link net.onelitefeather.titan.common.feature.ReleaseStage#GA} admits everyone without asking
+ * an audience - so an outage hides work in progress and never hides the lobby.
+ *
+ * @author TheMeinerLP
+ * @version 2.0.0
+ * @since 1.15.0
+ */
+public final class LuckPermsFeatureAudience implements FeatureAudience {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(LuckPermsFeatureAudience.class);
+
+ private final Supplier luckPerms;
+
+ /** Resolves the permission checker of an online player, or {@code null} when none is online. */
+ private final Function onlineChecker;
+
+ /** Guards the log so an outage costs one warning, not one per navigator entry per open. */
+ private final AtomicBoolean unavailable = new AtomicBoolean();
+
+ private LuckPermsFeatureAudience(Supplier luckPerms, Function onlineChecker) {
+ this.luckPerms = luckPerms;
+ this.onlineChecker = onlineChecker;
+ }
+
+ /**
+ * Creates an audience reading from the running LuckPerms instance and the online players.
+ *
+ * @return an audience backed by {@link LuckPermsProvider}
+ */
+ public static LuckPermsFeatureAudience create() {
+ return new LuckPermsFeatureAudience(LuckPermsProvider::get, LuckPermsFeatureAudience::onlineChecker);
+ }
+
+ /**
+ * Creates an audience reading from an explicitly supplied LuckPerms instance.
+ *
+ * @param luckPerms supplies the LuckPerms instance to ask
+ * @return an audience backed by that instance
+ */
+ public static LuckPermsFeatureAudience of(Supplier luckPerms) {
+ return new LuckPermsFeatureAudience(luckPerms, LuckPermsFeatureAudience::onlineChecker);
+ }
+
+ /**
+ * Creates an audience with an explicit player lookup, so a test can stand in for the running
+ * server without booting one.
+ *
+ * @param luckPerms supplies the LuckPerms instance to ask
+ * @param onlineChecker resolves the permission checker of an online player, {@code null} when
+ * that player is not online
+ * @return an audience backed by both
+ */
+ static LuckPermsFeatureAudience of(Supplier luckPerms, Function onlineChecker) {
+ return new LuckPermsFeatureAudience(luckPerms, onlineChecker);
+ }
+
+ @Override
+ public boolean hasPermission(UUID playerId, String permission) {
+ return answer(() -> {
+ PermissionChecker checker = this.onlineChecker.apply(playerId);
+ if (checker != null) {
+ return checker.test(permission);
+ }
+ User user = user(playerId);
+ return user != null && user.getCachedData().getPermissionData(queryOptions(user)).checkPermission(permission).asBoolean();
+ });
+ }
+
+ @Override
+ public boolean inGroup(UUID playerId, String group) {
+ return answer(() -> {
+ User user = user(playerId);
+ if (user == null) {
+ return false;
+ }
+ for (Group inherited : user.getInheritedGroups(queryOptions(user))) {
+ if (inherited.getName().equalsIgnoreCase(group)) {
+ return true;
+ }
+ }
+ return false;
+ });
+ }
+
+ /**
+ * Resolves the permission checker of the player behind the id, or {@code null} when nobody by
+ * that id is online. On a Titan lobby the checker is the {@link TitanPlayer} itself.
+ */
+ private static @Nullable PermissionChecker onlineChecker(UUID playerId) {
+ Player player = MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(playerId);
+ return player == null ? null : player.get(PermissionChecker.POINTER).orElse(null);
+ }
+
+ /**
+ * Returns the query options a permission or group question is answered with: the player's
+ * current context while they are online, the static context otherwise. This is the same
+ * resolution {@link TitanPlayer} performs, which is what keeps the two answers in step.
+ */
+ private QueryOptions queryOptions(User user) {
+ ContextManager contexts = this.luckPerms.get().getContextManager();
+ return contexts.getQueryOptions(user).orElseGet(contexts::getStaticQueryOptions);
+ }
+
+ private @Nullable User user(UUID playerId) {
+ return this.luckPerms.get().getUserManager().getUser(playerId);
+ }
+
+ /**
+ * Evaluates one question, denying rather than propagating when the permission backend is not
+ * available. See the class javadoc for why the failure direction is closed.
+ */
+ private boolean answer(BooleanSupplier question) {
+ try {
+ boolean answer = question.getAsBoolean();
+ if (this.unavailable.compareAndSet(true, false)) {
+ LOGGER.info("The permission backend answers again; release stages are enforced normally.");
+ }
+ return answer;
+ } catch (RuntimeException exception) {
+ if (this.unavailable.compareAndSet(false, true)) {
+ LOGGER.warn("The permission backend did not answer; every feature below stage 'ga' stays hidden until it does.", exception);
+ }
+ return false;
+ }
+ }
+}
diff --git a/app/src/main/java/net/onelitefeather/titan/app/feature/package-info.java b/app/src/main/java/net/onelitefeather/titan/app/feature/package-info.java
new file mode 100644
index 00000000..afc75615
--- /dev/null
+++ b/app/src/main/java/net/onelitefeather/titan/app/feature/package-info.java
@@ -0,0 +1,8 @@
+/**
+ * Application-side wiring of the staged feature delivery: the LuckPerms-backed answer to the
+ * questions {@link net.onelitefeather.titan.common.feature.FeatureGate} asks about a player.
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.app.feature;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/app/src/main/java/net/onelitefeather/titan/app/helper/NavigationHelper.java b/app/src/main/java/net/onelitefeather/titan/app/helper/NavigationHelper.java
index 15f6388c..29da7d04 100644
--- a/app/src/main/java/net/onelitefeather/titan/app/helper/NavigationHelper.java
+++ b/app/src/main/java/net/onelitefeather/titan/app/helper/NavigationHelper.java
@@ -27,13 +27,13 @@
import net.minestom.server.item.ItemStack;
import net.onelitefeather.deliver.DeliverComponent;
import net.onelitefeather.titan.api.deliver.Deliver;
+import net.onelitefeather.titan.common.feature.FeatureGate;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
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.togglz.core.user.SimpleFeatureUser;
-import org.togglz.core.user.thread.ThreadLocalUserProvider;
import java.time.Duration;
import java.util.UUID;
@@ -43,16 +43,20 @@ public class NavigationHelper {
private final String inventoryName = "Navigator";
private final Deliver deliver;
+ private final FeatureGate featureGate;
private final LoadingCache inventoryBuilderLoadingCache = Caffeine.newBuilder().maximumSize(10000).expireAfterWrite(Duration.ofMinutes(5)).refreshAfterWrite(Duration.ofMinutes(1)).build(key -> createPersonalInventoryBuilder(
MinecraftServer.getConnectionManager().getOnlinePlayerByUuid(key)));
- private NavigationHelper(Deliver deliver) {
+ private NavigationHelper(Deliver deliver, FeatureGate featureGate) {
this.deliver = deliver;
+ this.featureGate = featureGate;
}
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();
}
@@ -73,20 +77,28 @@ private PersonalInventoryBuilder createPersonalInventoryBuilder(Player player) {
InventoryLayout finalLayout = layout != null ? layout : InventoryLayout.fromType(InventoryType.CHEST_1_ROW);
finalLayout.setItems(LayoutCalculator.fillRow(InventoryType.CHEST_1_ROW), Items.NAVIGATOR_BLANK_ITEM_STACK);
- ThreadLocalUserProvider.bind(toUser(player));
- finalLayout.setItem(0, Items.NAVIGATOR_ELYTRA_ITEM_STACK, this::clickElytra);
- finalLayout.setItem(4, Items.NAVIGATOR_SURVIVAL_ITEM_STACK, this::clickSurvival);
- finalLayout.setItem(5, Items.NAVIGATOR_SLENDER_ITEM_STACK, this::clickSlender);
- finalLayout.setItem(8, Items.NAVIGATOR_CREATIVE_ITEM_STACK, this::clickCreative);
- ThreadLocalUserProvider.release();
+ // 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);
+ }
return finalLayout;
});
inventoryBuilder.register();
return inventoryBuilder;
}
- private SimpleFeatureUser toUser(Player player) {
- return new SimpleFeatureUser(player.getUsername());
+ private boolean isVisible(TitanFeatures feature, Player player) {
+ return this.featureGate.isVisibleTo(feature, player.getUuid());
}
private void clickElytra(Player player, int slot, Click click, ItemStack itemStack, Consumer result) {
@@ -109,8 +121,8 @@ private void clickCreative(Player player, int slot, Click click, ItemStack itemS
result.accept(ClickHolder.cancelClick());
}
- public static NavigationHelper instance(Deliver deliver) {
- return new NavigationHelper(deliver);
+ public static NavigationHelper instance(Deliver deliver, FeatureGate featureGate) {
+ return new NavigationHelper(deliver, featureGate);
}
}
diff --git a/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java b/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java
new file mode 100644
index 00000000..d4173616
--- /dev/null
+++ b/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java
@@ -0,0 +1,160 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.commands;
+
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.text.Component;
+import net.kyori.adventure.text.serializer.plain.PlainTextComponentSerializer;
+import net.kyori.adventure.util.TriState;
+import net.minestom.server.command.CommandSender;
+import net.minestom.server.command.builder.condition.CommandCondition;
+import net.minestom.server.entity.Player;
+import net.minestom.server.instance.Instance;
+import net.minestom.testing.Env;
+import net.minestom.testing.extension.MicrotusExtension;
+import net.onelitefeather.titan.common.feature.FeatureAudience;
+import net.onelitefeather.titan.common.feature.FeatureGate;
+import net.onelitefeather.titan.common.feature.ReleaseStage;
+import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.togglz.core.activation.DefaultActivationStrategyProvider;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.manager.FeatureManagerBuilder;
+import org.togglz.core.repository.FeatureState;
+import org.togglz.core.repository.mem.InMemoryStateRepository;
+import org.togglz.core.user.NoOpUserProvider;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.List;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.spy;
+
+@ExtendWith(MicrotusExtension.class)
+class SeasonCommandTest {
+
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+ private static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z");
+
+ private InMemoryStateRepository repository;
+ private SeasonCommand command;
+
+ @BeforeEach
+ void setUp() {
+ this.repository = new InMemoryStateRepository();
+ FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(this.repository).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+ FeatureGate gate = FeatureGate.with(featureManager, FeatureAudience.denyAll(), Clock.fixed(NOW, ZoneOffset.UTC), BERLIN);
+ this.command = new SeasonCommand(gate);
+ }
+
+ private static String plain(Component component) {
+ return PlainTextComponentSerializer.plainText().serialize(component);
+ }
+
+ private String lineFor(TitanFeatures feature) {
+ return this.command.statusLines().stream().map(SeasonCommandTest::plain).filter(candidate -> candidate.startsWith(feature.name())).findFirst().orElseThrow();
+ }
+
+ private static Player playerWith(Env env, Instance instance, boolean permitted) {
+ Player player = spy(env.createPlayer(instance));
+ doReturn(PermissionChecker.always(permitted ? TriState.TRUE : TriState.FALSE)).when(player).getOrDefault(eq(PermissionChecker.POINTER), any());
+ return player;
+ }
+
+ @Test
+ @DisplayName("only holders of titan.feature.internal may run the command")
+ void onlyTheTeamMayRunTheCommand(Env env) {
+ CommandCondition condition = this.command.getCondition();
+ assertNotNull(condition);
+ Instance instance = env.createFlatInstance();
+
+ assertTrue(condition.canUse(playerWith(env, instance, true), null));
+ assertFalse(condition.canUse(playerWith(env, instance, false), null));
+ }
+
+ @Test
+ @DisplayName("the server console may always run the command")
+ void theConsoleMayAlwaysRunTheCommand() {
+ assertTrue(this.command.getCondition().canUse(mock(CommandSender.class), null));
+ }
+
+ @Test
+ @DisplayName("the status lists every feature with stage, window and kill switch")
+ void statusListsStageWindowAndKillSwitch() {
+ this.repository.setFeatureState(new FeatureState(TitanFeatures.NAVIGATOR_ELYTRA, true).setStrategyId(SeasonWindowActivationStrategy.ID).setParameter(FeatureGate.STAGE_PARAMETER, ReleaseStage.LITE.id()).setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-01").setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2026-11-05"));
+
+ List lines = this.command.statusLines();
+
+ assertEquals(TitanFeatures.values().length + 1, lines.size());
+ assertTrue(plain(lines.getFirst()).contains("Feature rollout (" + TitanFeatures.values().length + ")"));
+ String elytra = lines.stream().map(SeasonCommandTest::plain).filter(line -> line.startsWith(TitanFeatures.NAVIGATOR_ELYTRA.name())).findFirst().orElseThrow();
+ assertTrue(elytra.contains("stage lite"), elytra);
+ assertTrue(elytra.contains("2026-10-01 00:00 to 2026-11-05 00:00 (Europe/Berlin, open)"), elytra);
+ assertTrue(elytra.contains("kill switch off"), elytra);
+ }
+
+ @Test
+ @DisplayName("an unreadable window is printed as unreadable, not as 'always'")
+ void unreadableWindowIsPrintedAsUnreadable() {
+ this.repository.setFeatureState(new FeatureState(TitanFeatures.NAVIGATOR_ELYTRA, true).setStrategyId(SeasonWindowActivationStrategy.ID).setParameter(FeatureGate.STAGE_PARAMETER, ReleaseStage.GA.id()).setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "1. Oktober"));
+
+ String line = lineFor(TitanFeatures.NAVIGATOR_ELYTRA);
+
+ // The gate denies everyone; the status has to point at the typo instead of claiming the
+ // feature runs unbounded.
+ assertTrue(line.contains("window unreadable"), line);
+ assertTrue(line.contains("1. Oktober"), line);
+ assertFalse(line.contains("window always"), line);
+ }
+
+ @Test
+ @DisplayName("an unknown stage id is printed next to the stage that was applied instead")
+ void unknownStageIsPrinted() {
+ this.repository.setFeatureState(new FeatureState(TitanFeatures.NAVIGATOR_ELYTRA, true).setParameter(FeatureGate.STAGE_PARAMETER, "intern"));
+
+ String line = lineFor(TitanFeatures.NAVIGATOR_ELYTRA);
+
+ assertTrue(line.contains("stage internal"), line);
+ assertTrue(line.contains("'intern' is not internal, lite or ga"), line);
+ }
+
+ @Test
+ @DisplayName("a switched off feature without a window is reported as such")
+ void switchedOffFeatureIsReported() {
+ this.repository.setFeatureState(new FeatureState(TitanFeatures.NAVIGATOR_SLENDER, false).setParameter(FeatureGate.STAGE_PARAMETER, ReleaseStage.GA.id()));
+
+ String line = this.command.statusLines().stream().map(SeasonCommandTest::plain).filter(candidate -> candidate.startsWith(TitanFeatures.NAVIGATOR_SLENDER.name())).findFirst().orElseThrow();
+
+ assertTrue(line.contains("stage ga"), line);
+ assertTrue(line.contains("window always"), line);
+ assertTrue(line.contains("kill switch engaged"), line);
+ }
+}
diff --git a/app/src/test/java/net/onelitefeather/titan/app/feature/FeatureManagerProviderResolutionTest.java b/app/src/test/java/net/onelitefeather/titan/app/feature/FeatureManagerProviderResolutionTest.java
new file mode 100644
index 00000000..a6dc88ed
--- /dev/null
+++ b/app/src/test/java/net/onelitefeather/titan/app/feature/FeatureManagerProviderResolutionTest.java
@@ -0,0 +1,93 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.feature;
+
+import net.onelitefeather.titan.common.feature.SingletonFeatureManagerProvider;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.togglz.core.Feature;
+import org.togglz.core.context.FeatureContext;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.spi.FeatureManagerProvider;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ServiceLoader;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Guards which {@link FeatureManagerProvider} wins the ambient {@link FeatureContext}.
+ *
+ * Butterfly ships a provider of its own that reads the same {@code flags.properties} but builds
+ * its manager from {@code ButterflyFeatures}. Both providers used to declare priority {@code 30},
+ * so the winner was decided by whichever entry the merged {@code META-INF/services} file listed
+ * first - shadow's classpath walk order. If Butterfly won, {@code FeatureGate.statuses()},
+ * {@code FeatureGate.pollStageTransitions()} and {@code /season status} would enumerate the wrong
+ * enum while {@code getFeatureState} kept resolving Titan's flags by name, which is a partial and
+ * confusing failure rather than a loud one.
+ *
+ *
This test runs in {@code :app}, the module where both providers are on one classpath.
+ */
+class FeatureManagerProviderResolutionTest {
+
+ private static final String BUTTERFLY_PROVIDER = "net.onelitefeather.butterfly.minestom.feature.SingletonFeatureManagerProvider";
+
+ @BeforeEach
+ void clearAmbientManager() {
+ FeatureContext.clearCache();
+ }
+
+ @Test
+ @DisplayName("the ambient feature manager enumerates Titan's features, not Butterfly's")
+ void ambientManagerEnumeratesTitanFeatures() {
+ FeatureManager manager = FeatureContext.getFeatureManager();
+
+ Set features = manager.getFeatures();
+ assertEquals(Set.of(TitanFeatures.values()), features, "The ambient Togglz manager enumerates " + features + " instead of Titan's features. A rival FeatureManagerProvider won the " + "ServiceLoader lookup - check SingletonFeatureManagerProvider.PRIORITY against the " + "providers listed by providerPriorities().");
+ }
+
+ @Test
+ @DisplayName("Butterfly's rival provider is on the classpath, so the test above is not vacuous")
+ void butterflyProviderIsPresent() {
+ List names = providers().stream().map(provider -> provider.getClass().getName()).toList();
+
+ assertTrue(names.contains(BUTTERFLY_PROVIDER), "Butterfly's provider is no longer on the :app classpath (" + names + "). The tie this " + "test guards is gone - either Butterfly stopped shipping one, or the dependency was " + "dropped. Re-check before deleting this test.");
+ }
+
+ @Test
+ @DisplayName("Titan's provider outranks every other provider on the classpath")
+ void titanProviderHasTheLowestPriority() {
+ List providers = providers();
+ FeatureManagerProvider titan = providers.stream().filter(SingletonFeatureManagerProvider.class::isInstance).findFirst().orElse(null);
+ assertNotNull(titan, "Titan's provider is not registered in META-INF/services at all.");
+
+ assertFalse(providers.stream().filter(provider -> provider != titan).anyMatch(provider -> provider.priority() <= titan.priority()), () -> "Titan's provider must win by priority, never by service-file order. Titan declares " + titan.priority() + ", the others declare " + providers.stream().filter(provider -> provider != titan).map(provider -> provider.getClass().getName() + '=' + provider.priority()).toList());
+ }
+
+ private static List providers() {
+ List providers = new ArrayList<>();
+ ServiceLoader.load(FeatureManagerProvider.class, FeatureManagerProviderResolutionTest.class.getClassLoader()).forEach(providers::add);
+ return providers;
+ }
+}
diff --git a/app/src/test/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudienceTest.java b/app/src/test/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudienceTest.java
new file mode 100644
index 00000000..26f19329
--- /dev/null
+++ b/app/src/test/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudienceTest.java
@@ -0,0 +1,179 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.feature;
+
+import net.kyori.adventure.permission.PermissionChecker;
+import net.kyori.adventure.util.TriState;
+import net.luckperms.api.LuckPerms;
+import net.luckperms.api.cacheddata.CachedDataManager;
+import net.luckperms.api.cacheddata.CachedPermissionData;
+import net.luckperms.api.context.ContextManager;
+import net.luckperms.api.model.group.Group;
+import net.luckperms.api.model.user.User;
+import net.luckperms.api.model.user.UserManager;
+import net.luckperms.api.query.QueryOptions;
+import net.luckperms.api.util.Tristate;
+import net.onelitefeather.titan.common.feature.FeatureAudience;
+import net.onelitefeather.titan.common.feature.ReleaseStage;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.function.Supplier;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * Pins the one thing the gate and the {@code /season} command must agree on: a permission is
+ * answered from the player's current context, not from the holder's stored query options.
+ */
+class LuckPermsFeatureAudienceTest {
+
+ private static final UUID PLAYER = UUID.fromString("00000000-0000-0000-0000-0000000000a1");
+
+ /** The options LuckPerms resolves for an online player - a server-scoped context. */
+ private static final QueryOptions CONTEXTUAL = mock(QueryOptions.class);
+
+ /** The holder's stored options, which carry no server, world or dimension context. */
+ private static final QueryOptions STORED = mock(QueryOptions.class);
+
+ @Test
+ @DisplayName("a permission is answered by the online player's own permission checker")
+ void permissionComesFromTheOnlinePlayersChecker() {
+ // The command condition consults exactly this checker through PermissionChecker.POINTER.
+ // If the audience reached past it into LuckPerms' stored data, the supplier below would
+ // be used and the answer would flip.
+ FeatureAudience audience = LuckPermsFeatureAudience.of(failingLuckPerms(), checker(ReleaseStage.INTERNAL_PERMISSION));
+
+ assertTrue(audience.hasPermission(PLAYER, ReleaseStage.INTERNAL_PERMISSION));
+ assertFalse(audience.hasPermission(PLAYER, "titan.feature.something.else"));
+ }
+
+ @Test
+ @DisplayName("an offline player is read with contextual options, never with the stored ones")
+ void offlinePermissionUsesResolvedQueryOptions() {
+ LuckPerms luckPerms = luckPerms(user -> {
+ CachedPermissionData contextual = mock(CachedPermissionData.class);
+ when(contextual.checkPermission(ReleaseStage.INTERNAL_PERMISSION)).thenReturn(Tristate.TRUE);
+ CachedPermissionData stored = mock(CachedPermissionData.class);
+ when(stored.checkPermission(ReleaseStage.INTERNAL_PERMISSION)).thenReturn(Tristate.UNDEFINED);
+ CachedDataManager data = mock(CachedDataManager.class);
+ when(data.getPermissionData(CONTEXTUAL)).thenReturn(contextual);
+ when(data.getPermissionData(STORED)).thenReturn(stored);
+ when(user.getCachedData()).thenReturn(data);
+ });
+ FeatureAudience audience = LuckPermsFeatureAudience.of(() -> luckPerms, nobodyOnline());
+
+ assertTrue(audience.hasPermission(PLAYER, ReleaseStage.INTERNAL_PERMISSION), "The permission was read from the holder's stored query options. Those carry no server " + "context, so a grant scoped to server=titan-lobby-1 disappears and the gate denies a " + "team member the command lets through.");
+ }
+
+ @Test
+ @DisplayName("group membership is resolved with contextual options, never with the stored ones")
+ void groupMembershipUsesResolvedQueryOptions() {
+ Group lite = mock(Group.class);
+ when(lite.getName()).thenReturn(ReleaseStage.LITE_GROUP);
+ LuckPerms luckPerms = luckPerms(user -> {
+ when(user.getInheritedGroups(CONTEXTUAL)).thenReturn(List.of(lite));
+ when(user.getInheritedGroups(STORED)).thenReturn(List.of());
+ });
+ FeatureAudience audience = LuckPermsFeatureAudience.of(() -> luckPerms, nobodyOnline());
+
+ assertTrue(audience.inGroup(PLAYER, ReleaseStage.LITE_GROUP), "Group membership was resolved against the holder's stored query options instead of " + "the context LuckPerms resolves for the player.");
+ assertFalse(audience.inGroup(PLAYER, "some-other-group"));
+ }
+
+ @Test
+ @DisplayName("an unknown player holds nothing")
+ void unknownPlayerHoldsNothing() {
+ LuckPerms luckPerms = luckPerms(null);
+ FeatureAudience audience = LuckPermsFeatureAudience.of(() -> luckPerms, nobodyOnline());
+
+ assertFalse(audience.hasPermission(PLAYER, ReleaseStage.INTERNAL_PERMISSION));
+ assertFalse(audience.inGroup(PLAYER, ReleaseStage.LITE_GROUP));
+ }
+
+ @Test
+ @DisplayName("an unavailable permission backend denies instead of throwing")
+ void unavailableBackendFailsClosed() {
+ // LuckPermsProvider.get() throws NotLoadedException (an IllegalStateException) while
+ // LuckPerms is still loading or after it failed to load. The gate is asked on every
+ // navigator open, so this must not escape into a listener - and it must deny, not admit.
+ FeatureAudience audience = LuckPermsFeatureAudience.of(failingLuckPerms(), nobodyOnline());
+
+ assertFalse(audience.hasPermission(PLAYER, ReleaseStage.INTERNAL_PERMISSION));
+ assertFalse(audience.inGroup(PLAYER, ReleaseStage.LITE_GROUP));
+ assertFalse(ReleaseStage.INTERNAL.admits(PLAYER, audience));
+ assertFalse(ReleaseStage.LITE.admits(PLAYER, audience));
+ // Fail-closed hides work in progress, never the lobby: ga never asks the audience.
+ assertTrue(ReleaseStage.GA.admits(PLAYER, audience));
+ }
+
+ @Test
+ @DisplayName("a checker that throws is treated as an outage, not as a grant")
+ void throwingCheckerFailsClosed() {
+ FeatureAudience audience = LuckPermsFeatureAudience.of(failingLuckPerms(), playerId -> permission -> {
+ throw new IllegalStateException("LuckPerms is not loaded");
+ });
+
+ assertFalse(audience.hasPermission(PLAYER, ReleaseStage.INTERNAL_PERMISSION));
+ }
+
+ /**
+ * Builds a LuckPerms whose context manager resolves {@link #CONTEXTUAL} for the player and
+ * whose static options are {@link #STORED}, so a caller that skips the context resolution ends
+ * up on the stored options and the assertion above catches it.
+ */
+ private static LuckPerms luckPerms(java.util.function.Consumer stubUser) {
+ LuckPerms luckPerms = mock(LuckPerms.class);
+ UserManager users = mock(UserManager.class);
+ ContextManager contexts = mock(ContextManager.class);
+ when(luckPerms.getUserManager()).thenReturn(users);
+ when(luckPerms.getContextManager()).thenReturn(contexts);
+ when(contexts.getStaticQueryOptions()).thenReturn(STORED);
+ if (stubUser == null) {
+ when(users.getUser(PLAYER)).thenReturn(null);
+ return luckPerms;
+ }
+ User user = mock(User.class);
+ when(user.getQueryOptions()).thenReturn(STORED);
+ when(users.getUser(PLAYER)).thenReturn(user);
+ when(contexts.getQueryOptions(user)).thenReturn(Optional.of(CONTEXTUAL));
+ stubUser.accept(user);
+ return luckPerms;
+ }
+
+ private static Supplier failingLuckPerms() {
+ return () -> {
+ throw new IllegalStateException("LuckPerms is not loaded");
+ };
+ }
+
+ private static Function nobodyOnline() {
+ return playerId -> null;
+ }
+
+ private static Function checker(String granted) {
+ PermissionChecker checker = permission -> granted.equals(permission) ? TriState.TRUE : TriState.FALSE;
+ return playerId -> PLAYER.equals(playerId) ? checker : null;
+ }
+}
diff --git a/app/src/test/java/net/onelitefeather/titan/app/helper/NavigationHelperTest.java b/app/src/test/java/net/onelitefeather/titan/app/helper/NavigationHelperTest.java
index e579ba42..be8c0153 100644
--- a/app/src/test/java/net/onelitefeather/titan/app/helper/NavigationHelperTest.java
+++ b/app/src/test/java/net/onelitefeather/titan/app/helper/NavigationHelperTest.java
@@ -20,24 +20,53 @@
import net.minestom.server.entity.Player;
import net.minestom.server.instance.Instance;
import net.minestom.server.inventory.PlayerInventory;
+import net.minestom.server.item.Material;
import net.minestom.testing.Env;
import net.minestom.testing.extension.MicrotusExtension;
import net.onelitefeather.titan.app.testutils.DummyDeliver;
+import net.onelitefeather.titan.app.testutils.TestFeatureGate;
+import net.onelitefeather.titan.common.feature.ReleaseStage;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
import net.onelitefeather.titan.common.utils.Items;
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 static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.*;
@ExtendWith(MicrotusExtension.class)
class NavigationHelperTest {
+ private static final int SLOT_ELYTRA = 0;
+ private static final int SLOT_SURVIVAL = 4;
+ private static final int SLOT_SLENDER = 5;
+ private static final int SLOT_CREATIVE = 8;
+
+ /** A fixture in which every navigator destination is generally released. */
+ private static TestFeatureGate allReleased() {
+ return TestFeatureGate.create().release(TitanFeatures.NAVIGATOR_ELYTRA, ReleaseStage.GA).release(TitanFeatures.NAVIGATOR_SURVIVAL, ReleaseStage.GA).release(TitanFeatures.NAVIGATOR_SLENDER, ReleaseStage.GA).release(TitanFeatures.NAVIGATOR_CREATIVE, ReleaseStage.GA);
+ }
+
+ /**
+ * Opens the navigator and reports the material a slot ended up showing.
+ *
+ * Aves applies the data layout on the next tick ({@code InventoryBuilder.retrieveDataLayout}
+ * schedules it), so the inventory is still empty right after {@code open()} - the tick is what
+ * makes this assert against what a player actually sees.
+ */
+ private static Material openedSlot(Env env, NavigationHelper helper, Player player, int slot) {
+ helper.openNavigator(player);
+ Assertions.assertNotNull(player.getOpenInventory(), "the navigator should be open");
+ env.tick();
+ return player.getOpenInventory().getItemStack(slot).material();
+ }
+
@DisplayName("Test if the NavigationHelper is set with the correct items")
@Test
void testNavigationHelperIsItemsSet(Env env) {
- NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance());
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), allReleased().gate());
Instance flatInstance = env.createFlatInstance();
Player realPlayer = env.createPlayer(flatInstance);
@@ -55,7 +84,7 @@ void testNavigationHelperIsItemsSet(Env env) {
@DisplayName("Test if the NavigationHelper open the navigation gui")
@Test
void testNavigationHelperOpenNavigationGui(Env env) {
- NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance());
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), allReleased().gate());
Instance flatInstance = env.createFlatInstance();
Player realPlayer = env.createPlayer(flatInstance);
@@ -65,54 +94,80 @@ void testNavigationHelperOpenNavigationGui(Env env) {
Assertions.assertNotNull(realPlayer.getOpenInventory());
}
- // @Disabled
- // @DisplayName("Test if clicked on the teleporter item the navigation gui is
- // opened")
- // @Test
- // void testNavigationHelperOpenNavigationGuiByClick(Env env) {
- // Deliver deliver = spy(DummyDeliver.instance());
- // NavigationHelper helper = NavigationHelper.instance(deliver);
- //
- // Instance flatInstance = env.createFlatInstance();
- // Player realPlayer = env.createPlayer(flatInstance);
- //
- // helper.setItems(realPlayer);
- // helper.openNavigator(realPlayer);
- // System.out.println(realPlayer.getOpenInventory().getWindowId());
- //
- // leftClickOpenInventory(realPlayer, 0, Items.NAVIGATOR_ELYTRA_ITEM_STACK);
- // verify(deliver, atLeastOnce()).sendPlayer(any(), any());
- // leftClickOpenInventory(realPlayer, 3, Items.NAVIGATOR_SLENDER_ITEM_STACK);
- // leftClickOpenInventory(realPlayer, 4, Items.NAVIGATOR_SURVIVAL_ITEM_STACK);
- // leftClickOpenInventory(realPlayer, 5, Items.NAVIGATOR_SLENDER_ITEM_STACK);
- // leftClickOpenInventory(realPlayer, 8, Items.NAVIGATOR_CREATIVE_ITEM_STACK);
- // env.tick();
- //
- //
- // }
- //
- // private void leftClickOpenInventory(Player player, int slot, ItemStack
- // clickedItem) {
- // _leftClick(player.getOpenInventory(), true, player, slot, clickedItem);
- // }
- // private void _leftClick(AbstractInventory openInventory, boolean
- // clickOpenInventory, Player player, int slot, ItemStack clickedItem) {
- // final byte windowId = openInventory != null ? openInventory.getWindowId() :
- // 0;
- // if (clickOpenInventory) {
- // assert openInventory != null;
- // // Do not touch slot
- // } else {
- // int offset = openInventory != null ? openInventory.getInnerSize() : 0;
- // slot = PlayerInventoryUtils.convertMinestomSlotToPlayerInventorySlot(slot);
- // if (openInventory != null) {
- // slot = slot - 9 + offset;
- // }
- // }
- // player.addPacketToQueue(new ClientClickWindowPacket(windowId, 0, (short)
- // slot, (byte) 0,
- // ClientClickWindowPacket.ClickType.PICKUP, Map.of(), clickedItem));
- // player.interpretPacketQueue();
- // }
+ @DisplayName("A generally released destination is shown to an ordinary player")
+ @Test
+ void generallyReleasedEntriesAreShown(Env env) {
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), allReleased().gate());
+ Player player = env.createPlayer(env.createFlatInstance());
+
+ assertEquals(Material.ELYTRA, openedSlot(env, helper, player, SLOT_ELYTRA));
+ assertEquals(Material.GRASS_BLOCK, openedSlot(env, helper, player, SLOT_SURVIVAL));
+ assertEquals(Material.ENDERMAN_SPAWN_EGG, openedSlot(env, helper, player, SLOT_SLENDER));
+ assertEquals(Material.WOODEN_AXE, openedSlot(env, helper, player, SLOT_CREATIVE));
+ }
+ @DisplayName("The kill switch removes the destination from the navigator, not just from /season status")
+ @Test
+ void killSwitchHidesTheEntry(Env env) {
+ // The scenario the gate exists for: an operator writes NAVIGATOR_ELYTRA = false and
+ // expects players to stop seeing the item, not merely a status line that says so.
+ TestFeatureGate features = allReleased().killSwitch(TitanFeatures.NAVIGATOR_ELYTRA);
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), features.gate());
+ Player player = env.createPlayer(env.createFlatInstance());
+
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, openedSlot(env, helper, player, SLOT_ELYTRA), "the elytra slot must fall back to the filler pane");
+ assertEquals(Material.GRASS_BLOCK, openedSlot(env, helper, player, SLOT_SURVIVAL), "the other destinations keep their slots");
+ assertEquals(Material.ENDERMAN_SPAWN_EGG, openedSlot(env, helper, player, SLOT_SLENDER));
+ assertEquals(Material.WOODEN_AXE, openedSlot(env, helper, player, SLOT_CREATIVE));
+ }
+
+ @DisplayName("An internal destination is hidden from a player without the permission")
+ @Test
+ void internalStageHidesTheEntryFromOrdinaryPlayers(Env env) {
+ TestFeatureGate features = allReleased().release(TitanFeatures.NAVIGATOR_SURVIVAL, ReleaseStage.INTERNAL);
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), features.gate());
+ Player player = env.createPlayer(env.createFlatInstance());
+
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, openedSlot(env, helper, player, SLOT_SURVIVAL));
+ }
+
+ @DisplayName("An internal destination is shown to a team member")
+ @Test
+ void internalStageShowsTheEntryToTheTeam(Env env) {
+ TestFeatureGate features = allReleased().release(TitanFeatures.NAVIGATOR_SURVIVAL, ReleaseStage.INTERNAL);
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), features.gate());
+ Player player = env.createPlayer(env.createFlatInstance());
+ features.grant(player.getUuid(), ReleaseStage.INTERNAL_PERMISSION);
+
+ assertEquals(Material.GRASS_BLOCK, openedSlot(env, helper, player, SLOT_SURVIVAL));
+ }
+
+ @DisplayName("A lite destination is shown to the lite group and hidden from everyone else")
+ @Test
+ void liteStageFollowsTheGroup(Env env) {
+ TestFeatureGate features = allReleased().release(TitanFeatures.NAVIGATOR_SLENDER, ReleaseStage.LITE);
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), features.gate());
+ Instance instance = env.createFlatInstance();
+ Player ordinary = env.createPlayer(instance);
+ Player lite = env.createPlayer(instance);
+ features.join(lite.getUuid(), ReleaseStage.LITE_GROUP);
+
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, openedSlot(env, helper, ordinary, SLOT_SLENDER));
+ assertEquals(Material.ENDERMAN_SPAWN_EGG, openedSlot(env, helper, lite, SLOT_SLENDER));
+ }
+
+ @DisplayName("A flag flipped between two opens takes effect on the second open")
+ @Test
+ void reopeningPicksUpAFlagChange(Env env) {
+ TestFeatureGate features = allReleased();
+ NavigationHelper helper = NavigationHelper.instance(DummyDeliver.instance(), features.gate());
+ Player player = env.createPlayer(env.createFlatInstance());
+
+ assertEquals(Material.ELYTRA, openedSlot(env, helper, player, SLOT_ELYTRA));
+
+ // The per-player inventory builder is cached; the layout must not be.
+ features.killSwitch(TitanFeatures.NAVIGATOR_ELYTRA);
+
+ assertEquals(Material.GRAY_STAINED_GLASS_PANE, openedSlot(env, helper, player, SLOT_ELYTRA));
+ }
}
diff --git a/app/src/test/java/net/onelitefeather/titan/app/listener/NavigationListenerTest.java b/app/src/test/java/net/onelitefeather/titan/app/listener/NavigationListenerTest.java
index 7d09e244..bf87ab25 100644
--- a/app/src/test/java/net/onelitefeather/titan/app/listener/NavigationListenerTest.java
+++ b/app/src/test/java/net/onelitefeather/titan/app/listener/NavigationListenerTest.java
@@ -25,6 +25,7 @@
import net.minestom.testing.extension.MicrotusExtension;
import net.onelitefeather.titan.app.helper.NavigationHelper;
import net.onelitefeather.titan.app.testutils.DummyDeliver;
+import net.onelitefeather.titan.app.testutils.TestFeatureGate;
import net.onelitefeather.titan.common.utils.Items;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -39,7 +40,7 @@ class NavigationListenerTest {
@Test
@DisplayName("Test has the navigator is opened when the player uses the player teleporter")
void testNavigationListenerForClicked(Env env) {
- NavigationHelper navigationHelper = spy(NavigationHelper.instance(DummyDeliver.instance()));
+ NavigationHelper navigationHelper = spy(NavigationHelper.instance(DummyDeliver.instance(), TestFeatureGate.create().gate()));
Instance flatInstance = env.createFlatInstance();
Player player = env.createPlayer(flatInstance);
MinecraftServer.getGlobalEventHandler().addListener(PlayerUseItemEvent.class, new NavigationListener(navigationHelper));
diff --git a/app/src/test/java/net/onelitefeather/titan/app/testutils/TestFeatureGate.java b/app/src/test/java/net/onelitefeather/titan/app/testutils/TestFeatureGate.java
new file mode 100644
index 00000000..a3d2a14e
--- /dev/null
+++ b/app/src/test/java/net/onelitefeather/titan/app/testutils/TestFeatureGate.java
@@ -0,0 +1,137 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.app.testutils;
+
+import net.onelitefeather.titan.common.feature.FeatureAudience;
+import net.onelitefeather.titan.common.feature.FeatureGate;
+import net.onelitefeather.titan.common.feature.ReleaseStage;
+import net.onelitefeather.titan.common.feature.TitanFeatures;
+import org.togglz.core.activation.DefaultActivationStrategyProvider;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.manager.FeatureManagerBuilder;
+import org.togglz.core.repository.FeatureState;
+import org.togglz.core.repository.mem.InMemoryStateRepository;
+import org.togglz.core.user.NoOpUserProvider;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * A real {@link FeatureGate} over an in-memory Togglz repository, with the flags and the
+ * permission answers writable from a test. Nothing here is a stub of the gate itself: tests using
+ * it exercise the same evaluation the lobby runs.
+ */
+public final class TestFeatureGate {
+
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+
+ private final InMemoryStateRepository repository = new InMemoryStateRepository();
+ private final Set permissions = new HashSet<>();
+ private final Set groups = new HashSet<>();
+ private final FeatureGate gate;
+
+ private TestFeatureGate() {
+ FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(this.repository).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+ this.gate = FeatureGate.with(featureManager, new MutableAudience(), Clock.fixed(Instant.parse("2026-10-15T12:00:00Z"), ZoneOffset.UTC), BERLIN);
+ }
+
+ /**
+ * Creates a fixture in which no feature is configured at all - every feature is therefore
+ * invisible until it is released.
+ *
+ * @return a new fixture
+ */
+ public static TestFeatureGate create() {
+ return new TestFeatureGate();
+ }
+
+ /**
+ * Releases a feature to the given audience, with no time window.
+ *
+ * @param feature the feature to release
+ * @param stage the stage to put it on
+ * @return this fixture
+ */
+ public TestFeatureGate release(TitanFeatures feature, ReleaseStage stage) {
+ this.repository.setFeatureState(new FeatureState(feature, true).setParameter(FeatureGate.STAGE_PARAMETER, stage.id()));
+ return this;
+ }
+
+ /**
+ * Engages the kill switch of a feature, leaving its stage untouched.
+ *
+ * @param feature the feature to switch off
+ * @return this fixture
+ */
+ public TestFeatureGate killSwitch(TitanFeatures feature) {
+ this.repository.setFeatureState(new FeatureState(feature, false).setParameter(FeatureGate.STAGE_PARAMETER, ReleaseStage.GA.id()));
+ return this;
+ }
+
+ /**
+ * Grants a permission to a player.
+ *
+ * @param playerId the player
+ * @param permission the permission node
+ * @return this fixture
+ */
+ public TestFeatureGate grant(UUID playerId, String permission) {
+ this.permissions.add(playerId + "/" + permission);
+ return this;
+ }
+
+ /**
+ * Adds a player to a group.
+ *
+ * @param playerId the player
+ * @param group the group name
+ * @return this fixture
+ */
+ public TestFeatureGate join(UUID playerId, String group) {
+ this.groups.add(playerId + "/" + group.toLowerCase(Locale.ROOT));
+ return this;
+ }
+
+ /**
+ * Returns the gate under test.
+ *
+ * @return the gate
+ */
+ public FeatureGate gate() {
+ return this.gate;
+ }
+
+ /** Reads the sets live, so a test may grant a permission after the gate was built. */
+ private final class MutableAudience implements FeatureAudience {
+
+ @Override
+ public boolean hasPermission(UUID playerId, String permission) {
+ return TestFeatureGate.this.permissions.contains(playerId + "/" + permission);
+ }
+
+ @Override
+ public boolean inGroup(UUID playerId, String group) {
+ return TestFeatureGate.this.groups.contains(playerId + "/" + group.toLowerCase(Locale.ROOT));
+ }
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureAudience.java b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureAudience.java
new file mode 100644
index 00000000..50562644
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureAudience.java
@@ -0,0 +1,76 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import java.util.UUID;
+
+/**
+ * Answers the two questions a release stage needs about a player: does the player hold a
+ * permission, and is the player a member of a group.
+ *
+ * This is deliberately not a second permission system. It is the seam that keeps
+ * {@code :common} free of LuckPerms types: the production implementation lives in the
+ * application module and delegates every answer to LuckPerms, while tests supply a fixture. Only
+ * JDK types cross this interface, which is the same rule
+ * {@code net.onelitefeather.titan.common.permission.TitanPermissionBridge} follows for the
+ * CloudNet bridge.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public interface FeatureAudience {
+
+ /**
+ * Returns an audience that answers {@code false} to everything. Used as the safe default
+ * before a real permission backend is available.
+ *
+ * @return an audience that grants nothing
+ */
+ static FeatureAudience denyAll() {
+ return new FeatureAudience() {
+
+ @Override
+ public boolean hasPermission(UUID playerId, String permission) {
+ return false;
+ }
+
+ @Override
+ public boolean inGroup(UUID playerId, String group) {
+ return false;
+ }
+ };
+ }
+
+ /**
+ * Checks whether the player holds the given permission node.
+ *
+ * @param playerId the player's unique id
+ * @param permission the permission node, for example {@code titan.feature.internal}
+ * @return whether the player holds the permission
+ */
+ boolean hasPermission(UUID playerId, String permission);
+
+ /**
+ * Checks whether the player is a member of the given group, inherited groups included.
+ *
+ * @param playerId the player's unique id
+ * @param group the group name, for example {@code lite}
+ * @return whether the player belongs to the group
+ */
+ boolean inGroup(UUID playerId, String group);
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureDecision.java b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureDecision.java
new file mode 100644
index 00000000..17e55d1a
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureDecision.java
@@ -0,0 +1,55 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.jetbrains.annotations.Contract;
+
+/**
+ * Outcome of a {@link FeatureGate} evaluation. The three denials name the step that stopped the
+ * evaluation, in the fixed order the gate walks them: kill switch, release stage, time window.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public enum FeatureDecision {
+
+ /** The player sees the feature. */
+ ALLOWED,
+
+ /**
+ * The feature is switched off. A feature that has never been enabled counts as switched off,
+ * which is what makes an unknown or unconfigured feature invisible rather than public.
+ */
+ DENIED_KILL_SWITCH,
+
+ /** The feature is on, but the player is not part of the audience of its release stage. */
+ DENIED_STAGE,
+
+ /** The player is in the audience, but the current time is outside the configured window. */
+ DENIED_WINDOW;
+
+ /**
+ * Returns whether this decision lets the player see the feature.
+ *
+ * @return {@code true} for {@link #ALLOWED}
+ */
+ @Contract(pure = true)
+ public boolean isAllowed() {
+ return this == ALLOWED;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java
new file mode 100644
index 00000000..87ab2a03
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java
@@ -0,0 +1,252 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import net.onelitefeather.titan.common.utils.ThreadHelper;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.togglz.core.Feature;
+import org.togglz.core.context.FeatureContext;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.repository.FeatureState;
+
+import java.time.Clock;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+import java.util.UUID;
+import java.util.function.Supplier;
+
+/**
+ * Decides whether a player sees a feature. This is the only type in Titan that talks to Togglz;
+ * navigator entries, seasonal content and portals ask the gate instead of a
+ * {@link FeatureManager}.
+ *
+ *
The evaluation order is fixed by US-3.07 and is walked in exactly this order:
+ *
+ *
+ * - kill switch — a disabled feature is invisible to everyone, whatever its stage and
+ * window say. A feature that was never enabled is disabled, so an unconfigured feature stays
+ * dark rather than going public.
+ * - release stage — {@code internal} needs {@value ReleaseStage#INTERNAL_PERMISSION},
+ * {@code lite} additionally admits the {@value ReleaseStage#LITE_GROUP} group, {@code ga}
+ * admits everyone. The stage is the feature-state parameter {@value #STAGE_PARAMETER}; a
+ * feature without it is treated as {@link ReleaseStage#DEFAULT}.
+ * - time window — evaluated by {@link SeasonWindowActivationStrategy}. A feature with
+ * no window is always within it.
+ *
+ *
+ * The three steps form a conjunction, so the order does not change the answer — it decides
+ * which step is reported as the reason, and it is what {@code /season status} and the tests rely
+ * on.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class FeatureGate {
+
+ /** Feature-state parameter holding the release stage of a feature. */
+ public static final String STAGE_PARAMETER = "stage";
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(FeatureGate.class);
+
+ private final Supplier featureManager;
+ private final FeatureAudience audience;
+ private final SeasonWindowActivationStrategy window;
+ private final StageTransitionLogger transitions;
+
+ private FeatureGate(Supplier featureManager, FeatureAudience audience, Clock clock, ZoneId zone) {
+ this.featureManager = featureManager;
+ this.audience = audience;
+ this.window = new SeasonWindowActivationStrategy(clock, zone);
+ this.transitions = new StageTransitionLogger(clock, zone);
+ }
+
+ /**
+ * Creates a gate backed by the ambient Togglz feature manager. The manager is resolved lazily
+ * and on a thread whose context classloader can see Titan's service files, because
+ * {@link FeatureContext} goes through the {@link java.util.ServiceLoader}.
+ *
+ * @param audience the source of permission and group answers
+ * @param clock the time source used for windows and transition timestamps
+ * @param zone the zone seasons are planned in when a feature names none
+ * @return a gate on the application's feature manager
+ */
+ public static FeatureGate create(FeatureAudience audience, Clock clock, ZoneId zone) {
+ return new FeatureGate(new LazyFeatureManager(), audience, clock, zone);
+ }
+
+ /**
+ * Creates a gate on an explicitly supplied feature manager. Used where the manager is already
+ * at hand — tests above all.
+ *
+ * @param featureManager the manager to read feature states from
+ * @param audience the source of permission and group answers
+ * @param clock the time source used for windows and transition timestamps
+ * @param zone the zone seasons are planned in when a feature names none
+ * @return a gate on the given feature manager
+ */
+ public static FeatureGate with(FeatureManager featureManager, FeatureAudience audience, Clock clock, ZoneId zone) {
+ return new FeatureGate(() -> featureManager, audience, clock, zone);
+ }
+
+ /**
+ * Checks whether the given player currently sees the feature.
+ *
+ * @param feature the feature to check
+ * @param playerId the player's unique id
+ * @return whether the feature is visible to that player
+ */
+ public boolean isVisibleTo(Feature feature, UUID playerId) {
+ return decide(feature, playerId).isAllowed();
+ }
+
+ /**
+ * Evaluates the feature for a player and reports which step decided the outcome.
+ *
+ * @param feature the feature to check
+ * @param playerId the player's unique id
+ * @return the decision, naming the step that denied the feature when it is not allowed
+ */
+ public FeatureDecision decide(Feature feature, UUID playerId) {
+ FeatureState state = state(feature);
+ if (state == null || !state.isEnabled()) {
+ return FeatureDecision.DENIED_KILL_SWITCH;
+ }
+ if (!stageOf(state).admits(playerId, this.audience)) {
+ return FeatureDecision.DENIED_STAGE;
+ }
+ if (!this.window.isWithinWindow(state)) {
+ return FeatureDecision.DENIED_WINDOW;
+ }
+ return FeatureDecision.ALLOWED;
+ }
+
+ /**
+ * Reads the operator-facing status of one feature and records a stage transition when the
+ * stage has moved since the last look.
+ *
+ * @param feature the feature to describe
+ * @return kill switch, stage and window of the feature
+ */
+ public FeatureStatus status(Feature feature) {
+ FeatureState state = state(feature);
+ if (state == null) {
+ return new FeatureStatus(feature.name(), true, ReleaseStage.DEFAULT, null, null, null, SeasonWindowActivationStrategy.DEFAULT_ZONE, false, null);
+ }
+ ReleaseStage stage = stageOf(state);
+ this.transitions.observe(feature.name(), stage);
+ String windowProblem = this.window.windowProblem(state);
+ LocalDateTime from = this.window.from(state).orElse(null);
+ LocalDateTime to = this.window.to(state).orElse(null);
+ // An unreadable window is never an open one - keep the two answers from contradicting
+ // each other rather than relying on isWithinWindow to fail closed on its own.
+ boolean open = windowProblem == null && this.window.isWithinWindow(state);
+ return new FeatureStatus(feature.name(), !state.isEnabled(), stage, unknownStageOf(state), from, to, zoneOf(state), open, windowProblem);
+ }
+
+ /**
+ * Reads the status of every known feature, ordered by name so the command output is stable.
+ *
+ * @return one status per feature the feature manager knows
+ */
+ public List statuses() {
+ List statuses = new ArrayList<>();
+ for (Feature feature : this.featureManager.get().getFeatures()) {
+ statuses.add(status(feature));
+ }
+ statuses.sort(Comparator.comparing(FeatureStatus::feature));
+ return List.copyOf(statuses);
+ }
+
+ /**
+ * Walks every feature once and logs the stage transitions that happened since the previous
+ * walk. Meant to be scheduled, so a stage change is recorded even while nobody is online to
+ * trigger an evaluation.
+ *
+ * @return the transitions observed in this walk
+ */
+ public List pollStageTransitions() {
+ List observed = new ArrayList<>();
+ for (Feature feature : this.featureManager.get().getFeatures()) {
+ FeatureState state = state(feature);
+ if (state == null) {
+ continue;
+ }
+ this.transitions.observe(feature.name(), stageOf(state)).ifPresent(observed::add);
+ }
+ return List.copyOf(observed);
+ }
+
+ private ReleaseStage stageOf(FeatureState state) {
+ String configured = state.getParameter(STAGE_PARAMETER);
+ Optional stage = ReleaseStage.fromId(configured);
+ if (stage.isEmpty() && configured != null && !configured.isBlank()) {
+ LOGGER.warn("Feature {} is configured with the unknown release stage '{}'; falling back to {}", state.getFeature().name(), configured, ReleaseStage.DEFAULT.id());
+ }
+ return stage.orElse(ReleaseStage.DEFAULT);
+ }
+
+ /**
+ * Returns the configured stage id when it is not one of the three known ones. The gate itself
+ * falls back to {@link ReleaseStage#DEFAULT}, but an operator who wrote {@code intern} instead
+ * of {@code internal} needs to see the typo rather than a stage they did not configure.
+ */
+ private static @Nullable String unknownStageOf(FeatureState state) {
+ String configured = state.getParameter(STAGE_PARAMETER);
+ if (configured == null || configured.isBlank()) {
+ return null;
+ }
+ return ReleaseStage.fromId(configured).isPresent() ? null : configured.trim();
+ }
+
+ private ZoneId zoneOf(FeatureState state) {
+ try {
+ return this.window.zoneOf(state);
+ } catch (RuntimeException exception) {
+ return SeasonWindowActivationStrategy.DEFAULT_ZONE;
+ }
+ }
+
+ private @Nullable FeatureState state(Feature feature) {
+ return this.featureManager.get().getFeatureState(feature);
+ }
+
+ /**
+ * Resolves and caches the ambient feature manager on a thread whose context classloader can
+ * see Titan's {@code META-INF/services} entries.
+ */
+ private static final class LazyFeatureManager implements Supplier, ThreadHelper {
+
+ private volatile @Nullable FeatureManager delegate;
+
+ @Override
+ public FeatureManager get() {
+ FeatureManager current = this.delegate;
+ if (current == null) {
+ current = syncThreadForServiceLoader(FeatureContext::getFeatureManager);
+ this.delegate = current;
+ }
+ return current;
+ }
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java
new file mode 100644
index 00000000..dce99fb5
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java
@@ -0,0 +1,99 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Nullable;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+
+/**
+ * Snapshot of everything an operator needs to know about one feature: whether the kill switch is
+ * engaged, which audience the feature is released to, which time window it is bound to, and
+ * whether any of that configuration could not be read. Rendered by the {@code /season status}
+ * command.
+ *
+ * The two "unreadable" fields exist because failing closed is safe but silent. A window whose
+ * {@code from} is a typo makes the gate deny everyone, while the bounds themselves come back
+ * empty — so without {@link #windowProblem()} the status would report "no window configured" for
+ * a feature nobody can see. The one command whose purpose is to spare the operator a trip to the
+ * log would then be telling them the opposite of the truth.
+ *
+ * @param feature name of the Togglz feature
+ * @param killSwitchEngaged whether the feature is switched off outright
+ * @param stage the release stage in effect, after the fallback for an unusable id
+ * @param unknownStage the configured stage id when it is not one of the three known ones,
+ * otherwise {@code null}
+ * @param from inclusive start of the window, {@code null} when unset or unreadable
+ * @param to exclusive end of the window, {@code null} when unset or unreadable
+ * @param zone the zone {@code from} and {@code to} are read in
+ * @param withinWindow whether the window is open right now
+ * @param windowProblem description of the window parameter that cannot be read, otherwise
+ * {@code null}
+ * @author TheMeinerLP
+ * @version 1.1.0
+ * @since 1.15.0
+ */
+public record FeatureStatus(String feature, boolean killSwitchEngaged, ReleaseStage stage,
+ @Nullable String unknownStage, @Nullable LocalDateTime from,
+ @Nullable LocalDateTime to, ZoneId zone, boolean withinWindow,
+ @Nullable String windowProblem) {
+
+ /**
+ * Guards the contradiction this record was extended to remove: an unreadable window is never
+ * reported as an open one.
+ */
+ public FeatureStatus {
+ if (windowProblem != null && withinWindow) {
+ throw new IllegalArgumentException("a feature whose window cannot be read is never within it: " + feature);
+ }
+ }
+
+ /**
+ * Returns whether this feature has a readable time window configured.
+ *
+ *
Only ever {@code true} when the configuration parsed: ask {@link #windowReadable()}
+ * before concluding from a {@code false} here that the feature runs unbounded.
+ *
+ * @return whether at least one of the two bounds is set and readable
+ */
+ @Contract(pure = true)
+ public boolean hasWindow() {
+ return this.from != null || this.to != null;
+ }
+
+ /**
+ * Returns whether every configured window parameter could be read.
+ *
+ * @return whether the window configuration is usable
+ */
+ @Contract(pure = true)
+ public boolean windowReadable() {
+ return this.windowProblem == null;
+ }
+
+ /**
+ * Returns whether the configured release stage was one of the three known ids.
+ *
+ * @return whether the stage configuration is usable
+ */
+ @Contract(pure = true)
+ public boolean stageReadable() {
+ return this.unknownStage == null;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/ReleaseStage.java b/common/src/main/java/net/onelitefeather/titan/common/feature/ReleaseStage.java
new file mode 100644
index 00000000..0807faea
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/ReleaseStage.java
@@ -0,0 +1,111 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Nullable;
+
+import java.util.Locale;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * The audience a feature has been released to. The stages widen in one direction only:
+ * {@link #INTERNAL} → {@link #LITE} → {@link #GA}. Every stage includes the audience of the
+ * stages before it, so a team member keeps seeing a feature when it moves on to lite players.
+ *
+ *
The stage of a feature is stored as the Togglz feature-state parameter
+ * {@value FeatureGate#STAGE_PARAMETER}; a feature without that parameter is treated as
+ * {@link #DEFAULT}, which is the narrowest audience rather than the widest.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public enum ReleaseStage {
+
+ /** Only holders of {@value #INTERNAL_PERMISSION} see the feature. */
+ INTERNAL("internal"),
+
+ /** Members of the {@value #LITE_GROUP} group see the feature, and so does the team. */
+ LITE("lite"),
+
+ /** Every player sees the feature. */
+ GA("ga");
+
+ /** Permission that marks a team member allowed to see features under internal test. */
+ public static final String INTERNAL_PERMISSION = "titan.feature.internal";
+
+ /** LuckPerms group whose members get early access at stage {@link #LITE}. */
+ public static final String LITE_GROUP = "lite";
+
+ /** Stage assumed for a feature whose stage parameter is missing or unreadable. */
+ public static final ReleaseStage DEFAULT = INTERNAL;
+
+ private final String id;
+
+ ReleaseStage(String id) {
+ this.id = id;
+ }
+
+ /**
+ * Resolves the stage written in a flag file.
+ *
+ * @param id the configured stage id, may be {@code null} when the parameter is absent
+ * @return the matching stage, or an empty optional when the id is absent or unknown
+ */
+ @Contract(pure = true)
+ public static Optional fromId(@Nullable String id) {
+ if (id == null || id.isBlank()) {
+ return Optional.empty();
+ }
+ String normalized = id.trim().toLowerCase(Locale.ROOT);
+ for (ReleaseStage stage : values()) {
+ if (stage.id.equals(normalized)) {
+ return Optional.of(stage);
+ }
+ }
+ return Optional.empty();
+ }
+
+ /**
+ * Returns the id used in the flag file, for example {@code lite}.
+ *
+ * @return the configured id of this stage
+ */
+ @Contract(pure = true)
+ public String id() {
+ return this.id;
+ }
+
+ /**
+ * Checks whether a player belongs to the audience of this stage.
+ *
+ * @param playerId the player's unique id
+ * @param audience the source of permission and group answers
+ * @return whether the player is part of this stage's audience
+ */
+ @Contract(pure = true)
+ public boolean admits(UUID playerId, FeatureAudience audience) {
+ return switch (this) {
+ case GA -> true;
+ case LITE ->
+ audience.hasPermission(playerId, INTERNAL_PERMISSION) || audience.inGroup(playerId, LITE_GROUP);
+ case INTERNAL -> audience.hasPermission(playerId, INTERNAL_PERMISSION);
+ };
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java
new file mode 100644
index 00000000..76c333f4
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java
@@ -0,0 +1,249 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.jetbrains.annotations.Contract;
+import org.jetbrains.annotations.Nullable;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.togglz.core.activation.Parameter;
+import org.togglz.core.activation.ParameterBuilder;
+import org.togglz.core.repository.FeatureState;
+import org.togglz.core.spi.ActivationStrategy;
+import org.togglz.core.user.FeatureUser;
+
+import java.time.Clock;
+import java.time.DateTimeException;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.time.format.DateTimeParseException;
+import java.util.Optional;
+
+/**
+ * Togglz activation strategy that limits a feature to a time window with a start, an end and a
+ * time zone.
+ *
+ * Togglz ships {@code ReleaseDateActivationStrategy}, but it only knows {@code PARAM_DATE} and
+ * {@code PARAM_TIME} — a point in time after which a feature is on. A season has an end as well,
+ * and it is planned in local Berlin time rather than in whatever zone the JVM happens to run in.
+ * This strategy therefore takes three parameters:
+ *
+ *
+ * - {@value #PARAM_FROM} — inclusive start, {@code 2026-10-01T18:00} or {@code 2026-10-01}
+ * - {@value #PARAM_TO} — exclusive end, same formats
+ * - {@value #PARAM_ZONE} — zone the two local times are read in, for example
+ * {@code Europe/Berlin}; falls back to the zone this strategy was built with
+ *
+ *
+ * Both bounds are optional: a window with only {@value #PARAM_FROM} never closes, one with only
+ * {@value #PARAM_TO} was always open, and a state with neither is always within its window. A
+ * parameter that is present but unreadable makes the feature inactive — a typo in a date must not
+ * widen an audience.
+ *
+ *
Registered through {@code META-INF/services/org.togglz.core.spi.ActivationStrategy}, which
+ * Togglz's {@code DefaultActivationStrategyProvider} reads with a plain
+ * {@link java.util.ServiceLoader#load(Class)}; that call uses the thread context classloader, so
+ * the lookup has to happen on a thread prepared by
+ * {@link net.onelitefeather.titan.common.utils.ThreadHelper}. The public no-argument constructor
+ * exists for that service lookup.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class SeasonWindowActivationStrategy implements ActivationStrategy {
+
+ /** Strategy id as written to {@code .strategy} in the flag file. */
+ public static final String ID = "season-window";
+
+ /** Parameter holding the inclusive start of the window. */
+ public static final String PARAM_FROM = "from";
+
+ /** Parameter holding the exclusive end of the window. */
+ public static final String PARAM_TO = "to";
+
+ /** Parameter holding the zone the two local times are interpreted in. */
+ public static final String PARAM_ZONE = "zone";
+
+ /** Zone seasons are planned in when a feature does not name one. */
+ public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Berlin");
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(SeasonWindowActivationStrategy.class);
+
+ private static final Parameter[] PARAMETERS = {ParameterBuilder.create(PARAM_FROM).label("Window start").description("Inclusive start, ISO-8601 local date or date-time (2026-10-01 or 2026-10-01T18:00).").optional(), ParameterBuilder.create(PARAM_TO).label("Window end").description("Exclusive end, ISO-8601 local date or date-time (2026-11-05 or 2026-11-05T04:00).").optional(), ParameterBuilder.create(PARAM_ZONE).label("Time zone").description("Zone the two local times are read in, for example Europe/Berlin.").optional(),
+ };
+
+ private final Clock clock;
+ private final ZoneId fallbackZone;
+
+ /**
+ * Creates the strategy the way the {@link java.util.ServiceLoader} needs it: on the system
+ * clock, planning seasons in {@link #DEFAULT_ZONE}.
+ */
+ public SeasonWindowActivationStrategy() {
+ this(Clock.systemUTC(), DEFAULT_ZONE);
+ }
+
+ /**
+ * Creates the strategy with an explicit time source.
+ *
+ * @param clock the clock every comparison is made against
+ * @param fallbackZone the zone used for features that do not set {@value #PARAM_ZONE}
+ */
+ public SeasonWindowActivationStrategy(Clock clock, ZoneId fallbackZone) {
+ this.clock = clock;
+ this.fallbackZone = fallbackZone;
+ }
+
+ @Override
+ public String getId() {
+ return ID;
+ }
+
+ @Override
+ public String getName() {
+ return "Season time window";
+ }
+
+ @Override
+ public boolean isActive(FeatureState featureState, @Nullable FeatureUser user) {
+ return isWithinWindow(featureState);
+ }
+
+ @Override
+ public Parameter[] getParameters() {
+ return PARAMETERS.clone();
+ }
+
+ /**
+ * Checks whether the current time falls into the window configured on the feature state. The
+ * user plays no part in this decision, which is why {@link FeatureGate} can call this directly
+ * as the third and last step of its evaluation.
+ *
+ * @param featureState the state carrying the window parameters
+ * @return whether now is inside the window; {@code false} when a parameter cannot be read
+ */
+ public boolean isWithinWindow(FeatureState featureState) {
+ Window window;
+ try {
+ window = readWindow(featureState);
+ } catch (IllegalArgumentException exception) {
+ LOGGER.warn("Feature {} has an unreadable season window ({}); treating it as inactive", featureState.getFeature().name(), exception.getMessage());
+ return false;
+ }
+ ZonedDateTime now = ZonedDateTime.ofInstant(this.clock.instant(), window.zone());
+ if (window.from() != null && now.isBefore(window.from().atZone(window.zone()))) {
+ return false;
+ }
+ return window.to() == null || now.isBefore(window.to().atZone(window.zone()));
+ }
+
+ /**
+ * Describes what makes the window configuration of this feature unreadable.
+ *
+ * {@link #isWithinWindow(FeatureState)} fails closed on an unreadable window, which is the
+ * safe answer but an opaque one: the feature is off and nothing on the feature state says why.
+ * This method names the offending parameter and its value so {@code /season status} can report
+ * broken configuration as broken instead of as "no window configured".
+ *
+ * @param featureState the state to inspect
+ * @return {@code null} when every configured parameter can be read, otherwise a description of
+ * the first parameter that cannot
+ */
+ @Contract(pure = true)
+ public @Nullable String windowProblem(FeatureState featureState) {
+ try {
+ readWindow(featureState);
+ return null;
+ } catch (IllegalArgumentException exception) {
+ return exception.getMessage();
+ }
+ }
+
+ /**
+ * Reads the inclusive start of the window.
+ *
+ * @param featureState the state to read from
+ * @return the start, or an empty optional when unset or unreadable
+ */
+ @Contract(pure = true)
+ public Optional from(FeatureState featureState) {
+ return bound(featureState, PARAM_FROM);
+ }
+
+ /**
+ * Reads the exclusive end of the window.
+ *
+ * @param featureState the state to read from
+ * @return the end, or an empty optional when unset or unreadable
+ */
+ @Contract(pure = true)
+ public Optional to(FeatureState featureState) {
+ return bound(featureState, PARAM_TO);
+ }
+
+ /**
+ * Resolves the zone the window of this feature is planned in.
+ *
+ * @param featureState the state to read from
+ * @return the configured zone, or the fallback zone when none is set
+ * @throws IllegalArgumentException when the configured zone id is not a known zone
+ */
+ @Contract(pure = true)
+ public ZoneId zoneOf(FeatureState featureState) {
+ String raw = featureState.getParameter(PARAM_ZONE);
+ if (raw == null || raw.isBlank()) {
+ return this.fallbackZone;
+ }
+ try {
+ return ZoneId.of(raw.trim());
+ } catch (DateTimeException exception) {
+ throw new IllegalArgumentException(PARAM_ZONE + "='" + raw.trim() + "' is not a known time zone");
+ }
+ }
+
+ private static Optional bound(FeatureState featureState, String parameter) {
+ try {
+ return Optional.ofNullable(readBound(featureState, parameter));
+ } catch (IllegalArgumentException exception) {
+ return Optional.empty();
+ }
+ }
+
+ private Window readWindow(FeatureState featureState) {
+ return new Window(zoneOf(featureState), readBound(featureState, PARAM_FROM), readBound(featureState, PARAM_TO));
+ }
+
+ private static @Nullable LocalDateTime readBound(FeatureState featureState, String parameter) {
+ String raw = featureState.getParameter(parameter);
+ if (raw == null || raw.isBlank()) {
+ return null;
+ }
+ String value = raw.trim();
+ try {
+ return value.indexOf('T') < 0 ? LocalDate.parse(value).atStartOfDay() : LocalDateTime.parse(value);
+ } catch (DateTimeParseException exception) {
+ throw new IllegalArgumentException(parameter + "='" + value + "' is not a date (2026-10-01) or a date-time (2026-10-01T18:00)");
+ }
+ }
+
+ /** The three window parameters, once they have been read successfully. */
+ private record Window(ZoneId zone, @Nullable LocalDateTime from, @Nullable LocalDateTime to) {
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java b/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java
new file mode 100644
index 00000000..9538c791
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java
@@ -0,0 +1,92 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.togglz.core.activation.DefaultActivationStrategyProvider;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.manager.FeatureManagerBuilder;
+import org.togglz.core.repository.file.FileBasedStateRepository;
+import org.togglz.core.spi.FeatureManagerProvider;
+import org.togglz.core.user.thread.ThreadLocalUserProvider;
+
+import java.io.File;
+
+/**
+ * Supplies the ambient Togglz {@link FeatureManager} for Titan: {@link TitanFeatures} backed by
+ * the {@code flags.properties} file next to the running process.
+ *
+ * Why the priority is {@value #PRIORITY}. Togglz collects every
+ * {@link FeatureManagerProvider} on the classpath through the {@link java.util.ServiceLoader},
+ * sorts them by {@link #priority()} ascending and takes the first one that returns a manager, so
+ * the lowest number wins. Titan's fat jar contains a second provider:
+ * {@code net.onelitefeather.butterfly.minestom.feature.SingletonFeatureManagerProvider}, which
+ * reads the same {@code flags.properties} but builds its manager from {@code ButterflyFeatures}.
+ * Both providers used to declare {@code 30}, and a tie is broken by whichever entry the merged
+ * service file happens to list first - that is shadow's classpath walk order, which changes when
+ * a dependency is reordered in {@code app/build.gradle.kts} or its configuration changes. Losing
+ * that coin flip is quiet rather than loud: {@code getFeatureState} still resolves Titan's flags
+ * by name, but {@link FeatureGate#statuses()},
+ * {@link FeatureGate#pollStageTransitions()} and {@code /season status} would enumerate
+ * Butterfly's enum instead of Titan's. {@value #PRIORITY} puts Titan ahead of Butterfly's 30 and
+ * of Togglz's own providers (50 to 200) without relying on file order.
+ *
+ *
On the static field. The lazily initialised {@code featureManager} is mutable static
+ * state, which OLF-L2-05 forbids on principle. It is the deviation that rule names explicitly:
+ * the Togglz SPI instantiates this class through the {@link java.util.ServiceLoader}, so there is
+ * no instance for the manager to hang off. The consequence the rule draws is that the class
+ * belongs in Butterfly rather than in two projects - not that the field should be turned into
+ * something else here.
+ *
+ * @author TheMeinerLP
+ * @version 1.1.0
+ * @since 1.0.0
+ */
+public final class SingletonFeatureManagerProvider implements FeatureManagerProvider {
+
+ /**
+ * Priority of this provider. Lower wins; Butterfly's rival provider declares {@code 30} and
+ * Togglz's own providers declare {@code 50} and above.
+ */
+ public static final int PRIORITY = 10;
+
+ private static FeatureManager featureManager;
+ private static final File FLAGS = new File("flags.properties");
+
+ /**
+ * Returns the feature manager, building it on first use.
+ *
+ * @return the manager over {@link TitanFeatures}
+ */
+ @Override
+ public FeatureManager getFeatureManager() {
+ if (featureManager == null) {
+ featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(new FileBasedStateRepository(FLAGS)).userProvider(new ThreadLocalUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+ }
+
+ return featureManager;
+ }
+
+ /**
+ * Returns {@value #PRIORITY}, low enough to beat Butterfly's provider deterministically.
+ *
+ * @return the provider priority, lower wins
+ */
+ @Override
+ public int priority() {
+ return PRIORITY;
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/StageTransition.java b/common/src/main/java/net/onelitefeather/titan/common/feature/StageTransition.java
new file mode 100644
index 00000000..1fc8990c
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/StageTransition.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import java.time.ZonedDateTime;
+
+/**
+ * A single observed change of a feature's release stage — the record US-3.09 asks for, and the
+ * material for a new line in {@code docs/rollout-log.md}.
+ *
+ * @param feature name of the Togglz feature that moved
+ * @param from the stage the feature was on before
+ * @param to the stage the feature is on now
+ * @param at the moment the change was observed
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public record StageTransition(String feature, ReleaseStage from, ReleaseStage to,
+ ZonedDateTime at) {
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/StageTransitionLogger.java b/common/src/main/java/net/onelitefeather/titan/common/feature/StageTransitionLogger.java
new file mode 100644
index 00000000..ae8476db
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/StageTransitionLogger.java
@@ -0,0 +1,78 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Clock;
+import java.time.ZoneId;
+import java.time.ZonedDateTime;
+import java.util.Map;
+import java.util.Optional;
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * Remembers the release stage each feature was last seen on and logs every change.
+ *
+ *
Stages live in a flag file that is reloaded in the background, so a stage change is not an
+ * event anyone fires — it is a difference between two observations. This class turns that
+ * difference into one log line with timestamp, old stage and new stage (US-3.09). The first
+ * observation of a feature is not a change: it seeds the memory and stays silent, so a restart
+ * does not fake a transition.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
+public final class StageTransitionLogger {
+
+ private static final Logger LOGGER = LoggerFactory.getLogger(StageTransitionLogger.class);
+
+ private final Map lastSeen = new ConcurrentHashMap<>();
+ private final Clock clock;
+ private final ZoneId zone;
+
+ /**
+ * Creates a logger that timestamps transitions with the given clock.
+ *
+ * @param clock the time source, so tests do not have to wait for real time
+ * @param zone the zone timestamps are rendered in
+ */
+ public StageTransitionLogger(Clock clock, ZoneId zone) {
+ this.clock = clock;
+ this.zone = zone;
+ }
+
+ /**
+ * Records the stage a feature is currently on and reports a change against the previous
+ * observation.
+ *
+ * @param feature name of the feature
+ * @param stage the stage observed now
+ * @return the transition when the stage changed, otherwise an empty optional
+ */
+ public Optional observe(String feature, ReleaseStage stage) {
+ ReleaseStage previous = this.lastSeen.put(feature, stage);
+ if (previous == null || previous == stage) {
+ return Optional.empty();
+ }
+ StageTransition transition = new StageTransition(feature, previous, stage, ZonedDateTime.ofInstant(this.clock.instant(), this.zone));
+ LOGGER.info("Feature {} changed release stage at {}: {} -> {}", transition.feature(), transition.at(), transition.from().id(), transition.to().id());
+ return Optional.of(transition);
+ }
+}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/utils/TitanFeatures.java b/common/src/main/java/net/onelitefeather/titan/common/feature/TitanFeatures.java
similarity index 66%
rename from common/src/main/java/net/onelitefeather/titan/common/utils/TitanFeatures.java
rename to common/src/main/java/net/onelitefeather/titan/common/feature/TitanFeatures.java
index eb0c3772..bc06769b 100644
--- a/common/src/main/java/net/onelitefeather/titan/common/utils/TitanFeatures.java
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/TitanFeatures.java
@@ -14,11 +14,24 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-package net.onelitefeather.titan.common.utils;
+package net.onelitefeather.titan.common.feature;
+import net.onelitefeather.titan.common.utils.ThreadHelper;
import org.togglz.core.Feature;
import org.togglz.core.context.FeatureContext;
+/**
+ * The feature flags Titan knows. The enum is the single source of truth for the flag names, and
+ * {@link SingletonFeatureManagerProvider} builds the ambient
+ * {@link org.togglz.core.manager.FeatureManager} from exactly this enum.
+ *
+ * Release stages and time windows are configuration on an existing flag, not new flags
+ * (NFR-009); {@link FeatureGate} reads them from the feature state.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.15.0
+ */
public enum TitanFeatures implements Feature, ThreadHelper {
NAVIGATOR_CREATIVE, NAVIGATOR_SLENDER, NAVIGATOR_MANIS, NAVIGATOR_SURVIVAL, NAVIGATOR_ELYTRA,;
diff --git a/common/src/main/java/net/onelitefeather/titan/common/feature/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/feature/package-info.java
new file mode 100644
index 00000000..87ccb635
--- /dev/null
+++ b/common/src/main/java/net/onelitefeather/titan/common/feature/package-info.java
@@ -0,0 +1,12 @@
+/**
+ * Staged feature delivery: one gate that decides whether a player sees a feature, and the time
+ * window that gate honours.
+ *
+ *
This package is the only place in Titan that talks to Togglz. Everything else asks
+ * {@link net.onelitefeather.titan.common.feature.FeatureGate} and never touches a
+ * {@code FeatureManager} itself.
+ */
+@NotNullByDefault
+package net.onelitefeather.titan.common.feature;
+
+import org.jetbrains.annotations.NotNullByDefault;
diff --git a/common/src/main/java/net/onelitefeather/titan/common/utils/SingletonFeatureManagerProvider.java b/common/src/main/java/net/onelitefeather/titan/common/utils/SingletonFeatureManagerProvider.java
deleted file mode 100644
index 8f508175..00000000
--- a/common/src/main/java/net/onelitefeather/titan/common/utils/SingletonFeatureManagerProvider.java
+++ /dev/null
@@ -1,46 +0,0 @@
-/**
- * Copyright (C) 2025 OneLiteFeather Network
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU Affero General Public License as published
- * by the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU Affero General Public License for more details.
- *
- * You should have received a copy of the GNU Affero General Public License
- * along with this program. If not, see .
- */
-package net.onelitefeather.titan.common.utils;
-
-import org.togglz.core.activation.DefaultActivationStrategyProvider;
-import org.togglz.core.manager.FeatureManager;
-import org.togglz.core.manager.FeatureManagerBuilder;
-import org.togglz.core.repository.file.FileBasedStateRepository;
-import org.togglz.core.spi.FeatureManagerProvider;
-import org.togglz.core.user.thread.ThreadLocalUserProvider;
-
-import java.io.File;
-
-public final class SingletonFeatureManagerProvider implements FeatureManagerProvider {
-
- private static FeatureManager featureManager;
- private static final File FLAGS = new File("flags.properties");
-
- @Override
- public FeatureManager getFeatureManager() {
- if (featureManager == null) {
- featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(new FileBasedStateRepository(FLAGS)).userProvider(new ThreadLocalUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
- }
-
- return featureManager;
- }
-
- @Override
- public int priority() {
- return 30;
- }
-}
diff --git a/common/src/main/java/net/onelitefeather/titan/common/utils/ThreadHelper.java b/common/src/main/java/net/onelitefeather/titan/common/utils/ThreadHelper.java
index a360ae37..45a2259b 100644
--- a/common/src/main/java/net/onelitefeather/titan/common/utils/ThreadHelper.java
+++ b/common/src/main/java/net/onelitefeather/titan/common/utils/ThreadHelper.java
@@ -18,6 +18,21 @@
import java.util.function.Supplier;
+/**
+ * Runs a {@link java.util.ServiceLoader}-backed lookup with the context classloader temporarily
+ * pointed at the classloader of the caller, so an SPI shipped by this jar is found even when the
+ * calling thread carries an unrelated context classloader.
+ *
+ *
This type stays in {@code common/utils} on purpose. It is not Titan code that lost its home
+ * (OLF-L3-02): it is the fourth byte-identical copy of the same helper in the OneLiteFeather
+ * estate (Titan, Butterfly Minestom, Butterfly Bukkit, ManisGame) and its destination is
+ * Butterfly, not another Titan package (OLF-L2-04, open point 4 of the OLF standard). Moving it
+ * inside Titan first would only make the eventual deletion harder to spot.
+ *
+ * @author TheMeinerLP
+ * @version 1.0.0
+ * @since 1.0.0
+ */
public interface ThreadHelper {
default void syncThreadForServiceLoader(Runnable runnable) {
Thread currentThread = Thread.currentThread();
diff --git a/common/src/main/resources/META-INF/services/org.togglz.core.spi.ActivationStrategy b/common/src/main/resources/META-INF/services/org.togglz.core.spi.ActivationStrategy
new file mode 100644
index 00000000..a30e8b62
--- /dev/null
+++ b/common/src/main/resources/META-INF/services/org.togglz.core.spi.ActivationStrategy
@@ -0,0 +1 @@
+net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy
diff --git a/common/src/main/resources/META-INF/services/org.togglz.core.spi.FeatureManagerProvider b/common/src/main/resources/META-INF/services/org.togglz.core.spi.FeatureManagerProvider
index 5e73363f..9774d85d 100644
--- a/common/src/main/resources/META-INF/services/org.togglz.core.spi.FeatureManagerProvider
+++ b/common/src/main/resources/META-INF/services/org.togglz.core.spi.FeatureManagerProvider
@@ -1 +1 @@
-net.onelitefeather.titan.common.utils.SingletonFeatureManagerProvider
\ No newline at end of file
+net.onelitefeather.titan.common.feature.SingletonFeatureManagerProvider
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java
new file mode 100644
index 00000000..724fb85f
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java
@@ -0,0 +1,282 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.togglz.core.activation.DefaultActivationStrategyProvider;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.manager.FeatureManagerBuilder;
+import org.togglz.core.repository.FeatureState;
+import org.togglz.core.repository.file.FileBasedStateRepository;
+import org.togglz.core.repository.mem.InMemoryStateRepository;
+import org.togglz.core.user.NoOpUserProvider;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.UUID;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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 FeatureGateTest {
+
+ private static final TitanFeatures FEATURE = TitanFeatures.NAVIGATOR_ELYTRA;
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+ private static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z");
+
+ private static final UUID TEAM = UUID.randomUUID();
+ private static final UUID LITE = UUID.randomUUID();
+ private static final UUID ANYONE = UUID.randomUUID();
+
+ /** A window that is open at {@link #NOW}. */
+ private static final String OPEN_FROM = "2026-10-01";
+ private static final String OPEN_TO = "2026-11-05";
+
+ /** A window that has already closed at {@link #NOW}. */
+ private static final String CLOSED_FROM = "2026-01-01";
+ private static final String CLOSED_TO = "2026-02-01";
+
+ private InMemoryStateRepository repository;
+ private FeatureGate gate;
+
+ @BeforeEach
+ void setUp() {
+ this.repository = new InMemoryStateRepository();
+ FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(this.repository).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+ TestFeatureAudience audience = new TestFeatureAudience().grantPermission(TEAM, ReleaseStage.INTERNAL_PERMISSION).joinGroup(LITE, ReleaseStage.LITE_GROUP);
+ this.gate = FeatureGate.with(featureManager, audience, Clock.fixed(NOW, ZoneOffset.UTC), BERLIN);
+ }
+
+ private void configure(boolean enabled, ReleaseStage stage, String from, String to) {
+ FeatureState state = new FeatureState(FEATURE, enabled).setStrategyId(SeasonWindowActivationStrategy.ID).setParameter(FeatureGate.STAGE_PARAMETER, stage.id());
+ if (from != null) {
+ state.setParameter(SeasonWindowActivationStrategy.PARAM_FROM, from);
+ }
+ if (to != null) {
+ state.setParameter(SeasonWindowActivationStrategy.PARAM_TO, to);
+ }
+ this.repository.setFeatureState(state);
+ }
+
+ @Test
+ @DisplayName("the kill switch beats an open window and a general release")
+ void killSwitchBeatsStageAndWindow() {
+ configure(false, ReleaseStage.GA, OPEN_FROM, OPEN_TO);
+
+ assertEquals(FeatureDecision.DENIED_KILL_SWITCH, this.gate.decide(FEATURE, ANYONE));
+ assertEquals(FeatureDecision.DENIED_KILL_SWITCH, this.gate.decide(FEATURE, LITE));
+ assertEquals(FeatureDecision.DENIED_KILL_SWITCH, this.gate.decide(FEATURE, TEAM));
+ assertFalse(this.gate.isVisibleTo(FEATURE, TEAM));
+ }
+
+ @Test
+ @DisplayName("lite players see a feature that has not reached general release")
+ void liteSeesWhatGaHasNotReached() {
+ configure(true, ReleaseStage.LITE, OPEN_FROM, OPEN_TO);
+
+ assertTrue(this.gate.isVisibleTo(FEATURE, LITE));
+ assertTrue(this.gate.isVisibleTo(FEATURE, TEAM));
+ assertEquals(FeatureDecision.DENIED_STAGE, this.gate.decide(FEATURE, ANYONE));
+ }
+
+ @Test
+ @DisplayName("a player without permissions sees nothing that is not on ga")
+ void withoutPermissionsOnlyGaIsVisible() {
+ configure(true, ReleaseStage.INTERNAL, null, null);
+ assertEquals(FeatureDecision.DENIED_STAGE, this.gate.decide(FEATURE, ANYONE));
+
+ configure(true, ReleaseStage.LITE, null, null);
+ assertEquals(FeatureDecision.DENIED_STAGE, this.gate.decide(FEATURE, ANYONE));
+
+ configure(true, ReleaseStage.GA, null, null);
+ assertEquals(FeatureDecision.ALLOWED, this.gate.decide(FEATURE, ANYONE));
+ }
+
+ @Test
+ @DisplayName("the stage is evaluated before the window, so the stage is the reported reason")
+ void stageIsEvaluatedBeforeTheWindow() {
+ configure(true, ReleaseStage.INTERNAL, CLOSED_FROM, CLOSED_TO);
+
+ // Both the stage and the window would deny this player; the fixed order reports the stage.
+ assertEquals(FeatureDecision.DENIED_STAGE, this.gate.decide(FEATURE, ANYONE));
+ // The team member passes the stage, so the window becomes the deciding step.
+ assertEquals(FeatureDecision.DENIED_WINDOW, this.gate.decide(FEATURE, TEAM));
+ }
+
+ @Test
+ @DisplayName("a closed window hides a feature that is generally released")
+ void closedWindowHidesAGeneralRelease() {
+ configure(true, ReleaseStage.GA, CLOSED_FROM, CLOSED_TO);
+
+ assertEquals(FeatureDecision.DENIED_WINDOW, this.gate.decide(FEATURE, ANYONE));
+ }
+
+ @Test
+ @DisplayName("a feature without a stage parameter stays internal")
+ void missingStageFallsBackToInternal() {
+ this.repository.setFeatureState(new FeatureState(FEATURE, true));
+
+ assertEquals(FeatureDecision.DENIED_STAGE, this.gate.decide(FEATURE, ANYONE));
+ assertEquals(FeatureDecision.ALLOWED, this.gate.decide(FEATURE, TEAM));
+ }
+
+ @Test
+ @DisplayName("an unknown stage name stays internal instead of widening the audience")
+ void unknownStageFallsBackToInternal() {
+ this.repository.setFeatureState(
+ new FeatureState(FEATURE, true).setParameter(FeatureGate.STAGE_PARAMETER, "everyone"));
+
+ assertEquals(FeatureDecision.DENIED_STAGE, this.gate.decide(FEATURE, ANYONE));
+ assertEquals(ReleaseStage.INTERNAL, this.gate.status(FEATURE).stage());
+ }
+
+ @Test
+ @DisplayName("a feature nobody configured is invisible, not public")
+ void unconfiguredFeatureIsInvisible() {
+ assertEquals(FeatureDecision.DENIED_KILL_SWITCH, this.gate.decide(FEATURE, TEAM));
+ }
+
+ @Test
+ @DisplayName("the status reports kill switch, stage and window of every feature")
+ void statusReportsKillSwitchStageAndWindow() {
+ configure(true, ReleaseStage.LITE, OPEN_FROM, OPEN_TO);
+
+ FeatureStatus status = this.gate.status(FEATURE);
+
+ assertEquals(FEATURE.name(), status.feature());
+ assertFalse(status.killSwitchEngaged());
+ assertEquals(ReleaseStage.LITE, status.stage());
+ assertTrue(status.hasWindow());
+ assertTrue(status.withinWindow());
+ assertEquals(BERLIN, status.zone());
+ assertEquals(TitanFeatures.values().length, this.gate.statuses().size());
+ }
+
+ @Test
+ @DisplayName("a feature without a window reports no bounds and counts as open")
+ void statusOfAWindowlessFeature() {
+ configure(true, ReleaseStage.GA, null, null);
+
+ FeatureStatus status = this.gate.status(FEATURE);
+
+ assertFalse(status.hasWindow());
+ assertNull(status.from());
+ assertNull(status.to());
+ assertTrue(status.withinWindow());
+ }
+
+ @Test
+ @DisplayName("stage and window are read from a real flags.properties")
+ void readsStageAndWindowFromAFlagFile(@TempDir Path directory) throws IOException {
+ Path flags = directory.resolve("flags.properties");
+ Files.writeString(flags, """
+ NAVIGATOR_ELYTRA = true
+ NAVIGATOR_ELYTRA.strategy = season-window
+ NAVIGATOR_ELYTRA.param.stage = lite
+ NAVIGATOR_ELYTRA.param.from = 2026-10-01
+ NAVIGATOR_ELYTRA.param.to = 2026-11-05
+ NAVIGATOR_ELYTRA.param.zone = Europe/Berlin
+ """);
+ FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(new FileBasedStateRepository(flags.toFile())).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+ FeatureGate fileGate = FeatureGate.with(featureManager, new TestFeatureAudience().grantPermission(TEAM, ReleaseStage.INTERNAL_PERMISSION).joinGroup(LITE, ReleaseStage.LITE_GROUP), Clock.fixed(NOW, ZoneOffset.UTC), BERLIN);
+
+ assertEquals(FeatureDecision.ALLOWED, fileGate.decide(FEATURE, LITE));
+ assertEquals(FeatureDecision.DENIED_STAGE, fileGate.decide(FEATURE, ANYONE));
+
+ FeatureStatus status = fileGate.status(FEATURE);
+ assertEquals(ReleaseStage.LITE, status.stage());
+ assertEquals(BERLIN, status.zone());
+ assertTrue(status.withinWindow());
+ }
+
+ @Test
+ @DisplayName("an unreadable window is reported as unreadable, never as no window at all")
+ void statusReportsAnUnreadableWindow() {
+ this.repository.setFeatureState(new FeatureState(FEATURE, true).setStrategyId(SeasonWindowActivationStrategy.ID).setParameter(FeatureGate.STAGE_PARAMETER, ReleaseStage.GA.id()).setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "1. Oktober"));
+
+ FeatureStatus status = this.gate.status(FEATURE);
+
+ // The gate denies everyone here, so the status must not suggest the feature runs unbounded.
+ assertEquals(FeatureDecision.DENIED_WINDOW, this.gate.decide(FEATURE, ANYONE));
+ assertFalse(status.windowReadable());
+ assertNotNull(status.windowProblem());
+ assertFalse(status.withinWindow());
+ assertFalse(status.hasWindow());
+ }
+
+ @Test
+ @DisplayName("an unknown stage id is reported alongside the stage that was used instead")
+ void statusReportsAnUnknownStageId() {
+ // "intern" is the German spelling the rollout log used to use - a plausible typo, and one
+ // that silently narrows the audience to internal.
+ this.repository.setFeatureState(new FeatureState(FEATURE, true).setParameter(FeatureGate.STAGE_PARAMETER, "intern"));
+
+ FeatureStatus status = this.gate.status(FEATURE);
+
+ assertEquals(ReleaseStage.INTERNAL, status.stage());
+ assertFalse(status.stageReadable());
+ assertEquals("intern", status.unknownStage());
+ }
+
+ @Test
+ @DisplayName("a readable configuration reports no problems")
+ void statusReportsNoProblemsForAReadableConfiguration() {
+ configure(true, ReleaseStage.GA, OPEN_FROM, OPEN_TO);
+
+ FeatureStatus status = this.gate.status(FEATURE);
+
+ assertTrue(status.windowReadable());
+ assertTrue(status.stageReadable());
+ assertNull(status.windowProblem());
+ assertNull(status.unknownStage());
+ }
+
+ @Test
+ @DisplayName("a status can never claim an unreadable window is open")
+ void anUnreadableWindowCanNeverBeOpen() {
+ assertThrows(IllegalArgumentException.class, () -> new FeatureStatus("F", false, ReleaseStage.GA, null, null, null, BERLIN, true, "from='nonsense' is not a date"));
+ }
+
+ @Test
+ @DisplayName("polling reports a stage change once and stays silent afterwards")
+ void pollingReportsEachStageChangeOnce() {
+ configure(true, ReleaseStage.INTERNAL, null, null);
+ assertTrue(this.gate.pollStageTransitions().isEmpty(), "the first walk only seeds the memory");
+
+ configure(true, ReleaseStage.LITE, null, null);
+ List transitions = this.gate.pollStageTransitions();
+
+ assertEquals(1, transitions.size());
+ assertEquals(ReleaseStage.INTERNAL, transitions.getFirst().from());
+ assertEquals(ReleaseStage.LITE, transitions.getFirst().to());
+ assertEquals(NOW, transitions.getFirst().at().toInstant());
+ assertTrue(this.gate.pollStageTransitions().isEmpty());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/ReleaseStageTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/ReleaseStageTest.java
new file mode 100644
index 00000000..426d944f
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/ReleaseStageTest.java
@@ -0,0 +1,77 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Optional;
+import java.util.UUID;
+
+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 ReleaseStageTest {
+
+ private static final UUID TEAM = UUID.randomUUID();
+ private static final UUID LITE = UUID.randomUUID();
+ private static final UUID ANYONE = UUID.randomUUID();
+
+ private final TestFeatureAudience audience = new TestFeatureAudience().grantPermission(TEAM, ReleaseStage.INTERNAL_PERMISSION).joinGroup(LITE, ReleaseStage.LITE_GROUP);
+
+ @Test
+ @DisplayName("stage ids are read back from their flag file spelling")
+ void fromIdReadsTheFlagFileSpelling() {
+ assertEquals(Optional.of(ReleaseStage.INTERNAL), ReleaseStage.fromId("internal"));
+ assertEquals(Optional.of(ReleaseStage.LITE), ReleaseStage.fromId(" LITE "));
+ assertEquals(Optional.of(ReleaseStage.GA), ReleaseStage.fromId("ga"));
+ }
+
+ @Test
+ @DisplayName("an absent or unknown stage id resolves to nothing, not to a wider audience")
+ void fromIdRejectsUnknownValues() {
+ assertTrue(ReleaseStage.fromId(null).isEmpty());
+ assertTrue(ReleaseStage.fromId("").isEmpty());
+ assertTrue(ReleaseStage.fromId("public").isEmpty());
+ assertEquals(ReleaseStage.INTERNAL, ReleaseStage.DEFAULT);
+ }
+
+ @Test
+ @DisplayName("internal admits only the team")
+ void internalAdmitsOnlyTheTeam() {
+ assertTrue(ReleaseStage.INTERNAL.admits(TEAM, this.audience));
+ assertFalse(ReleaseStage.INTERNAL.admits(LITE, this.audience));
+ assertFalse(ReleaseStage.INTERNAL.admits(ANYONE, this.audience));
+ }
+
+ @Test
+ @DisplayName("lite admits the lite group and keeps the team")
+ void liteAdmitsTheGroupAndTheTeam() {
+ assertTrue(ReleaseStage.LITE.admits(LITE, this.audience));
+ assertTrue(ReleaseStage.LITE.admits(TEAM, this.audience));
+ assertFalse(ReleaseStage.LITE.admits(ANYONE, this.audience));
+ }
+
+ @Test
+ @DisplayName("ga admits everyone without asking the permission backend")
+ void gaAdmitsEveryone() {
+ assertTrue(ReleaseStage.GA.admits(ANYONE, this.audience));
+ assertTrue(ReleaseStage.GA.admits(LITE, this.audience));
+ assertTrue(ReleaseStage.GA.admits(TEAM, this.audience));
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java
new file mode 100644
index 00000000..70cb4410
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java
@@ -0,0 +1,159 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.togglz.core.activation.DefaultActivationStrategyProvider;
+import org.togglz.core.activation.Parameter;
+import org.togglz.core.manager.FeatureManager;
+import org.togglz.core.manager.FeatureManagerBuilder;
+import org.togglz.core.repository.FeatureState;
+import org.togglz.core.repository.mem.InMemoryStateRepository;
+import org.togglz.core.spi.ActivationStrategy;
+import org.togglz.core.user.NoOpUserProvider;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.ServiceLoader;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class SeasonWindowActivationStrategyTest {
+
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+ private static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z");
+
+ private final SeasonWindowActivationStrategy strategy = new SeasonWindowActivationStrategy(Clock.fixed(NOW, ZoneOffset.UTC), BERLIN);
+
+ private static FeatureState state() {
+ return new FeatureState(TitanFeatures.NAVIGATOR_ELYTRA, true);
+ }
+
+ @Test
+ @DisplayName("a state without bounds is always inside its window")
+ void noBoundsMeansAlwaysOpen() {
+ assertTrue(this.strategy.isWithinWindow(state()));
+ }
+
+ @Test
+ @DisplayName("the window opens on its start and closes on its end")
+ void windowIsInclusiveAtTheStartAndExclusiveAtTheEnd() {
+ // 12:00Z is 14:00 in Berlin on 15.10.2026 (CEST).
+ assertTrue(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-15T14:00").setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2026-10-15T14:01")));
+ assertFalse(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-15T14:01")));
+ assertFalse(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2026-10-15T14:00")));
+ }
+
+ @Test
+ @DisplayName("a bare date is read as the start of that day")
+ void plainDatesAreReadAsStartOfDay() {
+ assertTrue(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-15").setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2026-11-05")));
+ assertEquals(LocalDateTime.parse("2026-10-15T00:00"), this.strategy.from(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-15")).orElseThrow());
+ }
+
+ @Test
+ @DisplayName("one-sided windows stay open on the missing side")
+ void oneSidedWindowsStayOpen() {
+ assertTrue(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-01-01")));
+ assertTrue(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2027-01-01")));
+ }
+
+ @Test
+ @DisplayName("the zone parameter decides which local time the bounds mean")
+ void zoneParameterIsHonoured() {
+ FeatureState berlin = state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-15T13:00").setParameter(SeasonWindowActivationStrategy.PARAM_ZONE, "Europe/Berlin");
+ FeatureState utc = state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-15T13:00").setParameter(SeasonWindowActivationStrategy.PARAM_ZONE, "UTC");
+ // Berlin is on summer time in October: 12:00Z is 14:00 local, so the window is open there
+ // and still closed in UTC. This is the difference Togglz' ReleaseDateActivationStrategy
+ // cannot express.
+ assertTrue(this.strategy.isWithinWindow(berlin));
+ assertFalse(this.strategy.isWithinWindow(utc));
+ assertEquals(ZoneId.of("UTC"), this.strategy.zoneOf(utc));
+ assertEquals(BERLIN, this.strategy.zoneOf(state()));
+ }
+
+ @Test
+ @DisplayName("an unreadable bound switches the feature off instead of widening it")
+ void unreadableParametersFailClosed() {
+ assertFalse(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "1. Oktober")));
+ assertFalse(this.strategy.isWithinWindow(state().setParameter(SeasonWindowActivationStrategy.PARAM_ZONE, "Mars/Olympus")));
+ }
+
+ @Test
+ @DisplayName("an unreadable bound is named, a readable one reports no problem")
+ void windowProblemNamesTheOffendingParameter() {
+ assertNull(this.strategy.windowProblem(state()));
+ assertNull(this.strategy.windowProblem(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2026-10-01").setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2026-11-05T04:00")));
+
+ String badFrom = this.strategy.windowProblem(state().setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "1. Oktober"));
+ assertNotNull(badFrom);
+ assertTrue(badFrom.contains(SeasonWindowActivationStrategy.PARAM_FROM), badFrom);
+ assertTrue(badFrom.contains("1. Oktober"), badFrom);
+
+ String badZone = this.strategy.windowProblem(state().setParameter(SeasonWindowActivationStrategy.PARAM_ZONE, "Mars/Olympus"));
+ assertNotNull(badZone);
+ assertTrue(badZone.contains("Mars/Olympus"), badZone);
+ }
+
+ @Test
+ @DisplayName("the strategy declares exactly from, to and zone, all optional")
+ void declaresThreeOptionalParameters() {
+ List names = new ArrayList<>();
+ for (Parameter parameter : this.strategy.getParameters()) {
+ names.add(parameter.getName());
+ assertTrue(parameter.isOptional(), parameter.getName() + " must be optional");
+ }
+ assertEquals(List.of(SeasonWindowActivationStrategy.PARAM_FROM, SeasonWindowActivationStrategy.PARAM_TO, SeasonWindowActivationStrategy.PARAM_ZONE), names);
+ assertEquals(SeasonWindowActivationStrategy.ID, this.strategy.getId());
+ }
+
+ @Test
+ @DisplayName("a feature manager dispatches to the strategy it found through the service file")
+ void aFeatureManagerDispatchesToTheRegisteredStrategy() {
+ InMemoryStateRepository repository = new InMemoryStateRepository();
+ FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(repository).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
+
+ // The strategy instance used here is the one the ServiceLoader built, so it runs on the
+ // system clock. The bounds are deliberately decades wide: this test is about the wiring,
+ // not about the arithmetic, which the tests above cover with a fixed clock.
+ repository.setFeatureState(state().setStrategyId(SeasonWindowActivationStrategy.ID).setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2000-01-01").setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2099-01-01"));
+ assertTrue(featureManager.isActive(TitanFeatures.NAVIGATOR_ELYTRA));
+
+ repository.setFeatureState(state().setStrategyId(SeasonWindowActivationStrategy.ID).setParameter(SeasonWindowActivationStrategy.PARAM_FROM, "2000-01-01").setParameter(SeasonWindowActivationStrategy.PARAM_TO, "2001-01-01"));
+ assertFalse(featureManager.isActive(TitanFeatures.NAVIGATOR_ELYTRA));
+ }
+
+ @Test
+ @DisplayName("the strategy is discovered through the Togglz activation-strategy service file")
+ void isRegisteredAsAService() {
+ List ids = new ArrayList<>();
+ for (ActivationStrategy loaded : ServiceLoader.load(ActivationStrategy.class, SeasonWindowActivationStrategyTest.class.getClassLoader())) {
+ ids.add(loaded.getId());
+ }
+ assertTrue(ids.contains(SeasonWindowActivationStrategy.ID), "META-INF/services/org.togglz.core.spi.ActivationStrategy must list the season window; found " + ids);
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/StageTransitionLoggerTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/StageTransitionLoggerTest.java
new file mode 100644
index 00000000..88dbd448
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/StageTransitionLoggerTest.java
@@ -0,0 +1,73 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class StageTransitionLoggerTest {
+
+ private static final ZoneId BERLIN = ZoneId.of("Europe/Berlin");
+ private static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z");
+
+ private final StageTransitionLogger logger = new StageTransitionLogger(Clock.fixed(NOW, ZoneOffset.UTC), BERLIN);
+
+ @Test
+ @DisplayName("the first observation seeds the memory instead of faking a transition")
+ void firstObservationIsNotATransition() {
+ assertTrue(this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.INTERNAL).isEmpty());
+ }
+
+ @Test
+ @DisplayName("a changed stage is reported with timestamp, old stage and new stage")
+ void changedStageIsReported() {
+ this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.INTERNAL);
+
+ Optional transition = this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.LITE);
+
+ assertTrue(transition.isPresent());
+ assertEquals("NAVIGATOR_ELYTRA", transition.orElseThrow().feature());
+ assertEquals(ReleaseStage.INTERNAL, transition.orElseThrow().from());
+ assertEquals(ReleaseStage.LITE, transition.orElseThrow().to());
+ assertEquals(NOW, transition.orElseThrow().at().toInstant());
+ assertEquals(BERLIN, transition.orElseThrow().at().getZone());
+ }
+
+ @Test
+ @DisplayName("an unchanged stage is not reported again")
+ void unchangedStageIsSilent() {
+ this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.GA);
+ assertTrue(this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.GA).isEmpty());
+ }
+
+ @Test
+ @DisplayName("features are tracked independently")
+ void featuresAreTrackedIndependently() {
+ this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.INTERNAL);
+ assertTrue(this.logger.observe("NAVIGATOR_SLENDER", ReleaseStage.GA).isEmpty());
+ assertTrue(this.logger.observe("NAVIGATOR_ELYTRA", ReleaseStage.GA).isPresent());
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java b/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java
new file mode 100644
index 00000000..36f52f44
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java
@@ -0,0 +1,56 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Set;
+import java.util.UUID;
+
+/**
+ * Fixture standing in for LuckPerms: a fixed set of granted permissions and group memberships.
+ */
+final class TestFeatureAudience implements FeatureAudience {
+
+ private final Set permissions = new HashSet<>();
+ private final Set groups = new HashSet<>();
+
+ TestFeatureAudience grantPermission(UUID playerId, String permission) {
+ this.permissions.add(key(playerId, permission));
+ return this;
+ }
+
+ TestFeatureAudience joinGroup(UUID playerId, String group) {
+ this.groups.add(key(playerId, group.toLowerCase(Locale.ROOT)));
+ return this;
+ }
+
+ @Override
+ public boolean hasPermission(UUID playerId, String permission) {
+ return this.permissions.contains(key(playerId, permission));
+ }
+
+ @Override
+ public boolean inGroup(UUID playerId, String group) {
+ return this.groups.contains(key(playerId, group.toLowerCase(Locale.ROOT)));
+ }
+
+ private static String key(UUID playerId, String value) {
+ return playerId + "/" + value;
+ }
+}
diff --git a/common/src/test/java/net/onelitefeather/titan/common/feature/TitanFeaturesTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/TitanFeaturesTest.java
new file mode 100644
index 00000000..431bb209
--- /dev/null
+++ b/common/src/test/java/net/onelitefeather/titan/common/feature/TitanFeaturesTest.java
@@ -0,0 +1,47 @@
+/**
+ * Copyright (C) 2025 OneLiteFeather Network
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as published
+ * by the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ */
+package net.onelitefeather.titan.common.feature;
+
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class TitanFeaturesTest {
+
+ /** NFR-009: the flag list must not grow into one constant per sub-feature. */
+ private static final int MAX_FEATURES = 12;
+
+ @Test
+ @DisplayName("the feature list stays below the agreed ceiling of twelve")
+ void featureCountStaysBelowTheCeiling() {
+ assertTrue(TitanFeatures.values().length <= MAX_FEATURES, "TitanFeatures holds " + TitanFeatures.values().length + " constants, at most " + MAX_FEATURES + " are allowed (NFR-009). Release stages and time windows are " + "configuration on an existing flag, not new flags.");
+ }
+
+ @Test
+ @DisplayName("feature names are unique")
+ void featureNamesAreUnique() {
+ Set names = new HashSet<>();
+ Arrays.stream(TitanFeatures.values()).map(Enum::name).forEach(names::add);
+ assertEquals(TitanFeatures.values().length, names.size());
+ }
+}
diff --git a/docs/rollout-log.md b/docs/rollout-log.md
index ee34c5d4..4e1d620b 100644
--- a/docs/rollout-log.md
+++ b/docs/rollout-log.md
@@ -8,15 +8,52 @@ Zeilen bleiben stehen — der Verlauf ist der Zweck.
## Die Stufen
-| Stufe | Wer sieht es | Berechtigung |
+| Stufe | Wer sieht es | Bedingung |
|---|---|---|
-| `intern` | nur das Team | `titan.feature.internal` |
-| `premium` | Team **und** Premium-Spieler | `titan.feature.premium` |
+| `internal` | nur das Team | Berechtigung `titan.feature.internal` |
+| `lite` | Team **und** Lite-Spieler | LuckPerms-Gruppe `lite`, zusätzlich zu `titan.feature.internal` |
| `ga` | alle Spieler | — |
-| `aus` | niemand (Notausschalter) | — |
+| — (Notausschalter) | niemand | Flag steht auf `false` |
Der Notausschalter schlägt jede Stufe und jedes Zeitfenster. Die Prüfreihenfolge
ist in US-3.07 festgelegt: erst Notausschalter, dann Stufe, dann Zeitfenster.
+Umgesetzt ist sie in `FeatureGate` — der einzigen Klasse, die mit Togglz spricht.
+
+Ein Feature ohne hinterlegte Stufe gilt als `internal`, ein Feature ohne Eintrag
+in der Flag-Datei als abgeschaltet. Die enge Auslegung ist Absicht: ein
+vergessener Eintrag darf nichts freigeben.
+
+## Wie eine Stufe gesetzt wird
+
+Alles steht in `flags.properties` neben dem Prozess und wird ohne Neustart
+innerhalb einer Sekunde übernommen (US-3.05):
+
+```properties
+# Notausschalter: false heisst, niemand sieht das Feature
+NAVIGATOR_ELYTRA = true
+NAVIGATOR_ELYTRA.strategy = season-window
+# Stufe: internal | lite | ga
+NAVIGATOR_ELYTRA.param.stage = lite
+# Zeitfenster, beide Grenzen optional
+NAVIGATOR_ELYTRA.param.from = 2026-10-01
+NAVIGATOR_ELYTRA.param.to = 2026-11-05
+NAVIGATOR_ELYTRA.param.zone = Europe/Berlin
+```
+
+Kommentare stehen in einer `.properties`-Datei immer in einer eigenen Zeile: ein
+`#` mitten in der Zeile ist Teil des Wertes, kein Kommentar.
+
+`from` (einschließend) und `to` (ausschließend) nehmen ein Datum oder eine
+ISO-Zeitangabe (`2026-10-01T18:00`); `zone` ist optional und steht sonst auf
+`Europe/Berlin`. Ein unlesbarer Wert schaltet das Feature ab, statt es zu öffnen.
+
+Den aktuellen Stand zeigt `/season status` im Spiel — je Feature Stufe,
+Zeitfenster und Notausschalter. Unlesbare Konfiguration wird als solche
+ausgewiesen (`window unreadable: from='1. Oktober' …`, `stage internal
+('intern' is not internal, lite or ga)`) und nicht als „kein Zeitfenster"
+beschönigt: das Gate sperrt in dem Fall alle aus, und die Anzeige muss auf den
+Tippfehler zeigen statt auf ein unbegrenzt laufendes Feature. Die Togglz-Adminkonsole ist ein Servlet und in
+einem Minestom-Prozess nicht verfügbar; der Befehl ersetzt sie.
## Verlauf
@@ -30,9 +67,17 @@ Ein Stufenwechsel wird an zwei Stellen festgehalten, und beide sind Pflicht:
1. **Hier**, als neue Zeile mit Datum, Feature, Übergang, Grund und
verantwortlicher Person.
-2. **Im Log der Anwendung**, automatisch beim Wechsel (US-3.09).
+2. **Im Log der Anwendung**, automatisch beim Wechsel (US-3.09). Die Anwendung
+ sieht jede Sekunde nach und schreibt bei einer Änderung eine Zeile der Form:
+
+ ```
+ Feature NAVIGATOR_ELYTRA changed release stage at 2026-10-01T00:00:01+02:00[Europe/Berlin]: internal -> lite
+ ```
+
+ Der erste Blick nach einem Start ist kein Wechsel und wird nicht
+ protokolliert — ein Neustart soll keine Stufenwechsel erfinden.
-Der Grund ist das Feld, das später zählt. „Auf premium gehoben" ist keine
+Der Grund ist das Feld, das später zählt. „Auf lite gehoben" ist keine
Begründung; „interne Prüfung ohne Befund über zwei Wochen" ist eine.
## Rücknahmen
diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md
index d9cee2af..e784f321 100644
--- a/docs/spec-lobby-saison-events.md
+++ b/docs/spec-lobby-saison-events.md
@@ -174,15 +174,15 @@ Abschnitt 6a.
| ID | Story | Akzeptanzkriterium (EARS) | Schnittstelle | Priorität | Status |
|---|---|---|---|---|---|
-| US-3.01 | Als Entwickler möchte ich ein Feature zuerst nur intern sehen, damit wir es prüfen können, bevor es jemand anders sieht. | Where ein Feature auf Stufe „intern" steht, shall die Lobby es ausschließlich Spielern mit der Berechtigung `titan.feature.internal` zeigen. | `FeatureGate`, LuckPerms | Must | offen |
-| US-3.02 | Als Betreiber möchte ich ein Feature auf Lite-Spieler ausweiten, damit wir es unter Last prüfen und Lite einen Vorteil hat. | Where ein Feature auf Stufe „lite" steht, shall die Lobby es Spielern der LuckPerms-Gruppe `lite` **und** Spielern mit `titan.feature.internal` zeigen. | `FeatureGate`, LuckPerms-Gruppe `lite` | Must | offen |
-| US-3.03 | Als Betreiber möchte ich ein Feature allgemein freigeben, damit alle es sehen. | Where ein Feature auf Stufe „ga" steht, shall die Lobby es allen Spielern zeigen. | `FeatureGate` | Must | offen |
-| US-3.04 | Als Betreiber möchte ich ein Feature sofort abschalten können, damit ein Fehler nicht bis zum nächsten Deployment sichtbar bleibt. | If der Notausschalter eines Features gesetzt ist, then shall die Lobby es unabhängig von Stufe und Zeitfenster niemandem zeigen. | Togglz-Flag | Must | offen |
-| US-3.05 | Als Betreiber möchte ich, dass die Abschaltung ohne Neustart wirkt, damit die Reaktionszeit kurz ist. | When die Flag-Datei geändert wird, shall die Änderung innerhalb von zwei Sekunden wirksam sein. | `FileBasedStateRepository` | Must | offen |
-| US-3.06 | Als Betreiber möchte ich Freigaben zeitlich planen, damit ein Event ohne Nachtschicht startet. | Where für ein Feature ein Zeitfenster konfiguriert ist, shall die Lobby es nur innerhalb dieses Fensters aktivieren. | eigene `ActivationStrategy` | Must | offen |
-| US-3.07 | Als Entwickler möchte ich, dass die Prüfreihenfolge festgelegt ist, damit das Verhalten vorhersagbar bleibt. | The Freigabeprüfung shall in dieser Reihenfolge auswerten: Notausschalter, dann Berechtigungsstufe, dann Zeitfenster. | `FeatureGate` | Must | offen |
-| US-3.08 | Als Betreiber möchte ich den aktuellen Stand im Spiel abfragen, damit ich nicht ins Log schauen muss. | When ein berechtigtes Teammitglied `/season status` ausführt, shall die Lobby je Feature Stufe, Zeitfenster und Notausschalter-Zustand ausgeben. | Command | Should | offen |
-| US-3.09 | Als Betreiber möchte ich jeden Stufenwechsel dokumentiert haben, damit nachvollziehbar ist, wann was freigegeben wurde. | When ein Feature die Stufe wechselt, shall der Wechsel mit Zeitpunkt, alter und neuer Stufe protokolliert werden. | Log + `docs/rollout-log.md` | Must | offen |
+| US-3.01 | Als Entwickler möchte ich ein Feature zuerst nur intern sehen, damit wir es prüfen können, bevor es jemand anders sieht. | Where ein Feature auf Stufe „intern" steht, shall die Lobby es ausschließlich Spielern mit der Berechtigung `titan.feature.internal` zeigen. | `FeatureGate`, LuckPerms | Must | umgesetzt (Navigator) |
+| US-3.02 | Als Betreiber möchte ich ein Feature auf Lite-Spieler ausweiten, damit wir es unter Last prüfen und Lite einen Vorteil hat. | Where ein Feature auf Stufe „lite" steht, shall die Lobby es Spielern der LuckPerms-Gruppe `lite` **und** Spielern mit `titan.feature.internal` zeigen. | `FeatureGate`, LuckPerms-Gruppe `lite` | Must | umgesetzt (Navigator) |
+| US-3.03 | Als Betreiber möchte ich ein Feature allgemein freigeben, damit alle es sehen. | Where ein Feature auf Stufe „ga" steht, shall die Lobby es allen Spielern zeigen. | `FeatureGate` | Must | umgesetzt (Navigator) |
+| US-3.04 | Als Betreiber möchte ich ein Feature sofort abschalten können, damit ein Fehler nicht bis zum nächsten Deployment sichtbar bleibt. | If der Notausschalter eines Features gesetzt ist, then shall die Lobby es unabhängig von Stufe und Zeitfenster niemandem zeigen. | Togglz-Flag | Must | umgesetzt (Navigator) |
+| US-3.05 | Als Betreiber möchte ich, dass die Abschaltung ohne Neustart wirkt, damit die Reaktionszeit kurz ist. | When die Flag-Datei geändert wird, shall die Änderung innerhalb von zwei Sekunden wirksam sein. | `FileBasedStateRepository` | Must | umgesetzt |
+| US-3.06 | Als Betreiber möchte ich Freigaben zeitlich planen, damit ein Event ohne Nachtschicht startet. | Where für ein Feature ein Zeitfenster konfiguriert ist, shall die Lobby es nur innerhalb dieses Fensters aktivieren. | eigene `ActivationStrategy` | Must | umgesetzt (Navigator) |
+| US-3.07 | Als Entwickler möchte ich, dass die Prüfreihenfolge festgelegt ist, damit das Verhalten vorhersagbar bleibt. | The Freigabeprüfung shall in dieser Reihenfolge auswerten: Notausschalter, dann Berechtigungsstufe, dann Zeitfenster. | `FeatureGate` | Must | umgesetzt |
+| US-3.08 | Als Betreiber möchte ich den aktuellen Stand im Spiel abfragen, damit ich nicht ins Log schauen muss. | When ein berechtigtes Teammitglied `/season status` ausführt, shall die Lobby je Feature Stufe, Zeitfenster und Notausschalter-Zustand ausgeben. | Command | Should | umgesetzt |
+| US-3.09 | Als Betreiber möchte ich jeden Stufenwechsel dokumentiert haben, damit nachvollziehbar ist, wann was freigegeben wurde. | When ein Feature die Stufe wechselt, shall der Wechsel mit Zeitpunkt, alter und neuer Stufe protokolliert werden. | Log + `docs/rollout-log.md` | Must | umgesetzt |
### Stufe 4 — Saison-Pakete
@@ -353,8 +353,8 @@ bekommen den Zeitpunkt übergeben, statt selbst auf die Uhr zu sehen. Die
- [ ] Ein falsch gesetztes `TITAN_LOBBY_MAP` startet die Lobby mit der Standardwelt und protokolliert den gesuchten Namen.
- [ ] Die Tageszeit der Lobby entspricht der Uhrzeit in Berlin, auch über eine Sommerzeitumstellung hinweg.
- [ ] Die Zeitsteuerung ist mit einer festen `Clock` testbar; ein Test prüft Winter im Sommer.
-- [ ] Ein Feature lässt sich nacheinander auf intern, lite und ga stellen, ohne dass Code geändert wird.
-- [ ] Der Notausschalter wirkt innerhalb von zwei Sekunden und schlägt Stufe und Zeitfenster.
+- [x] Ein Feature lässt sich nacheinander auf intern, lite und ga stellen, ohne dass Code geändert wird.
+- [x] Der Notausschalter wirkt innerhalb von zwei Sekunden und schlägt Stufe und Zeitfenster. — *Einschränkung: die Prüfung erfolgt beim Zeichnen des Menüs. Wer den Navigator bereits offen hat, sieht das alte Bild bis zum nächsten Öffnen. Ein abgelehnter Eintrag bekommt keinen Klick-Handler, und `InventoryPreClickEvent` wird global abgebrochen — das Fenster ist also eng, aber vorhanden. Eine Prüfung zur Klickzeit gehört zu `NavigatorEntry` aus Stufe 5.*
- [ ] Ein Spieler ohne `titan.navigator.buildserver` sieht die Build-Server nicht und kann sie auch durch einen manipulierten Klick nicht erreichen.
- [ ] Die Lobby startet ohne Saison-Paket vollständig funktionsfähig.
- [ ] Ein Saison-Paket lässt sich entfernen, ohne dass Reste in der Welt zurückbleiben.