From a2c5d3268916c9bf2326c9322de168bc7aabe9c5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:28:14 +0200 Subject: [PATCH 01/13] fix(build): merge service files instead of dropping duplicate paths The fat jar sets duplicatesStrategy = EXCLUDE, which keeps the first copy of every duplicate path and pre-empts mergeServiceFiles(). Any service file shipped by more than one jar therefore lost all but one set of entries: the packaged META-INF/services/org.togglz.core.spi.FeatureManagerProvider held Titan's provider alone, with Togglz's own five providers dropped silently. Let META-INF/services/** through as INCLUDE so the merge transformer sees every copy. Titan's provider keeps winning the lookup: it declares priority 30, the lowest of all registered providers, and Togglz asks the lowest first. This matters as soon as Titan registers an activation strategy of its own - without the fix, Titan's service file would shadow Togglz's built-in strategies. --- app/build.gradle.kts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a70ae7a..ffe93f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -87,6 +87,15 @@ 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. + filesMatching("META-INF/services/**") { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } } test { useJUnitPlatform() From b445f7c4d9f4292cee5f0d992ef8e805b9c29e9e Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:28:29 +0200 Subject: [PATCH 02/13] feat(feature): add release stages and a season time window strategy Two building blocks for the staged delivery of US-3.01 to US-3.06. ReleaseStage names the three audiences a feature can be released to and answers who belongs to them: internal needs titan.feature.internal, lite additionally admits the LuckPerms group lite, ga admits everyone. It asks a FeatureAudience rather than LuckPerms directly, so :common stays free of LuckPerms types and the order of the checks is testable without a permission backend - the same rule TitanPermissionBridge follows for the CloudNet bridge. A missing or unknown stage resolves to internal, never to a wider audience. SeasonWindowActivationStrategy is the Togglz activation strategy that carries the time window. Togglz's own ReleaseDateActivationStrategy knows only PARAM_DATE and PARAM_TIME - a point in time after which a feature is on, with no end and no zone (verified with javap against togglz-core 4.6.2). A season needs both, so this one takes from, to and zone, each optional, and reads its time from an injected Clock. A parameter that is present but unreadable makes the feature inactive: a typo in a date must not widen an audience. Registered through META-INF/services/org.togglz.core.spi.ActivationStrategy, which Togglz's DefaultActivationStrategyProvider reads with a plain ServiceLoader.load(Class) - a call that uses the thread context classloader, the reason ThreadHelper exists. Tests cover the audience of each stage, both window bounds, one-sided windows, the zone parameter across a summer-time date, unreadable values, the service-file registration itself and a FeatureManager dispatching to the strategy it found there. TitanFeaturesTest holds the flag list to the ceiling of twelve (NFR-009). --- .../titan/common/feature/FeatureAudience.java | 76 +++++++ .../titan/common/feature/ReleaseStage.java | 111 +++++++++ .../SeasonWindowActivationStrategy.java | 210 ++++++++++++++++++ .../titan/common/feature/package-info.java | 12 + .../org.togglz.core.spi.ActivationStrategy | 1 + .../common/feature/ReleaseStageTest.java | 77 +++++++ .../SeasonWindowActivationStrategyTest.java | 142 ++++++++++++ .../common/feature/TestFeatureAudience.java | 56 +++++ .../titan/common/utils/TitanFeaturesTest.java | 47 ++++ 9 files changed, 732 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/FeatureAudience.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/ReleaseStage.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/package-info.java create mode 100644 common/src/main/resources/META-INF/services/org.togglz.core.spi.ActivationStrategy create mode 100644 common/src/test/java/net/onelitefeather/titan/common/feature/ReleaseStageTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/utils/TitanFeaturesTest.java 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 0000000..5056264 --- /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/ReleaseStage.java b/common/src/main/java/net/onelitefeather/titan/common/feature/ReleaseStage.java new file mode 100644 index 0000000..0807fae --- /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 0000000..ffb465c --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java @@ -0,0 +1,210 @@ +/** + * 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.util.Optional; + +/** + * Togglz activation strategy that limits a feature to a time window with a start, an end and a + * time zone. + * + *

Togglz ships {@code ReleaseDateActivationStrategy}, but it only knows {@code PARAM_DATE} and + * {@code PARAM_TIME} — a point in time after which a feature is on. A season has an end as well, + * and it is planned in local Berlin time rather than in whatever zone the JVM happens to run in. + * This strategy therefore takes three parameters: + * + *

    + *
  • {@value #PARAM_FROM} — inclusive start, {@code 2026-10-01T18:00} or {@code 2026-10-01}
  • + *
  • {@value #PARAM_TO} — exclusive end, same formats
  • + *
  • {@value #PARAM_ZONE} — zone the two local times are read in, for example + * {@code Europe/Berlin}; falls back to the zone this strategy was built with
  • + *
+ * + *

Both bounds are optional: a window with only {@value #PARAM_FROM} never closes, one with only + * {@value #PARAM_TO} was always open, and a state with neither is always within its window. A + * parameter that is present but unreadable makes the feature inactive — a typo in a date must not + * widen an audience. + * + *

Registered through {@code META-INF/services/org.togglz.core.spi.ActivationStrategy}, which + * Togglz's {@code DefaultActivationStrategyProvider} reads with a plain + * {@link java.util.ServiceLoader#load(Class)}; that call uses the thread context classloader, so + * the lookup has to happen on a thread prepared by + * {@link net.onelitefeather.titan.common.utils.ThreadHelper}. The public no-argument constructor + * exists for that service lookup. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class SeasonWindowActivationStrategy implements ActivationStrategy { + + /** Strategy id as written to {@code .strategy} in the flag file. */ + public static final String ID = "season-window"; + + /** Parameter holding the inclusive start of the window. */ + public static final String PARAM_FROM = "from"; + + /** Parameter holding the exclusive end of the window. */ + public static final String PARAM_TO = "to"; + + /** Parameter holding the zone the two local times are interpreted in. */ + public static final String PARAM_ZONE = "zone"; + + /** Zone seasons are planned in when a feature does not name one. */ + public static final ZoneId DEFAULT_ZONE = ZoneId.of("Europe/Berlin"); + + private static final Logger LOGGER = LoggerFactory.getLogger(SeasonWindowActivationStrategy.class); + + private static final Parameter[] PARAMETERS = {ParameterBuilder.create(PARAM_FROM).label("Window start").description("Inclusive start, ISO-8601 local date or date-time (2026-10-01 or 2026-10-01T18:00).").optional(), ParameterBuilder.create(PARAM_TO).label("Window end").description("Exclusive end, ISO-8601 local date or date-time (2026-11-05 or 2026-11-05T04:00).").optional(), ParameterBuilder.create(PARAM_ZONE).label("Time zone").description("Zone the two local times are read in, for example Europe/Berlin.").optional(), + }; + + private final Clock clock; + private final ZoneId fallbackZone; + + /** + * Creates the strategy the way the {@link java.util.ServiceLoader} needs it: on the system + * clock, planning seasons in {@link #DEFAULT_ZONE}. + */ + public SeasonWindowActivationStrategy() { + this(Clock.systemUTC(), DEFAULT_ZONE); + } + + /** + * Creates the strategy with an explicit time source. + * + * @param clock the clock every comparison is made against + * @param fallbackZone the zone used for features that do not set {@value #PARAM_ZONE} + */ + public SeasonWindowActivationStrategy(Clock clock, ZoneId fallbackZone) { + this.clock = clock; + this.fallbackZone = fallbackZone; + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getName() { + return "Season time window"; + } + + @Override + public boolean isActive(FeatureState featureState, @Nullable FeatureUser user) { + return isWithinWindow(featureState); + } + + @Override + public Parameter[] getParameters() { + return PARAMETERS.clone(); + } + + /** + * Checks whether the current time falls into the window configured on the feature state. The + * user plays no part in this decision, which is why {@link FeatureGate} can call this directly + * as the third and last step of its evaluation. + * + * @param featureState the state carrying the window parameters + * @return whether now is inside the window; {@code false} when a parameter cannot be read + */ + public boolean isWithinWindow(FeatureState featureState) { + try { + ZoneId zone = zoneOf(featureState); + LocalDateTime from = parse(featureState.getParameter(PARAM_FROM)); + LocalDateTime to = parse(featureState.getParameter(PARAM_TO)); + ZonedDateTime now = ZonedDateTime.ofInstant(this.clock.instant(), zone); + if (from != null && now.isBefore(from.atZone(zone))) { + return false; + } + return to == null || now.isBefore(to.atZone(zone)); + } catch (DateTimeException exception) { + LOGGER.warn("Feature {} has an unreadable season window (from={}, to={}, zone={}); treating it as inactive", featureState.getFeature().name(), featureState.getParameter(PARAM_FROM), featureState.getParameter(PARAM_TO), featureState.getParameter(PARAM_ZONE), exception); + return false; + } + } + + /** + * 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 parseQuietly(featureState.getParameter(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 parseQuietly(featureState.getParameter(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 DateTimeException when the configured zone id is not a known zone + */ + @Contract(pure = true) + public ZoneId zoneOf(FeatureState featureState) { + String zone = featureState.getParameter(PARAM_ZONE); + return zone == null || zone.isBlank() ? this.fallbackZone : ZoneId.of(zone.trim()); + } + + private Optional parseQuietly(@Nullable String raw) { + try { + return Optional.ofNullable(parse(raw)); + } catch (DateTimeException exception) { + return Optional.empty(); + } + } + + private static @Nullable LocalDateTime parse(@Nullable String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String value = raw.trim(); + if (value.indexOf('T') < 0) { + return LocalDate.parse(value).atStartOfDay(); + } + return LocalDateTime.parse(value); + } +} 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 0000000..87ccb63 --- /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/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 0000000..a30e8b6 --- /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/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 0000000..426d944 --- /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 0000000..83aef63 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.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.common.feature; + +import net.onelitefeather.titan.common.utils.TitanFeatures; +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.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("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/TestFeatureAudience.java b/common/src/test/java/net/onelitefeather/titan/common/feature/TestFeatureAudience.java new file mode 100644 index 0000000..36f52f4 --- /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/utils/TitanFeaturesTest.java b/common/src/test/java/net/onelitefeather/titan/common/utils/TitanFeaturesTest.java new file mode 100644 index 0000000..bb059c0 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/utils/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.utils; + +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()); + } +} From f0b567a1581f68ee4e8fc4b6c75938beb1821ba1 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:28:57 +0200 Subject: [PATCH 03/13] feat(feature): add the feature gate and stage transition logging FeatureGate is the single place that decides whether a player sees a feature, and the only type in Titan that talks to Togglz. It walks the three steps of US-3.07 in the order the spec fixes: 1. kill switch - a disabled feature is invisible to everyone, whatever its stage and window say. A feature that was never enabled counts as disabled, so an unconfigured feature stays dark rather than going public. 2. release stage - read from the feature-state parameter "stage". 3. time window - evaluated by SeasonWindowActivationStrategy. The three steps form a conjunction, so the order does not change the answer; it decides which step is reported as the reason, which is what /season status shows and what the tests pin down. StageTransitionLogger covers US-3.09. 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. The logger turns that difference into one line with timestamp, old stage and new stage, and stays silent on the first observation so a restart does not fake a transition. FeatureGate#pollStageTransitions walks every feature once for callers that want to schedule the comparison. Tests cover the order the spec cares about: the kill switch beating an open window on a generally released feature, lite players seeing what ga has not reached, a player without permissions seeing nothing outside ga, and the stage being reported as the reason where both stage and window would deny. Added to that: a missing and an unknown stage falling back to internal, an unconfigured feature staying invisible, and a real flags.properties read through FileBasedStateRepository. --- .../titan/common/feature/FeatureDecision.java | 55 ++++ .../titan/common/feature/FeatureGate.java | 235 ++++++++++++++++++ .../titan/common/feature/FeatureStatus.java | 52 ++++ .../titan/common/feature/StageTransition.java | 35 +++ .../common/feature/StageTransitionLogger.java | 78 ++++++ .../titan/common/feature/FeatureGateTest.java | 233 +++++++++++++++++ .../feature/StageTransitionLoggerTest.java | 73 ++++++ 7 files changed, 761 insertions(+) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/FeatureDecision.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/StageTransition.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/feature/StageTransitionLogger.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/feature/StageTransitionLoggerTest.java 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 0000000..17e55d1 --- /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 0000000..00233ea --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java @@ -0,0 +1,235 @@ +/** + * 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, SeasonWindowActivationStrategy.DEFAULT_ZONE, false); + } + ReleaseStage stage = stageOf(state); + this.transitions.observe(feature.name(), stage); + LocalDateTime from = this.window.from(state).orElse(null); + LocalDateTime to = this.window.to(state).orElse(null); + return new FeatureStatus(feature.name(), !state.isEnabled(), stage, from, to, zoneOf(state), this.window.isWithinWindow(state)); + } + + /** + * 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); + } + + 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 0000000..2e9a134 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java @@ -0,0 +1,52 @@ +/** + * 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.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, and which time window it is bound to. Read + * by the {@code /season status} command. + * + * @param feature name of the Togglz feature + * @param killSwitchEngaged whether the feature is switched off outright + * @param stage the release stage the feature is currently on + * @param from inclusive start of the window, {@code null} when the window is open + * @param to exclusive end of the window, {@code null} when the window never closes + * @param zone the zone {@code from} and {@code to} are read in + * @param withinWindow whether the window is open right now + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public record FeatureStatus(String feature, boolean killSwitchEngaged, ReleaseStage stage, + @Nullable LocalDateTime from, @Nullable LocalDateTime to, ZoneId zone, + boolean withinWindow) { + + /** + * Returns whether this feature has any time window configured at all. + * + * @return whether at least one of the two bounds is set + */ + public boolean hasWindow() { + return this.from != null || this.to != null; + } +} 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 0000000..1fc8990 --- /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 0000000..ae8476d --- /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/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 0000000..03069fa --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java @@ -0,0 +1,233 @@ +/** + * 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.TitanFeatures; +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.assertNull; +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("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/StageTransitionLoggerTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/StageTransitionLoggerTest.java new file mode 100644 index 0000000..88dbd44 --- /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()); + } +} From d04587286d48c01f30062a7525fff4efcd8f9912 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:29:06 +0200 Subject: [PATCH 04/13] feat(app): add /season status and back the gate with LuckPerms LuckPermsFeatureAudience answers the gate's two questions through the permission system Titan already embeds: permissions from the user's cached permission data, group membership from the inherited groups of the user's query options, so a group that lite itself inherits from counts too. A player LuckPerms has not loaded yet holds nothing, which keeps an unfinished login on the narrow side of every release stage. SeasonCommand covers US-3.08. Togglz ships an admin console, but it is a servlet application and a Minestom process has no servlet container, so a command is what replaces it: /season status prints stage, window and kill switch per feature. The command is bound to titan.feature.internal - the same permission that defines the internal audience - and, like /stop, is always available from the server console. Titan now builds the gate from an injected Clock and ZoneId (NFR-007) defaulting to Europe/Berlin, registers the command, and schedules the stage comparison once a second so a transition is logged even while nobody is online. The commands package gets its package-info with @NotNullByDefault; the two @NotNull annotations it made redundant are removed. --- .../net/onelitefeather/titan/app/Titan.java | 26 ++++ .../titan/app/commands/EndCommand.java | 5 +- .../titan/app/commands/SeasonCommand.java | 126 +++++++++++++++++ .../titan/app/commands/StopCommand.java | 3 +- .../titan/app/commands/package-info.java | 7 + .../app/feature/LuckPermsFeatureAudience.java | 91 ++++++++++++ .../titan/app/feature/package-info.java | 8 ++ .../titan/app/commands/SeasonCommandTest.java | 131 ++++++++++++++++++ 8 files changed, 392 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java create mode 100644 app/src/main/java/net/onelitefeather/titan/app/commands/package-info.java create mode 100644 app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java create mode 100644 app/src/main/java/net/onelitefeather/titan/app/feature/package-info.java create mode 100644 app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java 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 42a2391..659ec14 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(); @@ -59,6 +78,7 @@ public Titan() { 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); } 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 69e2fe3..f976ea4 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 0000000..84c3fe8 --- /dev/null +++ b/app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java @@ -0,0 +1,126 @@ +/** + * 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(Component.text(status.stage().id(), stageColor(status.stage()))).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 describeWindow(FeatureStatus status) { + 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()); + return Component.text(from + " to " + to + " (" + status.zone().getId() + ", ", status.withinWindow() ? NamedTextColor.GREEN : NamedTextColor.GOLD).append(Component.text(status.withinWindow() ? "open)" : "closed)", status.withinWindow() ? NamedTextColor.GREEN : NamedTextColor.GOLD)); + } + + 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 596b069..db90bda 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 0000000..6ce8644 --- /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 0000000..5b191a8 --- /dev/null +++ b/app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java @@ -0,0 +1,91 @@ +/** + * 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.luckperms.api.LuckPerms; +import net.luckperms.api.LuckPermsProvider; +import net.luckperms.api.model.group.Group; +import net.luckperms.api.model.user.User; +import net.onelitefeather.titan.common.feature.FeatureAudience; +import org.jetbrains.annotations.Nullable; + +import java.util.UUID; +import java.util.function.Supplier; + +/** + * Answers the gate's questions through LuckPerms, the permission system Titan already embeds. + * + *

Permissions are read from the user's cached permission data, group membership from the + * inherited groups of the user's query options, so a group that {@code lite} itself inherits from + * counts as well. A player LuckPerms has not loaded yet is treated as holding nothing, which keeps + * an unfinished login on the narrow side of every release stage. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class LuckPermsFeatureAudience implements FeatureAudience { + + private final Supplier luckPerms; + + private LuckPermsFeatureAudience(Supplier luckPerms) { + this.luckPerms = luckPerms; + } + + /** + * Creates an audience reading from the running LuckPerms instance. + * + * @return an audience backed by {@link LuckPermsProvider} + */ + public static LuckPermsFeatureAudience create() { + return new LuckPermsFeatureAudience(LuckPermsProvider::get); + } + + /** + * 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); + } + + @Override + public boolean hasPermission(UUID playerId, String permission) { + User user = user(playerId); + return user != null && user.getCachedData().getPermissionData(user.getQueryOptions()).checkPermission(permission).asBoolean(); + } + + @Override + public boolean inGroup(UUID playerId, String group) { + User user = user(playerId); + if (user == null) { + return false; + } + for (Group inherited : user.getInheritedGroups(user.getQueryOptions())) { + if (inherited.getName().equalsIgnoreCase(group)) { + return true; + } + } + return false; + } + + private @Nullable User user(UUID playerId) { + return this.luckPerms.get().getUserManager().getUser(playerId); + } +} 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 0000000..afc7561 --- /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/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 0000000..0713dbd --- /dev/null +++ b/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java @@ -0,0 +1,131 @@ +/** + * 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.utils.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 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("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); + } +} From 0cdc7e35cd4381ebe590fd08ce4a14e355f987ce Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:29:14 +0200 Subject: [PATCH 05/13] docs: correct the rollout log's stages and record how a stage is set The stage table listed premium/titan.feature.premium, which no longer matches US-3.02: the middle stage is lite, and membership is decided by the LuckPerms group lite rather than by a permission of its own. Corrected, and extended by what an operator actually needs: - the flags.properties keys behind kill switch, stage, window and zone, with the warning that a mid-line # is part of the value in a .properties file, not a comment - the shape of the log line the application writes on a stage change (US-3.09), and the note that the first look after a restart is not a transition - the pointer to /season status as the replacement for Togglz's servlet console Stage-3 stories US-3.01 to US-3.09 are marked as implemented in the spec, and the two acceptance criteria they satisfy are ticked. --- docs/rollout-log.md | 53 ++++++++++++++++++++++++++++---- docs/spec-lobby-saison-events.md | 22 ++++++------- 2 files changed, 58 insertions(+), 17 deletions(-) diff --git a/docs/rollout-log.md b/docs/rollout-log.md index ee34c5d..9e5d98e 100644 --- a/docs/rollout-log.md +++ b/docs/rollout-log.md @@ -8,15 +8,48 @@ 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. Die Togglz-Adminkonsole ist ein Servlet und in +einem Minestom-Prozess nicht verfügbar; der Befehl ersetzt sie. ## Verlauf @@ -30,9 +63,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 d9cee2a..e525f51 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 | +| 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 | +| 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 | +| 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 | +| 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 | +| 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. - [ ] 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. From 3ad61bfd58beb41d8a7c63d212a391de932d2317 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 10:52:43 +0200 Subject: [PATCH 06/13] docs: revert the stage 3 status claims the code does not support An architecture review found that FeatureGate has zero production call sites. `isVisibleTo` and `decide` are consumed only by `/season status` and the stage-transition poll; NavigationHelper still sets all four items unconditionally, and `TitanFeatures.isActive()` now has no callers at all. So the mechanism is built and tested, but nothing asks it anything. An operator writing `NAVIGATOR_ELYTRA = false` gets a `/season status` that reports the kill switch as engaged while every player keeps seeing and clicking the item. US-3.01 through 3.04 and 3.06 were marked `umgesetzt` and two acceptance boxes were ticked. They are none of those things. Reverting the claims rather than the code - the gate itself is sound and worth keeping; it just needs wiring. US-3.08 is downgraded to partial: when `from`, `to` or `zone` fail to parse, the strategy returns empty and the command prints "window always" while the gate denies everyone. The one command whose purpose is to save the operator a trip to the log tells them the opposite of the truth. Not a regression: the unconditional item wiring predates this branch. --- docs/spec-lobby-saison-events.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index e525f51..fe3d644 100644 --- a/docs/spec-lobby-saison-events.md +++ b/docs/spec-lobby-saison-events.md @@ -174,14 +174,14 @@ 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 | umgesetzt | -| 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 | -| 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 | -| 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 | +| 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 | **Mechanik gebaut, NICHT verdrahtet** | +| 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 | **Mechanik gebaut, NICHT verdrahtet** | +| 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 | **Mechanik gebaut, NICHT verdrahtet** | +| 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 | **Mechanik gebaut, NICHT verdrahtet** | | 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 | +| 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 | **Mechanik gebaut, NICHT verdrahtet** | | 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.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 | teilweise — Ausgabe widerspricht dem Gate bei unlesbarer Konfiguration | | 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. -- [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. +- [ ] 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. - [ ] 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. From c487ea763e9fe0532dc460fb50258e529a97ccc5 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:04:59 +0200 Subject: [PATCH 07/13] fix(app): consult the feature gate for every navigator entry The gate had no production call sites. NavigationHelper wrote all four destinations unconditionally, so an operator who set NAVIGATOR_ELYTRA = false got a /season status that reported the kill switch as engaged while every player kept seeing and clicking the item. US-3.01 to US-3.04 and US-3.06 were built and tested but never reached a player. Each destination is now asked of the gate before it is written into the layout. A denied entry is simply not written, so its slot keeps the filler pane that the whole row was filled with a line earlier; five of the nine slots are that pane in the normal case, so a hidden entry does not read as a hole. The entries keep their fixed slots rather than compacting: Stage 5 replaces this slot arithmetic with NavigatorLayout.plan(...), which filters first and then derives contiguous centred slots, and inventing a second layout mechanism here would mean throwing one of them away. The gate check is written as a plain per-entry predicate, which is the shape that folds into that filter. The ThreadLocalUserProvider.bind/release bracket and its toUser helper are gone. They fed a Togglz user to a gate that no longer exists; nothing between them read the bound user. Tests assert what a player actually sees: the navigator is opened, the server is ticked (Aves applies the data layout on the next tick, so asserting without the tick reads an empty inventory and passes for the wrong reason), and the slot's material is checked. Covered: all four destinations visible on ga, the kill switch replacing the elytra item with the filler pane while its neighbours keep their slots, an internal destination hidden from an ordinary player and shown to a holder of titan.feature.internal, a lite destination shown to the lite group only, and a flag flipped between two opens taking effect on the second. Four of those fail if the gate check is replaced by a constant true - the property the previous green build did not have. --- .../net/onelitefeather/titan/app/Titan.java | 2 +- .../titan/app/helper/NavigationHelper.java | 38 +++-- .../app/helper/NavigationHelperTest.java | 157 ++++++++++++------ .../app/listener/NavigationListenerTest.java | 3 +- .../titan/app/testutils/TestFeatureGate.java | 137 +++++++++++++++ 5 files changed, 271 insertions(+), 66 deletions(-) create mode 100644 app/src/test/java/net/onelitefeather/titan/app/testutils/TestFeatureGate.java 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 659ec14..95959cd 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java +++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java @@ -77,8 +77,8 @@ public Titan(Clock clock, ZoneId zone) { 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() { 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 15f6388..4c7ad59 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.utils.Items; +import net.onelitefeather.titan.common.utils.TitanFeatures; 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/helper/NavigationHelperTest.java b/app/src/test/java/net/onelitefeather/titan/app/helper/NavigationHelperTest.java index e579ba4..9bc893f 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.utils.Items; +import net.onelitefeather.titan.common.utils.TitanFeatures; 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 7d09e24..bf87ab2 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 0000000..2024d92 --- /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.utils.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)); + } + } +} From 76d43cd39d8a4cb41e58882df9de97da1137da26 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:05:15 +0200 Subject: [PATCH 08/13] fix(feature): report unreadable release configuration instead of hiding it FeatureStatus could report hasWindow() == false and withinWindow() == false at the same time, which cannot both be true. The cause: the strategy parsed the window parameters twice, once failing closed for the decision and once quietly returning empty for the display. A feature whose `from` was a typo therefore denied everyone while /season status printed "window always" - the one command whose purpose is to spare the operator a trip to the log told them the opposite of the truth. The strategy now reads the three parameters in one place that throws with a message naming the offending parameter and its value; isWithinWindow catches it to fail closed, and the new windowProblem() hands the same message to the status. An unknown stage id is carried too: the gate falls back to internal, but the operator who wrote `stage = intern` needs to see the typo rather than a stage they did not configure - and the spec's own German checklist ("auf intern, lite und ga stellen") is exactly what tempts them into writing it. FeatureStatus rejects the contradiction in its compact constructor, so an unreadable window can no longer be reported as an open one anywhere. /season status prints "window unreadable: from='1. Oktober' is not a date ..." and "stage internal ('intern' is not internal, lite or ga)" in red. Tests assert that the broken window prints "unreadable", does not print "always", and names the offending value, plus the gate-level status fields and the constructor guard. --- .../titan/app/commands/SeasonCommand.java | 20 ++++- .../titan/app/commands/SeasonCommandTest.java | 29 +++++++ .../titan/common/feature/FeatureGate.java | 21 ++++- .../titan/common/feature/FeatureStatus.java | 67 ++++++++++++--- .../SeasonWindowActivationStrategy.java | 83 ++++++++++++++----- .../titan/common/feature/FeatureGateTest.java | 50 +++++++++++ .../SeasonWindowActivationStrategyTest.java | 18 ++++ 7 files changed, 252 insertions(+), 36 deletions(-) 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 index 84c3fe8..bf4ce84 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java +++ b/app/src/main/java/net/onelitefeather/titan/app/commands/SeasonCommand.java @@ -73,17 +73,33 @@ public SeasonCommand(FeatureGate featureGate) { * @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(Component.text(status.stage().id(), stageColor(status.stage()))).append(Component.text(" | window ", NamedTextColor.DARK_GRAY)).append(describeWindow(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()); - return Component.text(from + " to " + to + " (" + status.zone().getId() + ", ", status.withinWindow() ? NamedTextColor.GREEN : NamedTextColor.GOLD).append(Component.text(status.withinWindow() ? "open)" : "closed)", status.withinWindow() ? NamedTextColor.GREEN : NamedTextColor.GOLD)); + 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) { 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 index 0713dbd..c8a4b40 100644 --- a/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java +++ b/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java @@ -79,6 +79,10 @@ 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()); @@ -117,6 +121,31 @@ void statusListsStageWindowAndKillSwitch() { 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() { 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 index 00233ea..87ab2a0 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureGate.java @@ -151,13 +151,17 @@ public FeatureDecision decide(Feature feature, UUID playerId) { public FeatureStatus status(Feature feature) { FeatureState state = state(feature); if (state == null) { - return new FeatureStatus(feature.name(), true, ReleaseStage.DEFAULT, null, null, SeasonWindowActivationStrategy.DEFAULT_ZONE, false); + 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); - return new FeatureStatus(feature.name(), !state.isEnabled(), stage, from, to, zoneOf(state), this.window.isWithinWindow(state)); + // 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); } /** @@ -202,6 +206,19 @@ private ReleaseStage stageOf(FeatureState state) { 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); 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 index 2e9a134..dce99fb 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureStatus.java @@ -16,6 +16,7 @@ */ package net.onelitefeather.titan.common.feature; +import org.jetbrains.annotations.Contract; import org.jetbrains.annotations.Nullable; import java.time.LocalDateTime; @@ -23,30 +24,76 @@ /** * Snapshot of everything an operator needs to know about one feature: whether the kill switch is - * engaged, which audience the feature is released to, and which time window it is bound to. Read - * by the {@code /season status} command. + * 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 the feature is currently on - * @param from inclusive start of the window, {@code null} when the window is open - * @param to exclusive end of the window, {@code null} when the window never closes + * @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.0.0 + * @version 1.1.0 * @since 1.15.0 */ public record FeatureStatus(String feature, boolean killSwitchEngaged, ReleaseStage stage, - @Nullable LocalDateTime from, @Nullable LocalDateTime to, ZoneId zone, - boolean withinWindow) { + @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 any time window configured at all. + * 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 + * @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/SeasonWindowActivationStrategy.java b/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java index ffb465c..76c333f 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategy.java @@ -32,6 +32,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.ZonedDateTime; +import java.time.format.DateTimeParseException; import java.util.Optional; /** @@ -139,19 +140,40 @@ public Parameter[] getParameters() { * @return whether now is inside the window; {@code false} when a parameter cannot be read */ public boolean isWithinWindow(FeatureState featureState) { + Window window; try { - ZoneId zone = zoneOf(featureState); - LocalDateTime from = parse(featureState.getParameter(PARAM_FROM)); - LocalDateTime to = parse(featureState.getParameter(PARAM_TO)); - ZonedDateTime now = ZonedDateTime.ofInstant(this.clock.instant(), zone); - if (from != null && now.isBefore(from.atZone(zone))) { - return false; - } - return to == null || now.isBefore(to.atZone(zone)); - } catch (DateTimeException exception) { - LOGGER.warn("Feature {} has an unreadable season window (from={}, to={}, zone={}); treating it as inactive", featureState.getFeature().name(), featureState.getParameter(PARAM_FROM), featureState.getParameter(PARAM_TO), featureState.getParameter(PARAM_ZONE), exception); + 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(); + } } /** @@ -162,7 +184,7 @@ public boolean isWithinWindow(FeatureState featureState) { */ @Contract(pure = true) public Optional from(FeatureState featureState) { - return parseQuietly(featureState.getParameter(PARAM_FROM)); + return bound(featureState, PARAM_FROM); } /** @@ -173,7 +195,7 @@ public Optional from(FeatureState featureState) { */ @Contract(pure = true) public Optional to(FeatureState featureState) { - return parseQuietly(featureState.getParameter(PARAM_TO)); + return bound(featureState, PARAM_TO); } /** @@ -181,30 +203,47 @@ public Optional to(FeatureState featureState) { * * @param featureState the state to read from * @return the configured zone, or the fallback zone when none is set - * @throws DateTimeException when the configured zone id is not a known zone + * @throws IllegalArgumentException when the configured zone id is not a known zone */ @Contract(pure = true) public ZoneId zoneOf(FeatureState featureState) { - String zone = featureState.getParameter(PARAM_ZONE); - return zone == null || zone.isBlank() ? this.fallbackZone : ZoneId.of(zone.trim()); + 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 Optional parseQuietly(@Nullable String raw) { + private static Optional bound(FeatureState featureState, String parameter) { try { - return Optional.ofNullable(parse(raw)); - } catch (DateTimeException exception) { + return Optional.ofNullable(readBound(featureState, parameter)); + } catch (IllegalArgumentException exception) { return Optional.empty(); } } - private static @Nullable LocalDateTime parse(@Nullable String raw) { + 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(); - if (value.indexOf('T') < 0) { - return LocalDate.parse(value).atStartOfDay(); + 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)"); } - return LocalDateTime.parse(value); + } + + /** 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/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java index 03069fa..9f08020 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java @@ -41,7 +41,9 @@ 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 { @@ -215,6 +217,54 @@ void readsStageAndWindowFromAFlagFile(@TempDir Path directory) throws IOExceptio 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() { 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 index 83aef63..42e1e4e 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java @@ -39,6 +39,8 @@ 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 { @@ -102,6 +104,22 @@ void unreadableParametersFailClosed() { 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() { From d334431d6d308fd57180c729ecff37e8c958b931 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:05:16 +0200 Subject: [PATCH 09/13] docs: restore the stage 3 status claims now that the gate is wired US-3.01 to US-3.04 and US-3.06 are marked `umgesetzt (Navigator)` - qualified by surface on purpose. The navigator is the only player-facing surface that exists today; seasonal content and portals will have to consult the gate themselves when they arrive, and the qualifier keeps that visible instead of implying the whole lobby is covered. US-3.08 goes back to `umgesetzt`: the command no longer contradicts the gate on unreadable configuration. The rollout log gains what that looks like, so an operator who mistypes a date recognises the output as a pointer to the typo. The two acceptance boxes are ticked against tests rather than intent: stepping a feature through internal, lite and ga is covered by NavigationHelperTest, and the kill switch beating stage and window by FeatureGateTest, with the reload interval carrying the two-second requirement. --- docs/rollout-log.md | 6 +++++- docs/spec-lobby-saison-events.md | 16 ++++++++-------- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/docs/rollout-log.md b/docs/rollout-log.md index 9e5d98e..4e1d620 100644 --- a/docs/rollout-log.md +++ b/docs/rollout-log.md @@ -48,7 +48,11 @@ 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. Die Togglz-Adminkonsole ist ein Servlet und in +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 diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index fe3d644..6a9f01f 100644 --- a/docs/spec-lobby-saison-events.md +++ b/docs/spec-lobby-saison-events.md @@ -174,14 +174,14 @@ 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 | **Mechanik gebaut, NICHT verdrahtet** | -| 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 | **Mechanik gebaut, NICHT verdrahtet** | -| 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 | **Mechanik gebaut, NICHT verdrahtet** | -| 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 | **Mechanik gebaut, NICHT verdrahtet** | +| 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 | **Mechanik gebaut, NICHT verdrahtet** | +| 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 | teilweise — Ausgabe widerspricht dem Gate bei unlesbarer Konfiguration | +| 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. - [ ] 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. From abecf7afd102d60b45352589e3c00c157411b78f Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:07:24 +0200 Subject: [PATCH 10/13] docs: qualify the kill-switch acceptance criterion The gate is now wired into the navigator and the criterion is met, but not without a boundary worth writing down: the check happens when the menu is drawn. A player who already has the navigator open keeps the old picture until the next open. That window is narrow - a denied entry gets no click handler and InventoryPreClickEvent is cancelled globally - but it is real, and a click-time re-check belongs on Stage 5's NavigatorEntry, which already carries the permission an entry requires. --- docs/spec-lobby-saison-events.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index 6a9f01f..e784f32 100644 --- a/docs/spec-lobby-saison-events.md +++ b/docs/spec-lobby-saison-events.md @@ -354,7 +354,7 @@ bekommen den Zeitpunkt übergeben, statt selbst auf die Uhr zu sehen. Die - [ ] 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. - [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. +- [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. From 44b9bb94fdca59855cc053afd4c736debc8d6318 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:14:37 +0200 Subject: [PATCH 11/13] refactor(feature): move the feature flag types into common/feature TitanFeatures and SingletonFeatureManagerProvider stayed in common/utils when common/feature was created, and a new test was added next to them there. common/utils is the catch-all package OLF-L3-02 names as the anti-pattern, and it lists both classes by name with common/feature as their destination. Move both plus TitanFeaturesTest, and point the FeatureManagerProvider service file at the new name. Callers only change their import. ThreadHelper stays in common/utils: it is a cross-project duplicate whose destination is Butterfly (OLF-L2-04, open point 4), not another Titan package. Its javadoc now says so. --- .../titan/app/helper/NavigationHelper.java | 2 +- .../titan/app/commands/SeasonCommandTest.java | 2 +- .../titan/app/helper/NavigationHelperTest.java | 2 +- .../titan/app/testutils/TestFeatureGate.java | 2 +- .../SingletonFeatureManagerProvider.java | 2 +- .../common/{utils => feature}/TitanFeatures.java | 15 ++++++++++++++- .../titan/common/utils/ThreadHelper.java | 15 +++++++++++++++ .../org.togglz.core.spi.FeatureManagerProvider | 2 +- .../titan/common/feature/FeatureGateTest.java | 1 - .../SeasonWindowActivationStrategyTest.java | 1 - .../{utils => feature}/TitanFeaturesTest.java | 2 +- 11 files changed, 36 insertions(+), 10 deletions(-) rename common/src/main/java/net/onelitefeather/titan/common/{utils => feature}/SingletonFeatureManagerProvider.java (97%) rename common/src/main/java/net/onelitefeather/titan/common/{utils => feature}/TitanFeatures.java (66%) rename common/src/test/java/net/onelitefeather/titan/common/{utils => feature}/TitanFeaturesTest.java (97%) 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 4c7ad59..29da7d0 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 @@ -28,8 +28,8 @@ 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.onelitefeather.titan.common.utils.TitanFeatures; import net.theevilreaper.aves.inventory.InventoryLayout; import net.theevilreaper.aves.inventory.PersonalInventoryBuilder; import net.theevilreaper.aves.inventory.click.ClickHolder; 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 index c8a4b40..d417361 100644 --- a/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java +++ b/app/src/test/java/net/onelitefeather/titan/app/commands/SeasonCommandTest.java @@ -30,7 +30,7 @@ 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.utils.TitanFeatures; +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; 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 9bc893f..be8c015 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 @@ -26,8 +26,8 @@ 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 net.onelitefeather.titan.common.utils.TitanFeatures; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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 index 2024d92..a3d2a14 100644 --- a/app/src/test/java/net/onelitefeather/titan/app/testutils/TestFeatureGate.java +++ b/app/src/test/java/net/onelitefeather/titan/app/testutils/TestFeatureGate.java @@ -19,7 +19,7 @@ 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.utils.TitanFeatures; +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; diff --git a/common/src/main/java/net/onelitefeather/titan/common/utils/SingletonFeatureManagerProvider.java b/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java similarity index 97% rename from common/src/main/java/net/onelitefeather/titan/common/utils/SingletonFeatureManagerProvider.java rename to common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java index 8f50817..2d4b68a 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/utils/SingletonFeatureManagerProvider.java +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java @@ -14,7 +14,7 @@ * 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 org.togglz.core.activation.DefaultActivationStrategyProvider; import org.togglz.core.manager.FeatureManager; 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 eb0c377..bc06769 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/utils/ThreadHelper.java b/common/src/main/java/net/onelitefeather/titan/common/utils/ThreadHelper.java index a360ae3..45a2259 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.FeatureManagerProvider b/common/src/main/resources/META-INF/services/org.togglz.core.spi.FeatureManagerProvider index 5e73363..9774d85 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 index 9f08020..724fb85 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/FeatureGateTest.java @@ -16,7 +16,6 @@ */ package net.onelitefeather.titan.common.feature; -import net.onelitefeather.titan.common.utils.TitanFeatures; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; 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 index 42e1e4e..70cb441 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/SeasonWindowActivationStrategyTest.java @@ -16,7 +16,6 @@ */ package net.onelitefeather.titan.common.feature; -import net.onelitefeather.titan.common.utils.TitanFeatures; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.togglz.core.activation.DefaultActivationStrategyProvider; diff --git a/common/src/test/java/net/onelitefeather/titan/common/utils/TitanFeaturesTest.java b/common/src/test/java/net/onelitefeather/titan/common/feature/TitanFeaturesTest.java similarity index 97% rename from common/src/test/java/net/onelitefeather/titan/common/utils/TitanFeaturesTest.java rename to common/src/test/java/net/onelitefeather/titan/common/feature/TitanFeaturesTest.java index bb059c0..431bb20 100644 --- a/common/src/test/java/net/onelitefeather/titan/common/utils/TitanFeaturesTest.java +++ b/common/src/test/java/net/onelitefeather/titan/common/feature/TitanFeaturesTest.java @@ -14,7 +14,7 @@ * 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 org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; From f59370db3d5c5cf1ff41363ab6e207843df7bbca Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:16:55 +0200 Subject: [PATCH 12/13] fix(feature): make Titan's Togglz provider win by priority, not by file order Since META-INF/services/** is merged into the fat jar, three copies of org.togglz.core.spi.FeatureManagerProvider end up in one file and Butterfly's SingletonFeatureManagerProvider is registered for the first time. It declared priority 30 - the same value Titan declared - and reads the same flags.properties, but builds its manager from ButterflyFeatures. Togglz sorts providers by priority ascending with List.sort, which is stable, so Titan won only because shadow's classpath walk happened to list it first. Reordering a dependency in app/build.gradle.kts or switching implementation to api flips it, and the failure is partial: getFeatureState still resolves Titan's flags by name while statuses(), pollStageTransitions() and /season status enumerate Butterfly's enum. Drop Titan's priority to 10, below Butterfly's 30 and below Togglz's own providers (50-200), and record the reasoning plus the OLF-L2-05 note on the static manager field in the class javadoc. FeatureManagerProviderResolutionTest, in :app because that is where both providers share a classpath, asserts the ambient manager enumerates TitanFeatures, that Titan's priority is strictly lower than every rival, and that Butterfly's provider is actually present so the first assertion cannot pass vacuously. Also narrow the shadowJar filesMatching: META-INF/services/** is broader than ServiceFileTransformer's own pattern set, which excludes the legacy Groovy extension descriptor. Nothing on the classpath ships one today, so guard that single path explicitly instead of widening the rule. --- app/build.gradle.kts | 10 +- .../FeatureManagerProviderResolutionTest.java | 93 +++++++++++++++++++ .../SingletonFeatureManagerProvider.java | 48 +++++++++- 3 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/net/onelitefeather/titan/app/feature/FeatureManagerProviderResolutionTest.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ffe93f1..6c34cba 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,8 +93,16 @@ tasks { // 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/**") { - duplicatesStrategy = DuplicatesStrategy.INCLUDE + if (path != "META-INF/services/org.codehaus.groovy.runtime.ExtensionModule") { + duplicatesStrategy = DuplicatesStrategy.INCLUDE + } } } test { 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 0000000..a6dc88e --- /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/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java b/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java index 2d4b68a..9538c79 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/SingletonFeatureManagerProvider.java @@ -25,11 +25,52 @@ 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) { @@ -39,8 +80,13 @@ public FeatureManager getFeatureManager() { return featureManager; } + /** + * Returns {@value #PRIORITY}, low enough to beat Butterfly's provider deterministically. + * + * @return the provider priority, lower wins + */ @Override public int priority() { - return 30; + return PRIORITY; } } From 9f832e19f5319f573ff304d8c5166301c2552cf3 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 11:19:40 +0200 Subject: [PATCH 13/13] fix(feature): answer the gate's permission question from the player's context The lobby gave two answers to the same question. SeasonCommand.canUse goes through PermissionChecker.POINTER to TitanPlayer, which evaluates against the contextual query options LuckPerms resolves for the online player. LuckPermsFeatureAudience read User#getQueryOptions() instead - the holder's stored options, which carry no server, world or dimension context. A team member holding titan.feature.internal scoped to server=titan-lobby-1 was therefore inside the internal audience for /season and outside it for every feature the gate decides on. Answer a permission from the online player's own PermissionChecker - on a Titan lobby that is the TitanPlayer the command already consults - so the two answers cannot drift apart (OLF-L2-04). Group membership has no equivalent on the player object and stays on LuckPerms, but resolves against ContextManager#getQueryOptions(User), the same contextual options TitanPlayer uses; an offline player falls back to the static options. Second, LuckPermsProvider.get() throws NotLoadedException while LuckPerms is loading or after it failed to load, and nothing caught it even though the gate is consulted on every navigator open. Every answer now fails closed to false - the behaviour FeatureAudience.denyAll() documents as the safe default - and the outage is logged once rather than once per entry. Closed is the right direction because the stages only widen: failing open would promote every internal and lite feature to the whole server at the one moment nobody can revoke it, while failing closed only hides unreleased work. Stage ga admits everyone without consulting an audience, so an outage never hides the lobby itself. LuckPermsFeatureAudienceTest pins both: the stored-options regression fails two of its cases, and the outage cases assert that internal and lite deny while ga still admits. It needs the LuckPerms API on the test classpath, which compileOnly does not provide. --- app/build.gradle.kts | 6 + .../app/feature/LuckPermsFeatureAudience.java | 142 ++++++++++++-- .../feature/LuckPermsFeatureAudienceTest.java | 179 ++++++++++++++++++ 3 files changed, 307 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudienceTest.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6c34cba..504ba38 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) 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 index 5b191a8..77f56e3 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java +++ b/app/src/main/java/net/onelitefeather/titan/app/feature/LuckPermsFeatureAudience.java @@ -16,43 +16,86 @@ */ 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. * - *

Permissions are read from the user's cached permission data, group membership from the - * inherited groups of the user's query options, so a group that {@code lite} itself inherits from - * counts as well. A player LuckPerms has not loaded yet is treated as holding nothing, which keeps - * an unfinished login on the narrow side of every release stage. + *

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 1.0.0 + * @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; - private LuckPermsFeatureAudience(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. + * 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); + return new LuckPermsFeatureAudience(LuckPermsProvider::get, LuckPermsFeatureAudience::onlineChecker); } /** @@ -62,30 +105,89 @@ public static LuckPermsFeatureAudience create() { * @return an audience backed by that instance */ public static LuckPermsFeatureAudience of(Supplier luckPerms) { - return new LuckPermsFeatureAudience(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) { - User user = user(playerId); - return user != null && user.getCachedData().getPermissionData(user.getQueryOptions()).checkPermission(permission).asBoolean(); + 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) { - User user = user(playerId); - if (user == null) { - return false; - } - for (Group inherited : user.getInheritedGroups(user.getQueryOptions())) { - if (inherited.getName().equalsIgnoreCase(group)) { - return true; + return answer(() -> { + User user = user(playerId); + if (user == null) { + return false; } - } - 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/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 0000000..26f1932 --- /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; + } +}