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: + * + *

    + *
  1. 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.
  2. + *
  3. 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}.
  4. + *
  5. time window — evaluated by {@link SeasonWindowActivationStrategy}. A feature with + * no window is always within it.
  6. + *
+ * + *

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: + * + *

+ * + *

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.