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 ba896a4..460c901 100644 --- a/app/src/main/java/net/onelitefeather/titan/app/Titan.java +++ b/app/src/main/java/net/onelitefeather/titan/app/Titan.java @@ -46,7 +46,13 @@ import net.onelitefeather.titan.common.event.EntityDismountEvent; import net.onelitefeather.titan.common.helper.BlockHandlerHelper; import net.onelitefeather.titan.common.map.MapProvider; +import net.onelitefeather.titan.common.season.MinestomSeasonCanvas; +import net.onelitefeather.titan.common.season.SeasonCanvas; +import net.onelitefeather.titan.common.season.SeasonDirector; +import net.onelitefeather.titan.common.season.SeasonLoader; import net.onelitefeather.titan.common.utils.Cancelable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.file.Path; import java.time.Clock; @@ -54,6 +60,8 @@ public final class Titan { + private static final Logger LOGGER = LoggerFactory.getLogger(Titan.class); + private final Path path; private final EventNode eventNode = EventNode.all("titan"); private final Deliver deliver; @@ -61,6 +69,8 @@ public final class Titan { private final AppConfigProvider appConfigProvider; private final NavigationHelper navigationHelper; private final FeatureGate featureGate; + private final SeasonDirector seasons; + private final SeasonCanvas seasonCanvas; public Titan() { this(Clock.system(SeasonWindowActivationStrategy.DEFAULT_ZONE), SeasonWindowActivationStrategy.DEFAULT_ZONE); @@ -90,8 +100,14 @@ public Titan(Clock clock, ZoneId zone) { // The gate is built first: the navigator asks it whether a destination is released at all // before the build server permission narrows the list any further. this.featureGate = FeatureGate.create(audience, clock, zone); + // Seasons are files, not code: whatever is in seasons/ is what the lobby can run. A missing + // directory is a lobby with no seasonal content and is not an error (NFR-003), while a file + // that cannot be read stops the boot with the file and the value named - a season is looked + // at once a year, and a typo tolerated here is one nobody finds until it is live. + this.seasons = SeasonDirector.load(this.featureGate, this.path.resolve(SeasonLoader.DIRECTORY), zone); + this.seasonCanvas = MinestomSeasonCanvas.of(instance); this.navigationHelper = NavigationHelper.instance( - this.deliver, audience, this.featureGate, TitanBuildServerDirectory::reachableServices, buildServerAccess); + this.deliver, audience, this.featureGate, TitanBuildServerDirectory::reachableServices, buildServerAccess, this.seasons); } public void initialize() { @@ -104,18 +120,28 @@ public void initialize() { // 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)); + // Put the seasons that are live into the world now, and look again every five seconds so a + // window that opens or a kill switch that is thrown takes effect without a restart + // (NFR-004). synchronize() does nothing when the live set has not changed. + this.seasons.synchronize(this.seasonCanvas); + MinecraftServer.getSchedulerManager().scheduleTask( + () -> this.seasons.synchronize(this.seasonCanvas), TaskSchedule.seconds(5), TaskSchedule.seconds(5)); + this.seasons.world().ifPresent(world -> LOGGER.info( + "The winning season asks for the world '{}'; world selection is wired in spec stage 1", world)); MinecraftServer.getSchedulerManager().buildShutdownTask(this::terminate); MinecraftServer.getSchedulerManager().buildShutdownTask(butterfly::terminate); } public void terminate() { - + // A lobby that stops mid-season must not leave its decoration in the world files: the same + // undo the end of a season runs, run once more on the way out (US-4.02). + this.seasons.deactivateAll(); } private void initCommands() { MinecraftServer.getCommandManager().register(new EndCommand()); MinecraftServer.getCommandManager().register(new StopCommand()); - MinecraftServer.getCommandManager().register(new SeasonCommand(this.featureGate)); + MinecraftServer.getCommandManager().register(new SeasonCommand(this.featureGate, this.seasons)); } private void initListeners() { 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 bf4ce84..ad556c9 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 @@ -27,6 +27,8 @@ import net.onelitefeather.titan.common.feature.FeatureGate; import net.onelitefeather.titan.common.feature.FeatureStatus; import net.onelitefeather.titan.common.feature.ReleaseStage; +import net.onelitefeather.titan.common.season.SeasonDefinition; +import net.onelitefeather.titan.common.season.SeasonDirector; import org.jetbrains.annotations.Nullable; import java.time.format.DateTimeFormatter; @@ -42,8 +44,12 @@ * {@value ReleaseStage#INTERNAL_PERMISSION} — the same permission that defines the internal * audience — and, like {@code /stop}, is always available from the server console. * + *

{@code /season list} does the same for the seasons in the {@code seasons} directory. They are + * not Togglz features - a season's window lives in its own file - so they would otherwise be + * invisible to the one command whose job is to spare an operator a trip to the log. + * * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.15.0 */ public final class SeasonCommand extends Command { @@ -51,19 +57,66 @@ public final class SeasonCommand extends Command { private static final DateTimeFormatter WINDOW_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); private final FeatureGate featureGate; + private final SeasonDirector seasons; /** * Creates the command. * * @param featureGate the gate the status is read from + * @param seasons the seasons that were loaded from the season directory */ - public SeasonCommand(FeatureGate featureGate) { + public SeasonCommand(FeatureGate featureGate, SeasonDirector seasons) { super("season"); this.featureGate = featureGate; + this.seasons = seasons; setCondition(SeasonCommand::canUse); setDefaultExecutor((sender, context) -> sender.sendMessage( - Component.text("Usage: /season status", NamedTextColor.RED))); + Component.text("Usage: /season status | /season list", NamedTextColor.RED))); addSyntax((sender, context) -> sendStatus(sender), ArgumentType.Literal("status")); + addSyntax((sender, context) -> sendSeasons(sender), ArgumentType.Literal("list")); + } + + /** + * Renders one season as a single chat line: id, priority, stage, window and kill switch. + * + * @param definition the season to render + * @param live whether the season's world effects are in the world right now + * @return the line shown to the sender + */ + static Component describe(SeasonDefinition definition, boolean live) { + Component line = Component.text(definition.id(), NamedTextColor.WHITE).append(Component.text(" | priority ", NamedTextColor.DARK_GRAY)).append(Component.text(definition.priority(), NamedTextColor.AQUA)).append(Component.text(" | stage ", NamedTextColor.DARK_GRAY)).append(Component.text(definition.stage().id(), stageColor(definition.stage()))); + String from = definition.window().from() == null ? "-∞" : WINDOW_FORMAT.format(definition.window().from()); + String to = definition.window().to() == null ? "∞" : WINDOW_FORMAT.format(definition.window().to()); + line = line.append(Component.text(" | window ", NamedTextColor.DARK_GRAY)).append(Component.text(from + " to " + to + " (" + definition.window().zone().getId() + ")", live ? NamedTextColor.GREEN : NamedTextColor.GOLD)); + if (definition.world() != null) { + line = line.append(Component.text(" | world ", NamedTextColor.DARK_GRAY)).append(Component.text(definition.world(), NamedTextColor.WHITE)); + } + line = line.append(Component.text(" | kill switch ", NamedTextColor.DARK_GRAY)).append(definition.enabled() ? Component.text("off", NamedTextColor.GREEN) : Component.text("engaged", NamedTextColor.RED)); + return line.append(Component.text(live ? " | live" : " | not live", live ? NamedTextColor.GREEN : NamedTextColor.GRAY)); + } + + /** + * Builds the lines {@code /season list} prints: one header plus one line per loaded season. + * + * @return the rendered list, in the order the seasons are applied + */ + List seasonLines() { + List definitions = this.seasons.definitions(); + List lines = new ArrayList<>(); + lines.add(Component.text("Seasons (" + definitions.size() + "), lowest priority first", NamedTextColor.YELLOW)); + if (definitions.isEmpty()) { + lines.add(Component.text("No seasons are installed; the lobby runs without seasonal content.", NamedTextColor.GRAY)); + return List.copyOf(lines); + } + List live = this.seasons.live(); + for (SeasonDefinition definition : definitions) { + lines.add(describe(definition, live.contains(definition))); + } + return List.copyOf(lines); + } + + private void sendSeasons(CommandSender sender) { + seasonLines().forEach(sender::sendMessage); } /** 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 cc3a2e8..323e1a5 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 @@ -31,6 +31,8 @@ import net.onelitefeather.titan.common.navigator.BuildServerDirectory; import net.onelitefeather.titan.common.navigator.NavigatorEntry; import net.onelitefeather.titan.common.navigator.NavigatorLayout; +import net.onelitefeather.titan.common.season.SeasonDirector; +import net.onelitefeather.titan.common.season.SeasonPresentation; import net.onelitefeather.titan.common.utils.Items; import net.theevilreaper.aves.inventory.InventoryLayout; import net.theevilreaper.aves.inventory.PersonalInventoryBuilder; @@ -57,8 +59,13 @@ * drawn, not when the helper is created, so a menu opened a second time reflects a stopped server * or a withdrawn permission (US-5.04). * + *

A running season may re-skin a destination's icon (US-4.03). That is done here rather than in + * the entry list, and per player rather than once, because which seasons a player may see is a + * question for the gate: a holder of {@code titan.season.preview} sees next month's icons while + * everybody else still sees this month's. + * * @author TheMeinerLP - * @version 2.0.0 + * @version 2.1.0 * @since 1.15.0 */ public class NavigationHelper { @@ -88,16 +95,18 @@ private record GatedEntry(TitanFeatures feature, NavigatorEntry entry) { private final FeatureGate featureGate; private final BuildServerDirectory buildServers; private final BuildServerAccess access; + private final SeasonDirector seasons; 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, FeatureAudience audience, FeatureGate featureGate, BuildServerDirectory buildServers, BuildServerAccess access) { + private NavigationHelper(Deliver deliver, FeatureAudience audience, FeatureGate featureGate, BuildServerDirectory buildServers, BuildServerAccess access, SeasonDirector seasons) { this.deliver = deliver; this.audience = audience; this.featureGate = featureGate; this.buildServers = buildServers; this.access = access; + this.seasons = seasons; } public void openNavigator(Player player) { @@ -164,10 +173,14 @@ List layoutFor(UUID playerId) { * @return the public entries, followed by the reachable build servers in a stable order */ private List entriesFor(UUID playerId) { + // Read once per menu: the seasons this player may see, which is not necessarily the ones + // that are live. The gate has already decided that; nothing is checked a second time here. + SeasonPresentation presentation = this.seasons.presentationFor(playerId); List entries = new ArrayList<>(PUBLIC_ENTRIES.size()); for (GatedEntry gated : PUBLIC_ENTRIES) { if (this.featureGate.isVisibleTo(gated.feature(), playerId)) { - entries.add(gated.entry()); + NavigatorEntry entry = gated.entry(); + entries.add(presentation.icon(entry.destination()).map(entry::withIconMaterial).orElse(entry)); } } if (!this.audience.hasPermission(playerId, this.access.permission())) { @@ -188,6 +201,19 @@ public static NavigationHelper instance(Deliver deliver, FeatureGate featureGate return instance(deliver, FeatureAudience.denyAll(), featureGate, BuildServerDirectory.empty(), BuildServerAccess.defaults()); } + /** + * Creates a navigator that offers the public entries only, unchanged by any season. Used where + * no permission backend is available. + * + * @param deliver the delivery used to move a player on click + * @param featureGate the gate deciding which destinations are released + * @param seasons the seasons that may re-skin an icon + * @return a navigator without build servers + */ + public static NavigationHelper instance(Deliver deliver, FeatureGate featureGate, SeasonDirector seasons) { + return instance(deliver, FeatureAudience.denyAll(), featureGate, BuildServerDirectory.empty(), BuildServerAccess.defaults(), seasons); + } + /** * Creates a navigator that can also offer the build servers. * @@ -198,7 +224,22 @@ public static NavigationHelper instance(Deliver deliver, FeatureGate featureGate * @return the navigator */ public static NavigationHelper instance(Deliver deliver, FeatureAudience audience, FeatureGate featureGate, BuildServerDirectory buildServers, BuildServerAccess access) { - return new NavigationHelper(deliver, audience, featureGate, buildServers, access); + return instance(deliver, audience, featureGate, buildServers, access, SeasonDirector.of(featureGate, List.of())); + } + + /** + * Creates a navigator that can also offer the build servers and be re-skinned by a season. + * + * @param deliver the delivery used to move a player on click + * @param audience the source of permission answers, asked every time the menu is drawn + * @param featureGate the gate deciding which destinations are released + * @param buildServers the currently reachable build servers + * @param access which destinations are build servers and what they require + * @param seasons the seasons that may re-skin an icon + * @return the navigator + */ + public static NavigationHelper instance(Deliver deliver, FeatureAudience audience, FeatureGate featureGate, BuildServerDirectory buildServers, BuildServerAccess access, SeasonDirector seasons) { + return new NavigationHelper(deliver, audience, featureGate, buildServers, access, seasons); } } 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 d417361..9b21b70 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 @@ -31,6 +31,9 @@ import net.onelitefeather.titan.common.feature.ReleaseStage; import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy; import net.onelitefeather.titan.common.feature.TitanFeatures; +import net.onelitefeather.titan.common.season.SeasonDefinition; +import net.onelitefeather.titan.common.season.SeasonDirector; +import net.onelitefeather.titan.common.season.SeasonLoader; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -65,6 +68,7 @@ class SeasonCommandTest { private static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z"); private InMemoryStateRepository repository; + private FeatureGate gate; private SeasonCommand command; @BeforeEach @@ -72,7 +76,8 @@ 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); + this.gate = gate; + this.command = new SeasonCommand(gate, SeasonDirector.of(gate, List.of())); } private static String plain(Component component) { @@ -89,6 +94,41 @@ private static Player playerWith(Env env, Instance instance, boolean permitted) return player; } + @Test + @DisplayName("the season list names every loaded season and whether it is live") + void seasonListNamesEverySeasonAndWhetherItIsLive() { + SeasonLoader loader = SeasonLoader.create(BERLIN); + SeasonDefinition open = loader.parse("open", """ + { "id": "open", "priority": 5, "stage": "ga", "world": "lantern-nights", + "window": { "from": "2026-10-01", "to": "2026-11-05", "zone": "Europe/Berlin" } } + """); + SeasonDefinition later = loader.parse("later", """ + { "id": "later", "priority": 9, "stage": "internal", + "window": { "from": "2026-12-01", "to": "2026-12-27", "zone": "Europe/Berlin" } } + """); + SeasonCommand listing = new SeasonCommand(this.gate, SeasonDirector.of(this.gate, List.of(later, open))); + + List lines = listing.seasonLines().stream().map(SeasonCommandTest::plain).toList(); + + assertEquals(3, lines.size()); + assertTrue(lines.getFirst().contains("Seasons (2)"), lines.getFirst()); + assertTrue(lines.get(1).startsWith("open"), "lowest priority first, whatever order they were handed over in: " + lines); + assertTrue(lines.get(1).contains("priority 5"), lines.get(1)); + assertTrue(lines.get(1).contains("world lantern-nights"), lines.get(1)); + assertTrue(lines.get(1).endsWith("live"), lines.get(1)); + assertTrue(lines.get(2).startsWith("later"), lines.get(2)); + assertTrue(lines.get(2).endsWith("not live"), lines.get(2)); + } + + @Test + @DisplayName("a lobby with no seasons says so instead of printing an empty list") + void seasonListSaysWhenNoSeasonIsInstalled() { + List lines = this.command.seasonLines().stream().map(SeasonCommandTest::plain).toList(); + + assertEquals(2, lines.size()); + assertTrue(lines.get(1).contains("No seasons are installed"), lines.get(1)); + } + @Test @DisplayName("only holders of titan.feature.internal may run the command") void onlyTheTeamMayRunTheCommand(Env env) { 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 a98fef3..cf353a4 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 @@ -33,12 +33,16 @@ import net.onelitefeather.titan.common.feature.TitanFeatures; import net.onelitefeather.titan.common.navigator.BuildServerAccess; import net.onelitefeather.titan.common.navigator.BuildServerDirectory; +import net.onelitefeather.titan.common.season.SeasonDefinition; +import net.onelitefeather.titan.common.season.SeasonDirector; +import net.onelitefeather.titan.common.season.SeasonLoader; import net.onelitefeather.titan.common.utils.Items; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import java.time.ZoneId; import java.util.Arrays; import java.util.HashSet; import java.util.List; @@ -244,6 +248,52 @@ void reopeningPicksUpAFlagChange(Env env) { Assertions.assertFalse(openedMaterials(env, helper, player).contains(Material.ELYTRA), "the second open must reflect the flag change"); } + @DisplayName("A running season re-skins a navigator icon and leaves the rest of the menu alone") + @Test + void aRunningSeasonReskinsAnIcon(Env env) { + TestFeatureGate features = allReleased(); + NavigationHelper helper = navigatorWith(features, season("live", "2026-10-01", "2026-11-05")); + Player player = env.createPlayer(env.createFlatInstance()); + + Set shown = openedMaterials(env, helper, player); + + Assertions.assertTrue(shown.contains(Material.CARVED_PUMPKIN), "the season's icon should have replaced the survival one"); + Assertions.assertFalse(shown.contains(Material.GRASS_BLOCK), "and the ordinary icon should be gone, not shown alongside it"); + Assertions.assertTrue(shown.contains(Material.ELYTRA), "a season must not touch a destination it did not name"); + Assertions.assertTrue(shown.contains(Material.WOODEN_AXE), "a season must not touch a destination it did not name"); + } + + @DisplayName("A season whose window has not opened is shown to a preview holder and to nobody else") + @Test + void previewHolderSeesNextSeasonsIcon(Env env) { + TestFeatureGate features = allReleased(); + NavigationHelper helper = navigatorWith(features, season("future", "2026-12-01", "2026-12-27")); + Instance instance = env.createFlatInstance(); + Player ordinary = env.createPlayer(instance); + Player team = env.createPlayer(instance); + features.grant(team.getUuid(), FeatureGate.PREVIEW_PERMISSION); + + Assertions.assertTrue(openedMaterials(env, helper, ordinary).contains(Material.GRASS_BLOCK), "a player sees this month's menu"); + Assertions.assertFalse(openedMaterials(env, helper, ordinary).contains(Material.CARVED_PUMPKIN), "and never next month's"); + Assertions.assertTrue(openedMaterials(env, helper, team).contains(Material.CARVED_PUMPKIN), "the team sees next month's, which is what the preview permission is for"); + } + + /** A season that swaps the survival icon for a pumpkin between the two given dates. */ + private static SeasonDefinition season(String id, String from, String to) { + return SeasonLoader.create(ZoneId.of("Europe/Berlin")).parse(id, """ + { + "id": "%s", + "stage": "ga", + "window": { "from": "%s", "to": "%s", "zone": "Europe/Berlin" }, + "effects": [ { "type": "replace_icon", "destination": "Survival", "material": "minecraft:carved_pumpkin" } ] + } + """.formatted(id, from, to)); + } + + private static NavigationHelper navigatorWith(TestFeatureGate features, SeasonDefinition... seasons) { + return NavigationHelper.instance(DummyDeliver.instance(), features.gate(), SeasonDirector.of(features.gate(), List.of(seasons))); + } + /** 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); 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 index 17e55d1..4990bf9 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureDecision.java +++ b/common/src/main/java/net/onelitefeather/titan/common/feature/FeatureDecision.java @@ -20,10 +20,11 @@ /** * 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. + * evaluation, in the fixed order the gate walks them: kill switch, release stage, preview + * permission, time window. * * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.15.0 */ public enum FeatureDecision { @@ -31,6 +32,14 @@ public enum FeatureDecision { /** The player sees the feature. */ ALLOWED, + /** + * The player sees the feature only because they hold + * {@value FeatureGate#PREVIEW_PERMISSION}; the window is closed and everybody else is denied. + * Kept apart from {@link #ALLOWED} so an operator asking why they can see something gets the + * honest answer instead of concluding the season is live. + */ + ALLOWED_PREVIEW, + /** * 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. @@ -46,10 +55,21 @@ public enum FeatureDecision { /** * Returns whether this decision lets the player see the feature. * - * @return {@code true} for {@link #ALLOWED} + * @return {@code true} for {@link #ALLOWED} and {@link #ALLOWED_PREVIEW} */ @Contract(pure = true) public boolean isAllowed() { - return this == ALLOWED; + return this == ALLOWED || this == ALLOWED_PREVIEW; + } + + /** + * Returns whether the feature is visible to this player alone, on the strength of the preview + * permission, rather than to everybody. + * + * @return {@code true} for {@link #ALLOWED_PREVIEW} + */ + @Contract(pure = true) + public boolean isPreview() { + return this == ALLOWED_PREVIEW; } } 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 87ab2a0..a92295e 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 @@ -50,16 +50,26 @@ * {@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}. + *

  • preview — a holder of {@value #PREVIEW_PERMISSION} passes the window step + * unconditionally and is told so, by {@link FeatureDecision#ALLOWED_PREVIEW} rather than + * {@link FeatureDecision#ALLOWED} (US-4.07). Preview widens the window and nothing else: it + * never revives a killed feature and never admits somebody the release stage excludes, because + * "let me look at it early" and "let me see what I am not cleared for" are different + * requests.
  • *
  • time window — evaluated by {@link SeasonWindowActivationStrategy}. A feature with * no window is always within it.
  • * * - *

    The three steps form a conjunction, so the order does not change the answer — it decides - * which step is reported as the reason, and it is what {@code /season status} and the tests rely - * on. + *

    The 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. + * + *

    Seasons are evaluated by the very same code even though their window lives in a JSON file + * rather than in the Togglz repository: {@link #decide(FeatureState, UUID)} takes the state + * directly, and a season builds one from its configuration. There is deliberately no second + * permission check anywhere for seasonal content. * * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.15.0 */ public final class FeatureGate { @@ -67,6 +77,12 @@ public final class FeatureGate { /** Feature-state parameter holding the release stage of a feature. */ public static final String STAGE_PARAMETER = "stage"; + /** + * Permission that lets a team member see seasonal content before its window opens and after it + * closes (US-4.07). Held by the team, never by players. + */ + public static final String PREVIEW_PERMISSION = "titan.season.preview"; + private static final Logger LOGGER = LoggerFactory.getLogger(FeatureGate.class); private final Supplier featureManager; @@ -128,7 +144,19 @@ public boolean isVisibleTo(Feature feature, UUID playerId) { * @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); + return decide(state(feature), playerId); + } + + /** + * Evaluates a feature state for a player. The state does not have to come from the Togglz + * repository — a season builds one from its own configuration file and gets exactly the + * evaluation a flagged feature gets. + * + * @param state the state to evaluate, {@code null} when the feature is unknown + * @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(@Nullable FeatureState state, UUID playerId) { if (state == null || !state.isEnabled()) { return FeatureDecision.DENIED_KILL_SWITCH; } @@ -136,11 +164,32 @@ public FeatureDecision decide(Feature feature, UUID playerId) { return FeatureDecision.DENIED_STAGE; } if (!this.window.isWithinWindow(state)) { - return FeatureDecision.DENIED_WINDOW; + // Preview is the last word on the window and only on the window. Asking it here rather + // than earlier is what keeps it from widening the release stage as a side effect. + return this.audience.hasPermission(playerId, PREVIEW_PERMISSION) ? FeatureDecision.ALLOWED_PREVIEW : FeatureDecision.DENIED_WINDOW; } return FeatureDecision.ALLOWED; } + /** + * Evaluates a feature state without a player, for the decisions the server takes on everyone's + * behalf at once — a season painting decoration into the shared world above all. + * + *

    Only the kill switch and the window are asked. The other two steps are questions about a + * person and have no answer here: a block is in the world or it is not, and a release stage + * cannot hide it from half the lobby. That is also why preview does not apply — see + * {@code SeasonDirector} for what a preview holder does and does not get to see early. + * + * @param state the state to evaluate, {@code null} when the feature is unknown + * @return {@link FeatureDecision#ALLOWED}, or the step that denied it + */ + public FeatureDecision decideForServer(@Nullable FeatureState state) { + if (state == null || !state.isEnabled()) { + return FeatureDecision.DENIED_KILL_SWITCH; + } + return this.window.isWithinWindow(state) ? FeatureDecision.ALLOWED : FeatureDecision.DENIED_WINDOW; + } + /** * Reads the operator-facing status of one feature and records a stage transition when the * stage has moved since the last look. diff --git a/common/src/main/java/net/onelitefeather/titan/common/navigator/NavigatorEntry.java b/common/src/main/java/net/onelitefeather/titan/common/navigator/NavigatorEntry.java index 8ee9250..e3db309 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/navigator/NavigatorEntry.java +++ b/common/src/main/java/net/onelitefeather/titan/common/navigator/NavigatorEntry.java @@ -17,6 +17,7 @@ package net.onelitefeather.titan.common.navigator; import net.minestom.server.item.ItemStack; +import net.minestom.server.item.Material; import net.onelitefeather.deliver.DeliverComponent; import net.onelitefeather.deliver.DeliverType; import net.onelitefeather.titan.common.feature.FeatureAudience; @@ -38,7 +39,7 @@ * @param destination the task or service name a click connects to * @param permission the permission required to see this entry, or {@code null} when it is public * @author TheMeinerLP - * @version 1.0.0 + * @version 1.1.0 * @since 1.15.0 */ public record NavigatorEntry(ItemStack icon, DeliverType type, String destination, @@ -70,6 +71,21 @@ public static NavigatorEntry restrictedServer(ItemStack icon, String serviceName return new NavigatorEntry(icon, DeliverType.SERVER, serviceName, permission); } + /** + * Returns this entry with its icon changed to another material, keeping the name and everything + * else about the item. + * + *

    Used by seasonal content to re-skin a destination without knowing anything about what the + * icon says or which permission guards the entry. + * + * @param material the material the icon takes on + * @return a copy of this entry with the new icon material + */ + @Contract(value = "_ -> new", pure = true) + public NavigatorEntry withIconMaterial(Material material) { + return new NavigatorEntry(this.icon.withMaterial(material), this.type, this.destination, this.permission); + } + /** * Checks whether this entry may be shown to the given player. * diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/ConfiguredSeason.java b/common/src/main/java/net/onelitefeather/titan/common/season/ConfiguredSeason.java new file mode 100644 index 0000000..630069c --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/ConfiguredSeason.java @@ -0,0 +1,173 @@ +/** + * 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.season; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.block.Block; +import org.jetbrains.annotations.Contract; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.UUID; + +/** + * A season built from its file, and the only implementation of {@link SeasonalContent} there is. + * + *

    There is deliberately no second one. A season that needed its own class would need its own + * deployment, its own review and its own way of cleaning up after itself — which is exactly the + * cost that makes a purely decorative season a net loss. Adding a season here is adding a file. + * + *

    The undo stack is the whole of {@link #deactivate()}. Each effect, as it is applied, pushes + * the action that takes it back; deactivation pops them. Two consequences fall out of that and both + * are wanted: the undo is written next to the change it undoes rather than in a second method that + * can drift out of sync, and it restores what was actually at a position rather than what the + * author assumed was there. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class ConfiguredSeason implements SeasonalContent { + + private static final Logger LOGGER = LoggerFactory.getLogger(ConfiguredSeason.class); + + private final SeasonDefinition definition; + private final Deque undo = new ArrayDeque<>(); + + private boolean active; + + private ConfiguredSeason(SeasonDefinition definition) { + this.definition = definition; + } + + /** + * Creates the content described by a season file. + * + * @param definition the season as read from its file + * @return the content + */ + @Contract(value = "_ -> new", pure = true) + public static ConfiguredSeason of(SeasonDefinition definition) { + return new ConfiguredSeason(definition); + } + + /** + * Returns the season this content was built from. + * + * @return the definition + */ + @Contract(pure = true) + public SeasonDefinition definition() { + return this.definition; + } + + @Override + public String id() { + return this.definition.id(); + } + + @Override + public int priority() { + return this.definition.priority(); + } + + @Override + public boolean active() { + return this.active; + } + + @Override + public void activate(SeasonCanvas canvas) { + if (this.active) { + return; + } + this.active = true; + for (SeasonEffect effect : this.definition.effects(SeasonEffect.Scope.WORLD)) { + apply(canvas, effect); + } + LOGGER.info("Season {} activated with {} world effect(s)", id(), this.undo.size()); + } + + @Override + public void deactivate() { + if (!this.active) { + return; + } + int taken = this.undo.size(); + while (!this.undo.isEmpty()) { + Runnable step = this.undo.pop(); + try { + step.run(); + } catch (RuntimeException exception) { + // Keep unwinding. One step that cannot be taken back is a leftover; stopping here + // would turn it into every remaining step being a leftover as well. + LOGGER.error("Season {} could not undo one of its changes; continuing with the rest", id(), exception); + } + } + this.active = false; + LOGGER.info("Season {} deactivated, {} change(s) taken back", id(), taken); + } + + /** + * Applies one world effect and pushes the step that takes it back. + * + *

    The switch is exhaustive over the sealed {@link SeasonEffect} hierarchy and has no + * {@code default}: an effect type that nobody has decided how to undo does not compile + * (US-4.04). + */ + private void apply(SeasonCanvas canvas, SeasonEffect effect) { + switch (effect) { + case SeasonEffect.PlaceDecoration decoration -> { + Pos position = decoration.position(); + // Read first, then write: the undo puts back whatever was actually there, which is + // not necessarily air and is not necessarily what the season author expected. + Block previous = canvas.blockAt(position); + Block block = Block.fromKey(decoration.block()); + if (block == null) { + // Unreachable through SeasonLoader, which resolves every key while reading the + // file. Kept as a guard for a definition built in code. + throw new IllegalStateException("season " + id() + " places the unknown block " + decoration.block().asString()); + } + canvas.setBlock(position, block); + this.undo.push(() -> canvas.setBlock(position, previous)); + } + case SeasonEffect.PlaceDisplay display -> { + UUID id = canvas.spawnDisplay(display.position(), MiniMessage.miniMessage().deserialize(display.text())); + this.undo.push(() -> canvas.removeDisplay(id)); + } + case SeasonEffect.AmbientSound sound -> { + SeasonCanvas.Handle handle = canvas.schedule(Duration.ofSeconds(sound.periodSeconds()), () -> canvas.playSound(sound.position(), sound.sound())); + this.undo.push(handle::cancel); + } + case SeasonEffect.MessagePrefix messagePrefix -> { + Component previous = canvas.prefix(); + canvas.prefix(MiniMessage.miniMessage().deserialize(messagePrefix.prefix())); + this.undo.push(() -> canvas.prefix(previous)); + } + // A player-scoped effect never touches the world, so there is nothing to place and + // nothing to take back. It is answered per viewer by SeasonPresentation, which is also + // what lets a preview holder see it outside the window. + case SeasonEffect.ReplaceIcon ignored -> { + } + } + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/MinestomSeasonCanvas.java b/common/src/main/java/net/onelitefeather/titan/common/season/MinestomSeasonCanvas.java new file mode 100644 index 0000000..4ade36f --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/MinestomSeasonCanvas.java @@ -0,0 +1,148 @@ +/** + * 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.season; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.sound.Sound; +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.Entity; +import net.minestom.server.entity.EntityType; +import net.minestom.server.entity.metadata.display.TextDisplayMeta; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.server.timer.Task; +import net.minestom.server.timer.TaskSchedule; +import net.minestom.server.utils.chunk.ChunkUtils; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.UUID; + +/** + * The {@link SeasonCanvas} the lobby actually runs on: one Minestom {@link Instance}. + * + *

    Scheduling goes through the instance's own scheduler rather than the server's. That is not a + * detail — an instance scheduler dies with the instance, so a season whose world is unloaded + * cannot keep a task alive against a world that no longer exists. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class MinestomSeasonCanvas implements SeasonCanvas { + + private final Instance instance; + + private MinestomSeasonCanvas(Instance instance) { + this.instance = instance; + } + + /** + * Creates a canvas painting on the given instance. + * + * @param instance the lobby world seasons change + * @return the canvas + */ + @Contract(value = "_ -> new", pure = true) + public static MinestomSeasonCanvas of(Instance instance) { + return new MinestomSeasonCanvas(instance); + } + + @Override + public Block blockAt(Point position) { + // Reading an unloaded chunk throws, and a season is applied at startup, before anybody has + // walked anywhere - so the chunk holding a decoration is routinely not loaded yet. Load it + // rather than let the season fail on the first position outside the spawn chunks. + ensureLoaded(position); + return this.instance.getBlock(position); + } + + @Override + public void setBlock(Point position, Block block) { + ensureLoaded(position); + this.instance.setBlock(position, block); + } + + private void ensureLoaded(Point position) { + if (!ChunkUtils.isLoaded(this.instance, position)) { + this.instance.loadChunk(position).join(); + } + } + + @Override + public UUID spawnDisplay(Pos position, Component text) { + // Same reason as blockAt: an entity put into an unloaded chunk is not in the world yet, and + // a season is applied before anybody has walked anywhere. + ensureLoaded(position); + Entity display = new Entity(EntityType.TEXT_DISPLAY); + display.editEntityMeta(TextDisplayMeta.class, meta -> { + meta.setText(text); + meta.setHasNoGravity(true); + meta.setBillboardRenderConstraints(TextDisplayMeta.BillboardConstraints.CENTER); + }); + display.setInstance(this.instance, position); + return display.getUuid(); + } + + @Override + public void removeDisplay(UUID displayId) { + Entity display = this.instance.getEntityByUuid(displayId); + if (display != null) { + display.remove(); + } + } + + @Override + public void playSound(Pos position, Key sound) { + this.instance.playSound(Sound.sound(sound, Sound.Source.AMBIENT, 1.0f, 1.0f), position.x(), position.y(), position.z()); + } + + @Override + public Component prefix() { + return SeasonPrefix.current(); + } + + @Override + public void prefix(@Nullable Component prefix) { + SeasonPrefix.current(prefix); + } + + @Override + public Handle schedule(Duration period, Runnable action) { + TaskSchedule schedule = TaskSchedule.duration(period); + return new TaskHandle(this.instance.scheduler().scheduleTask(action, schedule, schedule)); + } + + /** A Minestom {@link Task} seen through the narrow window a season needs. */ + private record TaskHandle(@Nullable Task task) implements Handle { + + @Override + public void cancel() { + if (this.task != null) { + this.task.cancel(); + } + } + + @Override + public boolean alive() { + return this.task != null && this.task.isAlive(); + } + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/NamedWindowResolver.java b/common/src/main/java/net/onelitefeather/titan/common/season/NamedWindowResolver.java new file mode 100644 index 0000000..5e34c60 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/NamedWindowResolver.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.season; + +import java.time.ZoneId; +import java.util.Optional; + +/** + * Turns the name of a recurring season into the dates it covers in one year. + * + *

    This is the seam left for spec stage 2. A season file normally spells its window out: + * + *

    {@code "window": { "from": "2026-12-01", "to": "2026-12-27" }}
    + * + *

    which is right for an event with a chosen start date, and wrong for anything tied to the + * calendar rather than to the marketing plan. Winter does not begin on a date somebody typed; it + * begins where the boundary strategy says it does, and it moves by a day or so every year. Written + * out, such a window has to be corrected annually, and the year it is not corrected is the year + * nobody notices. + * + *

    So a file may name its window instead: + * + *

    {@code "window": { "named": "WINTER", "year": 2026 }}
    + * + *

    and {@link SeasonLoader} asks this resolver what those dates are. Stage 2 implements it over + * its {@code SeasonBoundaryStrategy} and {@code Season} types and passes the implementation to + * {@link SeasonLoader#create(ZoneId, NamedWindowResolver)}; nothing in this package needs to know + * those types exist, and nothing here has to change when they arrive. + * + *

    Until then {@link #unavailable()} is installed, and a file that names a window fails to load + * with a message saying so rather than quietly running all year. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +@FunctionalInterface +public interface NamedWindowResolver { + + /** + * Returns the resolver used while no season strategies are installed. It resolves nothing, so + * every named window is reported as unknown by name. + * + * @return a resolver that never resolves anything + */ + static NamedWindowResolver unavailable() { + return (name, year, zone) -> Optional.empty(); + } + + /** + * Resolves a named window to the dates it covers. + * + * @param name the name from the season file, for example {@code WINTER} + * @param year the year the season is being planned for + * @param zone the zone the resulting local times are read in + * @return the window, or an empty optional when this resolver does not know the name + */ + Optional resolve(String name, int year, ZoneId zone); +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonCanvas.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonCanvas.java new file mode 100644 index 0000000..3ee912e --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonCanvas.java @@ -0,0 +1,130 @@ +/** + * 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.season; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.block.Block; +import org.jetbrains.annotations.Nullable; + +import java.time.Duration; +import java.util.UUID; + +/** + * Everything a season is allowed to change about the running lobby. + * + *

    The interface is small, and every method on it has an inverse. That is the whole reason it + * exists: a season that can only reach the world through operations it can also undo cannot leave + * anything behind, and {@link SeasonalContent#deactivate()} becomes a property of the design + * rather than a promise somebody has to keep (US-4.02). + * + *

    {@link #blockAt(Point)} is on here for the same reason. A season reads what is at a position + * before it writes to it, so the undo is "put back what was actually there" rather than "put back + * air", which is the version that leaves a hole in the roof of a build. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public interface SeasonCanvas { + + /** + * Reads the block at a position. + * + * @param position the position to read + * @return the block currently there + */ + Block blockAt(Point position); + + /** + * Writes a block to a position. + * + * @param position the position to write to + * @param block the block to put there + */ + void setBlock(Point position, Block block); + + /** + * Spawns a floating text display. + * + * @param position where the display floats + * @param text the text it shows + * @return the id the display can be removed by + */ + UUID spawnDisplay(Pos position, Component text); + + /** + * Removes a display spawned by {@link #spawnDisplay(Pos, Component)}. Removing a display that + * is already gone does nothing. + * + * @param displayId the id returned when the display was spawned + */ + void removeDisplay(UUID displayId); + + /** + * Plays a sound at a position, for everybody who can hear it. + * + * @param position where the sound comes from + * @param sound the sound to play + */ + void playSound(Pos position, Key sound); + + /** + * Returns the message prefix in force right now. + * + * @return the prefix every {@code } tag currently resolves to + */ + Component prefix(); + + /** + * Sets the message prefix. + * + * @param prefix the prefix to use, or {@code null} to go back to the lobby's own + */ + void prefix(@Nullable Component prefix); + + /** + * Schedules a repeating action. + * + * @param period how long to wait between two runs, and before the first + * @param action what to run + * @return the handle the season keeps so it can stop the action again + */ + Handle schedule(Duration period, Runnable action); + + /** + * A scheduled action a season can stop. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + interface Handle { + + /** Stops the action. Cancelling twice is allowed and does nothing the second time. */ + void cancel(); + + /** + * Returns whether the action is still scheduled. + * + * @return whether the action would still run + */ + boolean alive(); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonConfigurationException.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonConfigurationException.java new file mode 100644 index 0000000..88cb419 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonConfigurationException.java @@ -0,0 +1,70 @@ +/** + * 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.season; + +import org.jetbrains.annotations.Nullable; + +/** + * Thrown when a season file cannot be read into a {@link SeasonDefinition}. + * + *

    This is the type that makes US-4.04 a load-time failure rather than a runtime surprise. Every + * message names the file and the value that could not be used, because the alternative — a season + * that loads with one effect silently missing — is the failure mode the requirement exists to + * rule out. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class SeasonConfigurationException extends RuntimeException { + + private static final long serialVersionUID = 1L; + + private final @Nullable String source; + + /** + * Creates an exception describing a problem in one season file. + * + * @param source the file or season the problem was found in, {@code null} when unknown + * @param message what could not be read, naming the offending value + */ + public SeasonConfigurationException(@Nullable String source, String message) { + super(source == null ? message : source + ": " + message); + this.source = source; + } + + /** + * Creates an exception describing a problem in one season file, caused by another failure. + * + * @param source the file or season the problem was found in, {@code null} when unknown + * @param message what could not be read, naming the offending value + * @param cause the underlying failure + */ + public SeasonConfigurationException(@Nullable String source, String message, Throwable cause) { + super(source == null ? message : source + ": " + message, cause); + this.source = source; + } + + /** + * Returns the file or season the problem was found in. + * + * @return the source, or {@code null} when the problem could not be attributed to one + */ + public @Nullable String source() { + return this.source; + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonDefinition.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonDefinition.java new file mode 100644 index 0000000..f600d1b --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonDefinition.java @@ -0,0 +1,121 @@ +/** + * 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.season; + +import net.onelitefeather.titan.common.feature.FeatureGate; +import net.onelitefeather.titan.common.feature.ReleaseStage; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; +import org.togglz.core.repository.FeatureState; +import org.togglz.core.util.NamedFeature; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.regex.Pattern; + +/** + * One season, exactly as its file says it is. This is the whole contract between an operator and + * the lobby: an id, whether it is switched on, how it ranks against other seasons, who may see it, + * which world it wants, when it runs and what it does. + * + *

    Everything here is a value. There is no place to put a condition, a callback or a class name, + * and that is the point — a season is added by writing one of these files and a world directory, + * never by writing Java. + * + *

    Seasons cannot refer to each other (US-4.06). That is not enforced by a rule that could be + * forgotten, it is a property of this record: there is no field in which one season could name + * another, and therefore no deployment order to get right. + * + * @param id the season's id, lowercase letters, digits, {@code -} and {@code _}; also the + * feature name the gate evaluates it under + * @param enabled the kill switch; a season switched off here is invisible whatever its window says + * @param priority which season wins where two overlap — higher applies later and therefore on top + * (US-4.05) + * @param stage the audience the season has been released to, exactly as for a feature flag + * @param world the world directory this season wants the lobby to load, {@code null} to leave + * the world alone + * @param window when the season runs + * @param effects what the season does, in the order the file lists them + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public record SeasonDefinition(String id, boolean enabled, int priority, ReleaseStage stage, + @Nullable String world, SeasonWindow window, + List effects) { + + /** Ids are used as feature names, world folder names and log keys; keep them boring. */ + private static final Pattern ID_PATTERN = Pattern.compile("[a-z0-9][a-z0-9_-]*"); + + /** + * Orders seasons the way they are applied: lowest priority first, so the highest priority is + * applied last and is what a player ends up seeing. Ties fall back to the id, so the order + * never depends on the order the files happened to be read in. + */ + public static final Comparator BY_PRIORITY = Comparator.comparingInt(SeasonDefinition::priority).thenComparing(SeasonDefinition::id); + + /** + * Normalises the effect list and rejects an unusable id. + */ + public SeasonDefinition { + if (id == null || !ID_PATTERN.matcher(id).matches()) { + throw new IllegalArgumentException("a season id must be lowercase letters, digits, '-' or '_', got '" + id + "'"); + } + if (stage == null) { + throw new IllegalArgumentException("season '" + id + "' needs a release stage"); + } + if (window == null) { + throw new IllegalArgumentException("season '" + id + "' needs a window"); + } + effects = List.copyOf(effects); + } + + /** + * Builds the feature state {@link FeatureGate} evaluates this season under. + * + *

    A season is not in the Togglz repository — its window comes from its own file — but it is + * gated by the same three steps a flagged feature is, and this is what makes that possible + * without a second implementation of any of them. + * + * @return a feature state carrying the kill switch, the stage and the window of this season + */ + @Contract(value = "-> new", pure = true) + public FeatureState toFeatureState() { + FeatureState state = new FeatureState(new NamedFeature(this.id.toUpperCase(Locale.ROOT)), this.enabled); + state.setParameter(FeatureGate.STAGE_PARAMETER, this.stage.id()); + return this.window.applyTo(state); + } + + /** + * Returns the effects of this season that have the given scope, in file order. + * + * @param scope the scope to filter by + * @return the matching effects + */ + @Contract(pure = true) + public List effects(SeasonEffect.Scope scope) { + List matching = new ArrayList<>(this.effects.size()); + for (SeasonEffect effect : this.effects) { + if (effect.scope() == scope) { + matching.add(effect); + } + } + return List.copyOf(matching); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonDirector.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonDirector.java new file mode 100644 index 0000000..1fb2356 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonDirector.java @@ -0,0 +1,260 @@ +/** + * 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.season; + +import net.onelitefeather.titan.common.feature.FeatureDecision; +import net.onelitefeather.titan.common.feature.FeatureGate; +import org.jetbrains.annotations.Contract; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.file.Path; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; + +/** + * Decides which seasons are running and keeps the world in step with that decision. + * + *

    Three things live here and nowhere else, and all three are the stable part of the design — + * they do not change when a season changes: + * + *

      + *
    1. Who decides. Every question of the form "is this season on" goes to + * {@link FeatureGate}. There is no second kill switch, no second stage check and no second + * reading of the clock (US-4.07).
    2. + *
    3. In which order. Seasons are applied in ascending {@link SeasonDefinition#priority()}, + * so the highest priority is applied last and therefore wins. Two files with the same priority + * fall back to the id. Nothing anywhere depends on the order the files were read in + * (US-4.05).
    4. + *
    5. What is undone. {@link #synchronize(SeasonCanvas)} takes every running season back + * out before putting the new set in, rather than trying to unwind one season out of the middle + * of a stack of overlapping ones. That is slightly wasteful and completely predictable: the + * world becomes a function of the set of live seasons and of nothing else.
    6. + *
    + * + *

    What preview can and cannot do. A player-scoped effect — a navigator icon, a message + * prefix — is chosen while something is being shown to one person, so a preview holder sees it and + * nobody else does; that is {@link #presentationFor(UUID)}. A world-scoped effect is a block in a + * shared world, and no permission can put a block there for one player only. World effects + * therefore follow {@link FeatureGate#decideForServer} — the kill switch and the window, decided + * once for the lobby. Previewing decoration means starting a lobby with the season's window open, + * which the release stage then keeps to the team; it does not mean walking into the live lobby and + * seeing pumpkins nobody else sees. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class SeasonDirector { + + private static final Logger LOGGER = LoggerFactory.getLogger(SeasonDirector.class); + + private final FeatureGate gate; + private final List definitions; + private final Map contents = new LinkedHashMap<>(); + + private SeasonDirector(FeatureGate gate, List definitions) { + this.gate = gate; + List sorted = new ArrayList<>(definitions); + sorted.sort(SeasonDefinition.BY_PRIORITY); + this.definitions = List.copyOf(sorted); + for (SeasonDefinition definition : this.definitions) { + this.contents.put(definition.id(), ConfiguredSeason.of(definition)); + } + } + + /** + * Creates a director over a fixed set of seasons. + * + * @param gate the release gate every season is evaluated by + * @param definitions the seasons, in any order + * @return the director + */ + @Contract(value = "_, _ -> new", pure = true) + public static SeasonDirector of(FeatureGate gate, List definitions) { + return new SeasonDirector(gate, definitions); + } + + /** + * Creates a director over the seasons in a directory, or over no season at all when the + * directory is not there (NFR-003). + * + * @param gate the release gate every season is evaluated by + * @param directory the directory season files are read from + * @param zone the zone a season file that names none is planned in + * @return the director + * @throws SeasonConfigurationException when a season file cannot be read + */ + public static SeasonDirector load(FeatureGate gate, Path directory, ZoneId zone) { + return new SeasonDirector(gate, SeasonLoader.create(zone).loadAll(directory)); + } + + /** + * Returns every season that was loaded, in ascending priority order. + * + * @return the loaded seasons + */ + @Contract(pure = true) + public List definitions() { + return this.definitions; + } + + /** + * Returns the seasons that are live for the lobby as a whole, in ascending priority order. + * + * @return the seasons whose world effects belong in the world right now + */ + @Contract(pure = true) + public List live() { + List live = new ArrayList<>(); + for (SeasonDefinition definition : this.definitions) { + if (this.gate.decideForServer(definition.toFeatureState()).isAllowed()) { + live.add(definition); + } + } + return List.copyOf(live); + } + + /** + * Returns the seasons one player may see, in ascending priority order. A holder of + * {@link FeatureGate#PREVIEW_PERMISSION} also sees seasons whose window is shut. + * + * @param playerId the player's unique id + * @return the seasons visible to that player + */ + @Contract(pure = true) + public List visibleTo(UUID playerId) { + List visible = new ArrayList<>(); + for (SeasonDefinition definition : this.definitions) { + if (decisionFor(definition, playerId).isAllowed()) { + visible.add(definition); + } + } + return List.copyOf(visible); + } + + /** + * Evaluates one season for one player and reports which step decided the outcome. + * + * @param definition the season to evaluate + * @param playerId the player's unique id + * @return the gate's decision, including whether it rests on the preview permission + */ + @Contract(pure = true) + public FeatureDecision decisionFor(SeasonDefinition definition, UUID playerId) { + return this.gate.decide(definition.toFeatureState(), playerId); + } + + /** + * Returns what the seasons this player may see do to what they are shown. + * + * @param playerId the player's unique id + * @return the navigator icons and message prefix in force for that player + */ + @Contract(pure = true) + public SeasonPresentation presentationFor(UUID playerId) { + return SeasonPresentation.of(visibleTo(playerId)); + } + + /** + * Returns the world directory the winning live season asks the lobby to load. + * + *

    Consuming this is the job of the world selection in spec stage 1; the director only + * answers the question, because which world is loaded has to be decided once at startup and + * cannot be changed underneath players who are standing in it. + * + * @return the world of the highest-priority live season that names one + */ + @Contract(pure = true) + public Optional world() { + String world = null; + for (SeasonDefinition definition : live()) { + if (definition.world() != null) { + world = definition.world(); + } + } + return Optional.ofNullable(world); + } + + /** + * Brings the world in line with the seasons that are live right now. + * + *

    Safe to call repeatedly — the lobby calls it on a timer, so a window that opens or a kill + * switch that is thrown takes effect without a restart (NFR-004). When the live set has not + * changed, nothing is touched. + * + * @param canvas the world to apply seasons to + * @return whether anything changed + */ + public boolean synchronize(SeasonCanvas canvas) { + List live = live(); + List wanted = live.stream().map(SeasonDefinition::id).toList(); + List running = new ArrayList<>(); + for (SeasonDefinition definition : this.definitions) { + if (content(definition.id()).active()) { + running.add(definition.id()); + } + } + if (wanted.equals(running)) { + return false; + } + LOGGER.info("Seasons changing from {} to {}", running.isEmpty() ? "none" : running, wanted.isEmpty() ? "none" : wanted); + // Take everything down before putting anything up. Unwinding one season out of the middle + // of an overlapping stack would restore whatever the season above it had written, which is + // correct only by accident; a full rebuild is a few block writes a year and always right. + deactivateAll(); + for (SeasonDefinition definition : live) { + content(definition.id()).activate(canvas); + } + return true; + } + + /** + * Takes every running season back out of the world. Called on shutdown so a lobby that stops + * mid-season does not leave its decoration in the world files. + */ + public void deactivateAll() { + List reversed = new ArrayList<>(this.definitions); + Collections.reverse(reversed); + for (SeasonDefinition definition : reversed) { + content(definition.id()).deactivate(); + } + } + + /** + * Returns the content object of one season, for tests and for the smoke run of an archived + * season. + * + * @param id the season id + * @return the content + * @throws IllegalArgumentException when no season with that id was loaded + */ + @Contract(pure = true) + public ConfiguredSeason content(String id) { + ConfiguredSeason content = this.contents.get(id); + if (content == null) { + throw new IllegalArgumentException("no season with the id '" + id + "' is loaded"); + } + return content; + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonEffect.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonEffect.java new file mode 100644 index 0000000..43707b7 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonEffect.java @@ -0,0 +1,349 @@ +/** + * 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.season; + +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; + +import java.util.Arrays; +import java.util.Locale; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * One thing a season does. The hierarchy is sealed on purpose: it is the mechanism behind US-4.04. + * + *

    Two different failures are caught by two different means, and both matter: + * + *

    + * + *

    The effects are deliberately modest — decoration, a display, an ambient sound, an item swap, + * a message prefix. That is not a placeholder for something richer later: it is the line between a + * value and a verb. Anything that needs to decide something at runtime is a verb and belongs in + * Java, not in a season file. + * + *

    {@link #scope()} splits them into the two kinds that behave differently and must not be + * conflated. A {@link Scope#WORLD} effect is in the shared world or it is not — it cannot be shown + * to one player. A {@link Scope#PLAYER} effect is computed per viewer, which is what lets a + * preview holder see it early (US-4.07). + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public sealed interface SeasonEffect { + + /** + * Returns the type id this effect is written as in a season file. + * + * @return the effect type + */ + @Contract(pure = true) + Type type(); + + /** + * Returns whether this effect changes the shared world or is computed per viewer. + * + * @return the scope of this effect + */ + @Contract(pure = true) + default Scope scope() { + return type().scope(); + } + + /** + * Whether an effect changes the world everybody shares or is answered per player. + * + *

    The distinction is not a taxonomy, it is the reason preview cannot be uniform: a block is + * placed once for the whole lobby, so no permission can hide it from anyone, while a navigator + * icon is chosen while the menu is being drawn and can therefore differ per viewer. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + enum Scope { + + /** Changes the shared world; applied once, taken back once, seen by everybody. */ + WORLD, + + /** Computed while something is shown to one player; never written to the world. */ + PLAYER + } + + /** + * The effect types a season file may name, and the record each one deserialises into. + * + *

    This enum is the only place a JSON string is turned into a Java type. Everything else + * switches over the sealed interface and is therefore checked by the compiler. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + enum Type { + + /** {@link PlaceDecoration}. */ + PLACE_DECORATION("place_decoration", PlaceDecoration.class, Scope.WORLD), + + /** {@link PlaceDisplay}. */ + PLACE_DISPLAY("place_display", PlaceDisplay.class, Scope.WORLD), + + /** {@link AmbientSound}. */ + AMBIENT_SOUND("ambient_sound", AmbientSound.class, Scope.WORLD), + + /** {@link ReplaceIcon}. */ + REPLACE_ICON("replace_icon", ReplaceIcon.class, Scope.PLAYER), + + /** {@link MessagePrefix}. */ + MESSAGE_PREFIX("message_prefix", MessagePrefix.class, Scope.WORLD); + + private final String id; + private final Class effectClass; + private final Scope scope; + + Type(String id, Class effectClass, Scope scope) { + this.id = id; + this.effectClass = effectClass; + this.scope = scope; + } + + /** + * Resolves the type written in a season file. + * + * @param id the {@code type} value from the file, may be {@code null} + * @return the matching type, 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 (Type type : values()) { + if (type.id.equals(normalized)) { + return Optional.of(type); + } + } + return Optional.empty(); + } + + /** + * Lists every known type id, for the error message an unknown one produces. + * + * @return the known ids, comma separated and in declaration order + */ + @Contract(pure = true) + public static String knownIds() { + return Arrays.stream(values()).map(Type::id).collect(Collectors.joining(", ")); + } + + /** + * Returns the id used in a season file, for example {@code place_decoration}. + * + * @return the configured id of this type + */ + @Contract(pure = true) + public String id() { + return this.id; + } + + /** + * Returns the record this type deserialises into. + * + * @return the effect class + */ + @Contract(pure = true) + public Class effectClass() { + return this.effectClass; + } + + /** + * Returns whether effects of this type change the world or are computed per viewer. + * + * @return the scope of this type + */ + @Contract(pure = true) + public Scope scope() { + return this.scope; + } + } + + /** + * Puts a block into the world and remembers what was there before. + * + * @param position where the block goes + * @param block the block key, for example {@code minecraft:jack_o_lantern} + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + record PlaceDecoration(Pos position, Key block) implements SeasonEffect { + + /** + * Rejects a half-written effect at construction, so the loader can name the file it came + * from rather than the lobby discovering it later. + */ + public PlaceDecoration { + if (position == null) { + throw new IllegalArgumentException("place_decoration needs a position"); + } + if (block == null) { + throw new IllegalArgumentException("place_decoration needs a block"); + } + } + + @Override + public Type type() { + return Type.PLACE_DECORATION; + } + } + + /** + * Spawns a floating text display. + * + * @param position where the display floats + * @param text the text, in MiniMessage + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + record PlaceDisplay(Pos position, String text) implements SeasonEffect { + + /** + * Rejects a display without a position or without anything to say. + */ + public PlaceDisplay { + if (position == null) { + throw new IllegalArgumentException("place_display needs a position"); + } + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("place_display needs a text"); + } + } + + @Override + public Type type() { + return Type.PLACE_DISPLAY; + } + } + + /** + * Plays a sound at a place, over and over, until the season ends. + * + *

    This is the one effect that leaves a scheduled task behind, and therefore the one that + * makes {@link SeasonalContent#deactivate()} more than a formality: a season that forgets it + * keeps making noise into the next one. + * + * @param position where the sound comes from + * @param sound the sound key, for example {@code minecraft:ambient.cave} + * @param periodSeconds how many seconds pass between two plays, at least one + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + record AmbientSound(Pos position, Key sound, long periodSeconds) implements SeasonEffect { + + /** + * Rejects a sound loop that would spin every tick or never fire. + */ + public AmbientSound { + if (position == null) { + throw new IllegalArgumentException("ambient_sound needs a position"); + } + if (sound == null) { + throw new IllegalArgumentException("ambient_sound needs a sound"); + } + if (periodSeconds < 1) { + throw new IllegalArgumentException("ambient_sound needs a periodSeconds of at least 1, got " + periodSeconds); + } + } + + @Override + public Type type() { + return Type.AMBIENT_SOUND; + } + } + + /** + * Swaps the material of a navigator icon while the season runs. + * + * @param destination the navigator destination whose icon changes, for example {@code Survival} + * @param material the material key the icon takes on + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + record ReplaceIcon(String destination, Key material) implements SeasonEffect { + + /** + * Rejects an icon swap that names no destination or no material. + */ + public ReplaceIcon { + if (destination == null || destination.isBlank()) { + throw new IllegalArgumentException("replace_icon needs a destination"); + } + if (material == null) { + throw new IllegalArgumentException("replace_icon needs a material"); + } + } + + @Override + public Type type() { + return Type.REPLACE_ICON; + } + } + + /** + * Replaces what the {@code } tag resolves to while the season runs. + * + *

    Scoped to the world rather than to a player, which is a statement about messages and not + * about permissions: a prefix goes into text that is broadcast, so there is no viewer to + * compute + * it for. That is also why a preview holder does not get to see it early. + * + * @param prefix the prefix, in MiniMessage + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ + record MessagePrefix(String prefix) implements SeasonEffect { + + /** + * Rejects an empty prefix, which would be an effect that does nothing. + */ + public MessagePrefix { + if (prefix == null || prefix.isBlank()) { + throw new IllegalArgumentException("message_prefix needs a prefix"); + } + } + + @Override + public Type type() { + return Type.MESSAGE_PREFIX; + } + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonLoader.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonLoader.java new file mode 100644 index 0000000..2695779 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonLoader.java @@ -0,0 +1,452 @@ +/** + * 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.season; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonDeserializationContext; +import com.google.gson.JsonDeserializer; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParseException; +import com.google.gson.JsonPrimitive; +import com.google.gson.JsonSerializationContext; +import com.google.gson.JsonSerializer; +import net.kyori.adventure.key.Key; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.block.Block; +import net.minestom.server.item.Material; +import net.onelitefeather.titan.common.feature.ReleaseStage; +import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.Reader; +import java.lang.reflect.Type; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.DateTimeException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +/** + * Reads season files into {@link SeasonDefinition}s. + * + *

    The loader is strict on purpose. Anything it cannot read is a + * {@link SeasonConfigurationException} naming the file and the value — never a season that loads + * with one effect quietly missing. That is US-4.04, and it is worth the strictness: a season is + * looked at once a year, so a mistake found at startup costs minutes and the same mistake found in + * production costs the season. + * + *

    An absent {@code seasons} directory is not a mistake. The lobby runs without any season at + * all (NFR-003), and {@link #loadAll(Path)} returns an empty list for a directory that is not + * there. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class SeasonLoader { + + /** File extension a season file must have to be picked up. */ + public static final String FILE_EXTENSION = ".json"; + + /** Directory seasons are read from, relative to the working directory of the process. */ + public static final String DIRECTORY = "seasons"; + + private static final Logger LOGGER = LoggerFactory.getLogger(SeasonLoader.class); + + private final Gson gson; + private final ZoneId defaultZone; + private final NamedWindowResolver namedWindows; + + private SeasonLoader(ZoneId defaultZone, NamedWindowResolver namedWindows) { + this.defaultZone = defaultZone; + this.namedWindows = namedWindows; + this.gson = new GsonBuilder().registerTypeAdapter(SeasonEffect.class, new SeasonEffectAdapter()).registerTypeAdapter(Pos.class, new PosAdapter()).registerTypeHierarchyAdapter(Key.class, new KeyAdapter()).create(); + } + + /** + * Creates a loader that plans seasons in the given zone and cannot resolve named windows yet. + * + * @param defaultZone the zone a season file that names none is read in + * @return a loader for files that spell their windows out + */ + @Contract(value = "_ -> new", pure = true) + public static SeasonLoader create(ZoneId defaultZone) { + return new SeasonLoader(defaultZone, NamedWindowResolver.unavailable()); + } + + /** + * Creates a loader that can also resolve windows a file names rather than spells out. + * + * @param defaultZone the zone a season file that names none is read in + * @param namedWindows the resolver for named windows; see {@link NamedWindowResolver} for what + * spec stage 2 plugs in here + * @return a loader for both kinds of window + */ + @Contract(value = "_, _ -> new", pure = true) + public static SeasonLoader create(ZoneId defaultZone, NamedWindowResolver namedWindows) { + return new SeasonLoader(defaultZone, namedWindows); + } + + /** + * Reads every season file in a directory. + * + * @param directory the directory to read, usually {@value #DIRECTORY} next to the process + * @return the seasons, ordered by {@link SeasonDefinition#BY_PRIORITY} so the result never + * depends on the order the file system happened to list the files in (US-4.05) + * @throws SeasonConfigurationException when a file cannot be read, or when two files claim the + * same id + */ + public List loadAll(Path directory) { + if (!Files.isDirectory(directory)) { + LOGGER.info("No season directory at {}; running without seasonal content", directory.toAbsolutePath()); + return List.of(); + } + List files = new ArrayList<>(); + try (Stream stream = Files.list(directory)) { + stream.filter(Files::isRegularFile).filter(path -> path.getFileName().toString().endsWith(FILE_EXTENSION)).forEach(files::add); + } catch (IOException exception) { + throw new SeasonConfigurationException(directory.toString(), "the season directory could not be listed", exception); + } + Map seen = new HashMap<>(); + List definitions = new ArrayList<>(files.size()); + for (Path file : files) { + SeasonDefinition definition = load(file); + Path previous = seen.put(definition.id(), file); + if (previous != null) { + throw new SeasonConfigurationException(file.getFileName().toString(), "season id '" + definition.id() + "' is already used by " + previous.getFileName()); + } + definitions.add(definition); + } + definitions.sort(SeasonDefinition.BY_PRIORITY); + return List.copyOf(definitions); + } + + /** + * Reads one season file. + * + * @param file the file to read + * @return the season it describes + * @throws SeasonConfigurationException when the file cannot be read + */ + public SeasonDefinition load(Path file) { + String source = file.getFileName().toString(); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + return parse(source, this.gson.fromJson(reader, SeasonFile.class)); + } catch (IOException exception) { + throw new SeasonConfigurationException(source, "the file could not be read", exception); + } catch (SeasonConfigurationException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new SeasonConfigurationException(source, rootMessage(exception), exception); + } + } + + /** + * Reads a season from a string, for tests and for the smoke run of an archived season. + * + * @param source a name for the season used in error messages + * @param json the season as JSON + * @return the season it describes + * @throws SeasonConfigurationException when the JSON cannot be read + */ + public SeasonDefinition parse(String source, String json) { + try { + return parse(source, this.gson.fromJson(json, SeasonFile.class)); + } catch (SeasonConfigurationException exception) { + throw exception; + } catch (RuntimeException exception) { + throw new SeasonConfigurationException(source, rootMessage(exception), exception); + } + } + + private SeasonDefinition parse(String source, @Nullable SeasonFile file) { + if (file == null) { + throw new SeasonConfigurationException(source, "the file is empty"); + } + if (file.id() == null || file.id().isBlank()) { + throw new SeasonConfigurationException(source, "no id; a season needs one, and it is also the name the release gate knows it by"); + } + ReleaseStage stage = file.stage() == null ? ReleaseStage.DEFAULT : ReleaseStage.fromId(file.stage()).orElseThrow(() -> new SeasonConfigurationException(source, "stage='" + file.stage() + "' is not internal, lite or ga")); + SeasonWindow window = window(source, file.window()); + List effects = file.effects() == null ? List.of() : file.effects(); + // enabled defaults to true: a file that exists is meant to run, and the kill switch is + // something an operator reaches for deliberately. priority defaults to 0. + boolean enabled = file.enabled() == null || file.enabled(); + int priority = file.priority() == null ? 0 : file.priority(); + for (SeasonEffect effect : effects) { + checkRegistries(source, effect); + } + try { + return new SeasonDefinition(file.id(), enabled, priority, stage, blankToNull(file.world()), window, effects); + } catch (IllegalArgumentException exception) { + throw new SeasonConfigurationException(source, exception.getMessage(), exception); + } + } + + /** + * Checks that every key an effect names exists in the game's registries. + * + *

    A misspelt block is the mistake a season file is most likely to contain and the one that + * costs most: unchecked, it turns into either a silent hole in the build or an exception thrown + * at the moment the season goes live. Resolving the keys here makes it a startup failure with + * the file name on it instead. + * + *

    The switch is exhaustive over the sealed hierarchy and has no {@code default}, so an + * effect type that names a new kind of registry key does not compile until this method has been + * told how to check it. + */ + private static void checkRegistries(String source, SeasonEffect effect) { + switch (effect) { + case SeasonEffect.PlaceDecoration decoration -> { + if (Block.fromKey(decoration.block()) == null) { + throw new SeasonConfigurationException(source, "block='" + decoration.block().asString() + "' is not a known block"); + } + } + case SeasonEffect.ReplaceIcon icon -> { + if (Material.fromKey(icon.material()) == null) { + throw new SeasonConfigurationException(source, "material='" + icon.material().asString() + "' is not a known item material"); + } + } + // A sound key is not resolved: resource packs may add sounds the server does not know, + // and refusing an unknown one here would make the server the authority on a client-side + // registry. MiniMessage texts are parsed where they are used. + case SeasonEffect.PlaceDisplay ignored -> { + } + case SeasonEffect.AmbientSound ignored -> { + } + case SeasonEffect.MessagePrefix ignored -> { + } + } + } + + private SeasonWindow window(String source, @Nullable SeasonFile.WindowSpec spec) { + if (spec == null) { + return SeasonWindow.always(this.defaultZone); + } + ZoneId zone = zone(source, spec.zone()); + if (spec.named() != null && !spec.named().isBlank()) { + if (spec.from() != null || spec.to() != null) { + throw new SeasonConfigurationException(source, "the window names '" + spec.named() + "' and also spells out from/to; use one or the other"); + } + if (spec.year() == null) { + throw new SeasonConfigurationException(source, "the window names '" + spec.named() + "' but no year to resolve it in"); + } + return this.namedWindows.resolve(spec.named(), spec.year(), zone).orElseThrow(() -> new SeasonConfigurationException(source, "the window names '" + spec.named() + "', which no installed season strategy knows; spell the window out as from/to, or install a NamedWindowResolver")); + } + LocalDateTime from = bound(source, "from", spec.from()); + LocalDateTime to = bound(source, "to", spec.to()); + try { + return new SeasonWindow(from, to, zone); + } catch (IllegalArgumentException exception) { + throw new SeasonConfigurationException(source, exception.getMessage(), exception); + } + } + + private ZoneId zone(String source, @Nullable String raw) { + if (raw == null || raw.isBlank()) { + return this.defaultZone; + } + try { + return ZoneId.of(raw.trim()); + } catch (DateTimeException exception) { + throw new SeasonConfigurationException(source, "zone='" + raw.trim() + "' is not a known time zone", exception); + } + } + + private static @Nullable LocalDateTime bound(String source, String field, @Nullable String raw) { + if (raw == null || raw.isBlank()) { + return null; + } + String value = raw.trim(); + try { + return value.indexOf('T') < 0 ? LocalDate.parse(value).atStartOfDay() : LocalDateTime.parse(value); + } catch (DateTimeParseException exception) { + throw new SeasonConfigurationException(source, field + "='" + value + "' is not a date (2026-12-01) or a date-time (2026-12-01T18:00)", exception); + } + } + + private static @Nullable String blankToNull(@Nullable String value) { + return value == null || value.isBlank() ? null : value.trim(); + } + + /** + * Digs out the message that actually says what is wrong. Gson wraps an exception thrown by a + * record's constructor in a {@link RuntimeException} whose message is the constructor + * signature and the argument array — accurate, and useless to the person holding the file. + */ + private static String rootMessage(Throwable throwable) { + Throwable current = throwable; + String message = current.getMessage(); + while (current.getCause() != null && current.getCause() != current) { + current = current.getCause(); + if (current.getMessage() != null && !current.getMessage().isBlank()) { + message = current.getMessage(); + } + } + return message == null || message.isBlank() ? throwable.getClass().getSimpleName() : message; + } + + /** + * The raw shape of a season file, before anything has been validated. Kept apart from + * {@link SeasonDefinition} so that a missing or malformed value is reported by this loader, + * with the file name attached, instead of surfacing as a null somewhere in the lobby. + */ + private record SeasonFile(@Nullable String id, @Nullable Boolean enabled, + @Nullable Integer priority, @Nullable String stage, + @Nullable String world, @Nullable WindowSpec window, + @Nullable List effects) { + + /** The raw shape of the {@code window} object; see {@link NamedWindowResolver}. */ + private record WindowSpec(@Nullable String from, @Nullable String to, @Nullable String zone, + @Nullable String named, @Nullable Integer year) { + } + } + + /** + * Turns the {@code type} field of an effect into the record it belongs to, and refuses a type + * nobody implements. + * + *

    This is the load-time half of US-4.04. The compile-time half is the sealed hierarchy + * itself, and neither half covers the other: this one catches a typo in a file, that one + * catches a gap in the code. + */ + private static final class SeasonEffectAdapter implements JsonDeserializer, JsonSerializer { + + @Override + public SeasonEffect deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) { + if (!json.isJsonObject()) { + throw new JsonParseException("an effect must be an object with a type, got " + json); + } + JsonObject object = json.getAsJsonObject(); + JsonElement rawType = object.get("type"); + String id = rawType == null || !rawType.isJsonPrimitive() ? null : rawType.getAsString(); + SeasonEffect.Type type = SeasonEffect.Type.fromId(id).orElseThrow(() -> new JsonParseException(id == null ? "an effect has no type; known types are: " + SeasonEffect.Type.knownIds() : "unknown season effect type '" + id + "'; known types are: " + SeasonEffect.Type.knownIds())); + return context.deserialize(json, type.effectClass()); + } + + @Override + public JsonElement serialize(SeasonEffect src, Type typeOfSrc, JsonSerializationContext context) { + // Exhaustive over the sealed hierarchy and without a default on purpose: a new effect + // record does not compile until it has been considered here as well. + JsonElement element = switch (src) { + case SeasonEffect.PlaceDecoration decoration -> + context.serialize(decoration, SeasonEffect.PlaceDecoration.class); + case SeasonEffect.PlaceDisplay display -> + context.serialize(display, SeasonEffect.PlaceDisplay.class); + case SeasonEffect.AmbientSound sound -> + context.serialize(sound, SeasonEffect.AmbientSound.class); + case SeasonEffect.ReplaceIcon icon -> + context.serialize(icon, SeasonEffect.ReplaceIcon.class); + case SeasonEffect.MessagePrefix prefix -> + context.serialize(prefix, SeasonEffect.MessagePrefix.class); + }; + element.getAsJsonObject().add("type", new JsonPrimitive(src.type().id())); + return element; + } + } + + /** + * Reads a position as {@code {"x": 0.5, "y": 65, "z": 0.5}}, with an optional yaw and pitch. + * + *

    Aves ships an adapter for this, but it returns a {@code Vec} whenever yaw and pitch are + * absent — which is right for its own callers and wrong here, where the field is declared as a + * {@code Pos} and would fail with a class cast at load. Decoration is placed far more often + * than it is aimed, so the yaw and pitch stay optional and default to zero. + */ + private static final class PosAdapter implements JsonDeserializer, JsonSerializer { + + @Override + public Pos deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) { + if (!json.isJsonObject()) { + throw new JsonParseException("a position must be an object with x, y and z, got " + json); + } + JsonObject object = json.getAsJsonObject(); + return new Pos(coordinate(object, "x"), coordinate(object, "y"), coordinate(object, "z"), object.has("yaw") ? object.get("yaw").getAsFloat() : 0.0f, object.has("pitch") ? object.get("pitch").getAsFloat() : 0.0f); + } + + @Override + public JsonElement serialize(Pos src, Type typeOfSrc, JsonSerializationContext context) { + JsonObject object = new JsonObject(); + object.addProperty("x", src.x()); + object.addProperty("y", src.y()); + object.addProperty("z", src.z()); + if (src.yaw() != 0.0f || src.pitch() != 0.0f) { + object.addProperty("yaw", src.yaw()); + object.addProperty("pitch", src.pitch()); + } + return object; + } + + private static double coordinate(JsonObject object, String name) { + JsonElement element = object.get(name); + if (element == null || !element.isJsonPrimitive()) { + throw new JsonParseException("a position needs an " + name + " coordinate"); + } + return element.getAsDouble(); + } + } + + /** + * Reads a key as the plain string an operator would write, {@code minecraft:jack_o_lantern}, + * rather than as the two-field object Aves' adapter expects. A season file is edited by hand; + * the shorter form is the one that gets typed correctly. + */ + private static final class KeyAdapter implements JsonDeserializer, JsonSerializer { + + @Override + public Key deserialize(JsonElement json, Type typeOfT, JsonDeserializationContext context) { + if (!json.isJsonPrimitive()) { + throw new JsonParseException("a key must be a string like minecraft:jack_o_lantern, got " + json); + } + String raw = json.getAsString().trim(); + try { + return Key.key(raw); + } catch (RuntimeException exception) { + throw new JsonParseException("'" + raw + "' is not a valid key; expected something like minecraft:jack_o_lantern", exception); + } + } + + @Override + public JsonElement serialize(Key src, Type typeOfSrc, JsonSerializationContext context) { + return new JsonPrimitive(src.asString()); + } + } + + /** + * Returns the zone seasons are planned in when nothing says otherwise. + * + * @return the editorial time zone, {@code Europe/Berlin} + */ + @Contract(pure = true) + public static ZoneId editorialZone() { + return SeasonWindowActivationStrategy.DEFAULT_ZONE; + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonPrefix.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonPrefix.java new file mode 100644 index 0000000..836a5de --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonPrefix.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.season; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.minimessage.MiniMessage; +import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; + +import java.util.concurrent.atomic.AtomicReference; + +/** + * The component the {@code } tag resolves to in every message the lobby writes. + * + *

    A message prefix is the one seasonal effect that is neither in the world nor per player: it is + * a value the whole server reads while rendering text. It therefore lives here, in one place, read + * by {@code TitanMiniMessageImpl} and written by exactly one season at a time — the + * highest-priority + * one that sets it, since seasons are applied in ascending priority. + * + *

    The default is the one Titan has always had, and a season that ends puts it back. That is the + * same undo discipline as a block: what is restored is whatever was read before the change, so two + * overlapping seasons unwind to the default rather than to each other's guesses. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public final class SeasonPrefix { + + /** The prefix outside any season: the Titan wordmark. */ + public static final Component DEFAULT = MiniMessage.builder().tags(TagResolver.standard()).build().deserialize("Titan"); + + private static final AtomicReference CURRENT = new AtomicReference<>(DEFAULT); + + private SeasonPrefix() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Returns the prefix in force right now. + * + * @return the seasonal prefix, or {@link #DEFAULT} when no season sets one + */ + @Contract(pure = true) + public static Component current() { + return CURRENT.get(); + } + + /** + * Sets the prefix in force. + * + * @param prefix the prefix to use, or {@code null} to go back to {@link #DEFAULT} + */ + public static void current(@Nullable Component prefix) { + CURRENT.set(prefix == null ? DEFAULT : prefix); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonPresentation.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonPresentation.java new file mode 100644 index 0000000..9025bef --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonPresentation.java @@ -0,0 +1,130 @@ +/** + * 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.season; + +import net.minestom.server.item.Material; +import org.jetbrains.annotations.Contract; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * What the seasons a particular player is allowed to see do to what that player is shown — today, + * the navigator icons that have been swapped. + * + *

    This is the half of a season that is computed per viewer rather than written into the world, + * and it is the half preview actually works on (US-4.07). A team member holding + * {@link net.onelitefeather.titan.common.feature.FeatureGate#PREVIEW_PERMISSION} gets a + * presentation built from seasons whose window has not opened, while everybody else gets the + * presentation of the seasons that are live — with no extra permission check anywhere, because the + * filtering already happened in the gate. + * + *

    Overlap is resolved by priority and only by priority (US-4.05): + * {@link #of(List)} folds the seasons in ascending priority, so the highest-priority season is the + * last to write and therefore the one that wins. Load order plays no part; the caller hands the + * list over already ordered by {@link SeasonDefinition#BY_PRIORITY}. + * + * @param icons the navigator destinations whose icon a season replaced, and the material it took + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public record SeasonPresentation(Map icons) { + + private static final SeasonPresentation NONE = new SeasonPresentation(Map.of()); + + /** + * Copies the icon map so a presentation cannot be changed after the fact. + */ + public SeasonPresentation { + icons = Map.copyOf(icons); + } + + /** + * Returns the presentation of a lobby with no season running: nothing swapped, no prefix. + * + * @return the empty presentation + */ + @Contract(pure = true) + public static SeasonPresentation none() { + return NONE; + } + + /** + * Folds the player-scoped effects of the given seasons into one presentation. + * + *

    The switch is exhaustive over the sealed {@link SeasonEffect} hierarchy and has no + * {@code default}, so a new effect record does not compile until it has been decided whether it + * changes what a player is shown (US-4.04). + * + * @param seasons the seasons visible to one player, in ascending priority order + * @return the presentation for that player + */ + @Contract(pure = true) + public static SeasonPresentation of(List seasons) { + if (seasons.isEmpty()) { + return NONE; + } + Map icons = new LinkedHashMap<>(); + for (SeasonDefinition season : seasons) { + for (SeasonEffect effect : season.effects(SeasonEffect.Scope.PLAYER)) { + switch (effect) { + case SeasonEffect.ReplaceIcon icon -> { + Material material = Material.fromKey(icon.material()); + if (material != null) { + icons.put(icon.destination(), material); + } + } + // World effects are placed once for everybody by ConfiguredSeason; there is + // nothing per player to compute for them. + case SeasonEffect.PlaceDecoration ignored -> { + } + case SeasonEffect.PlaceDisplay ignored -> { + } + case SeasonEffect.AmbientSound ignored -> { + } + case SeasonEffect.MessagePrefix ignored -> { + } + } + } + } + return new SeasonPresentation(icons); + } + + /** + * Returns the material a season put on the icon of a navigator destination. + * + * @param destination the navigator destination, for example {@code Survival} + * @return the seasonal material, or an empty optional when no season touched this destination + */ + @Contract(pure = true) + public Optional icon(String destination) { + return Optional.ofNullable(this.icons.get(destination)); + } + + /** + * Returns whether any season changed anything about what a player is shown. + * + * @return whether this presentation differs from {@link #none()} + */ + @Contract(pure = true) + public boolean isEmpty() { + return this.icons.isEmpty(); + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonWindow.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonWindow.java new file mode 100644 index 0000000..e853e68 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonWindow.java @@ -0,0 +1,102 @@ +/** + * 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.season; + +import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy; +import org.jetbrains.annotations.Contract; +import org.jetbrains.annotations.Nullable; +import org.togglz.core.repository.FeatureState; + +import java.time.LocalDateTime; +import java.time.ZoneId; + +/** + * When a season runs: an inclusive start, an exclusive end and the zone both are read in. + * + *

    The window is not evaluated here. It is written onto a {@link FeatureState} and handed to + * {@link SeasonWindowActivationStrategy}, the strategy the release gate already uses — so a season + * and a feature flag answer "is it time yet" with the same code, including the same treatment of + * summer time and the same refusal to open on an unreadable date. Duplicating the comparison would + * have been three lines and a second thing to keep correct across a daylight-saving change. + * + *

    Both bounds are optional. A window with only a start never closes, one with only an end was + * always open, and one with neither is always open — which is how a season that is meant to be + * switched by its kill switch alone is written. + * + * @param from inclusive start, {@code null} when the season has always been running + * @param to exclusive end, {@code null} when the season never ends on its own + * @param zone the zone {@code from} and {@code to} are read in + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public record SeasonWindow(@Nullable LocalDateTime from, @Nullable LocalDateTime to, ZoneId zone) { + + /** + * Rejects a window that ends before it starts — the one mistake in a season file that would + * otherwise produce a season nobody ever sees and no error anywhere. + */ + public SeasonWindow { + if (zone == null) { + throw new IllegalArgumentException("a season window needs a zone"); + } + if (from != null && to != null && !to.isAfter(from)) { + throw new IllegalArgumentException("window ends at " + to + ", which is not after its start " + from); + } + } + + /** + * Creates a window that is always open, planned in the given zone. + * + * @param zone the zone the season is planned in + * @return a window with neither bound set + */ + @Contract(value = "_ -> new", pure = true) + public static SeasonWindow always(ZoneId zone) { + return new SeasonWindow(null, null, zone); + } + + /** + * Writes this window onto a feature state, in the parameters + * {@link SeasonWindowActivationStrategy} reads. + * + * @param state the state to write to + * @return the same state, for chaining + */ + @Contract("_ -> param1") + public FeatureState applyTo(FeatureState state) { + state.setStrategyId(SeasonWindowActivationStrategy.ID); + state.setParameter(SeasonWindowActivationStrategy.PARAM_ZONE, this.zone.getId()); + if (this.from != null) { + state.setParameter(SeasonWindowActivationStrategy.PARAM_FROM, this.from.toString()); + } + if (this.to != null) { + state.setParameter(SeasonWindowActivationStrategy.PARAM_TO, this.to.toString()); + } + return state; + } + + /** + * Returns whether this window has an end at all. + * + * @return whether {@link #to()} is set + */ + @Contract(pure = true) + public boolean hasEnd() { + return this.to != null; + } +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/SeasonalContent.java b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonalContent.java new file mode 100644 index 0000000..483379e --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/SeasonalContent.java @@ -0,0 +1,81 @@ +/** + * 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.season; + +import org.jetbrains.annotations.Contract; + +/** + * Seasonal content that can be switched on and, above all, switched off again (US-4.01, US-4.02). + * + *

    {@link #deactivate()} is the method this interface exists for. Placing decoration is easy and + * gets tested by anybody who looks at the lobby; removing it happens once, months later, usually + * on the day a new season is being deployed, and nobody is watching. So the contract is + * deliberately + * blunt: content that cannot take itself back out of the world is not finished content, and the + * test for it asserts what the world looks like before and after rather than that the method ran. + * + *

    Both methods are idempotent. Activating twice is one activation, deactivating twice is one + * deactivation, and deactivating something that was never activated does nothing — the lobby + * re-evaluates seasons on a timer, so both calls will happen more than once. + * + * @author TheMeinerLP + * @version 1.0.0 + * @since 1.15.0 + */ +public interface SeasonalContent { + + /** + * Returns the id of this content, as written in its file. + * + * @return the season id + */ + @Contract(pure = true) + String id(); + + /** + * Returns which season wins where two overlap: higher applies later, and therefore on top + * (US-4.05). + * + * @return the priority from the season file + */ + @Contract(pure = true) + int priority(); + + /** + * Returns whether this content is currently applied to the world. + * + * @return whether the content is active + */ + @Contract(pure = true) + boolean active(); + + /** + * Applies the content to the world, recording enough to undo every change. + * + * @param canvas the world to apply the content to + */ + void activate(SeasonCanvas canvas); + + /** + * Takes every change back, in the reverse order it was made. + * + *

    Reverse order is not tidiness. Two seasons may have written to the same position, and only + * undoing in reverse puts back what the other one had put there rather than what was underneath + * both. + */ + void deactivate(); +} diff --git a/common/src/main/java/net/onelitefeather/titan/common/season/package-info.java b/common/src/main/java/net/onelitefeather/titan/common/season/package-info.java new file mode 100644 index 0000000..b531048 --- /dev/null +++ b/common/src/main/java/net/onelitefeather/titan/common/season/package-info.java @@ -0,0 +1,40 @@ +/** + * Seasons as data (spec stage 4). + * + *

    The premise the package is built on is measured rather than assumed: cosmetic seasonal events + * move concurrent players by roughly nothing, while content moves them by a lot. A season that + * only decorates therefore has to be nearly free to add, or it is a net loss. InnoGames, six + * iterations into the same event, put the same thing the other way round — the currency and the + * 21-day runtime stayed constant, everything else was open to change. + * + *

    So the split here is deliberate and it is the whole design: + * + *

    + * + *

    The boundary between the two is the question "is this a new value or a new + * verb?". A pumpkin being orange is a value and belongs in JSON. A pumpkin exploding when + * somebody walks over it is a verb and belongs in a + * {@link net.onelitefeather.titan.common.season.SeasonEffect}. The moment the JSON grows an + * {@code if}, it has become a programming language without a type checker, a debugger or a stack + * trace. + * + *

    What keeps that honest is the sealed hierarchy: an effect type nobody handles does not reach + * production, it breaks the compile, and an effect type nobody implements does not reach the world + * either — the loader refuses the file and names the type (US-4.04). + * + *

    {@link net.onelitefeather.titan.common.season.SeasonalContent#deactivate()} is the other load + * bearing part. Everything a season places is written down as it is placed, and taken back in the + * reverse order, so the end of a season is not a cleanup task somebody has to remember. + */ +@NotNullByDefault +package net.onelitefeather.titan.common.season; + +import org.jetbrains.annotations.NotNullByDefault; diff --git a/common/src/main/java/net/onelitefeather/titan/common/utils/component/TitanMiniMessageImpl.java b/common/src/main/java/net/onelitefeather/titan/common/utils/component/TitanMiniMessageImpl.java index 2878e94..8ec50bd 100644 --- a/common/src/main/java/net/onelitefeather/titan/common/utils/component/TitanMiniMessageImpl.java +++ b/common/src/main/java/net/onelitefeather/titan/common/utils/component/TitanMiniMessageImpl.java @@ -16,10 +16,10 @@ */ package net.onelitefeather.titan.common.utils.component; -import net.kyori.adventure.text.Component; import net.kyori.adventure.text.minimessage.MiniMessage; -import net.kyori.adventure.text.minimessage.tag.resolver.Placeholder; +import net.kyori.adventure.text.minimessage.tag.Tag; import net.kyori.adventure.text.minimessage.tag.resolver.TagResolver; +import net.onelitefeather.titan.common.season.SeasonPrefix; import org.jetbrains.annotations.NotNull; import java.util.function.Consumer; @@ -27,11 +27,19 @@ public class TitanMiniMessageImpl implements MiniMessage.Provider { @Override public @NotNull MiniMessage miniMessage() { - return MiniMessage.builder().tags(TagResolver.resolver(TagResolver.standard(), Placeholder.component("prefix", TitanMiniMessageImpl::prefix))).build(); + return MiniMessage.builder().tags(TagResolver.resolver(TagResolver.standard(), prefixResolver())).build(); } - private static @NotNull Component prefix() { - return MiniMessage.builder().tags(TagResolver.resolver(TagResolver.standard())).build().deserialize("Titan"); + /** + * Resolves {@code } when the tag is used rather than when this resolver is built. + * + *

    Adventure caches the {@link MiniMessage} instance a provider returns, and + * {@code Placeholder.component} resolves its value eagerly - so a placeholder built here would + * freeze the prefix at the first message the process ever sends. A running season needs it to + * be read every time; see {@link SeasonPrefix}. + */ + private static @NotNull TagResolver prefixResolver() { + return TagResolver.resolver("prefix", (arguments, context) -> Tag.selfClosingInserting(SeasonPrefix.current())); } @Override diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/RecordingSeasonCanvas.java b/common/src/test/java/net/onelitefeather/titan/common/season/RecordingSeasonCanvas.java new file mode 100644 index 0000000..9a70af0 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/RecordingSeasonCanvas.java @@ -0,0 +1,109 @@ +/** + * 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.season; + +import net.kyori.adventure.key.Key; +import net.kyori.adventure.text.Component; +import net.minestom.server.coordinate.Point; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.instance.block.Block; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +/** + * A real {@link SeasonCanvas} with a notebook. + * + *

    It delegates everything to a {@link MinestomSeasonCanvas} over a running instance — the blocks + * are real blocks, the displays real entities, the handles real Minestom tasks — and only remembers + * what it handed out, so a test can afterwards inspect the state of those very objects. It stands + * in for nothing; a test using it is still asserting against the world. + */ +final class RecordingSeasonCanvas implements SeasonCanvas { + + private final SeasonCanvas delegate; + private final List displays = new ArrayList<>(); + private final List handles = new ArrayList<>(); + + RecordingSeasonCanvas(SeasonCanvas delegate) { + this.delegate = delegate; + } + + /** + * Returns the ids of every display spawned through this canvas, in order. + * + * @return the display ids + */ + List displays() { + return List.copyOf(this.displays); + } + + /** + * Returns the handles of every task scheduled through this canvas, in order. + * + * @return the task handles + */ + List handles() { + return List.copyOf(this.handles); + } + + @Override + public Block blockAt(Point position) { + return this.delegate.blockAt(position); + } + + @Override + public void setBlock(Point position, Block block) { + this.delegate.setBlock(position, block); + } + + @Override + public UUID spawnDisplay(Pos position, Component text) { + UUID id = this.delegate.spawnDisplay(position, text); + this.displays.add(id); + return id; + } + + @Override + public void removeDisplay(UUID displayId) { + this.delegate.removeDisplay(displayId); + } + + @Override + public void playSound(Pos position, Key sound) { + this.delegate.playSound(position, sound); + } + + @Override + public Component prefix() { + return this.delegate.prefix(); + } + + @Override + public void prefix(Component prefix) { + this.delegate.prefix(prefix); + } + + @Override + public Handle schedule(Duration period, Runnable action) { + Handle handle = this.delegate.schedule(period, action); + this.handles.add(handle); + return handle; + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/SeasonDirectorTest.java b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonDirectorTest.java new file mode 100644 index 0000000..4f05e06 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonDirectorTest.java @@ -0,0 +1,182 @@ +/** + * 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.season; + +import net.minestom.server.item.Material; +import net.onelitefeather.titan.common.feature.FeatureDecision; +import net.onelitefeather.titan.common.feature.FeatureGate; +import net.onelitefeather.titan.common.feature.ReleaseStage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +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.assertSame; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers US-4.05 (priority beats load order) and US-4.07 (preview goes through the gate). + */ +class SeasonDirectorTest { + + private static final UUID TEAM = UUID.randomUUID(); + private static final UUID PLAYER = UUID.randomUUID(); + + /** Open at {@link SeasonFixtures#NOW}. */ + private static final String OPEN_FROM = "2026-10-01"; + private static final String OPEN_TO = "2026-11-05"; + + /** Shut at {@link SeasonFixtures#NOW} - it has not started yet. */ + private static final String FUTURE_FROM = "2026-12-01"; + private static final String FUTURE_TO = "2026-12-27"; + + private final SeasonLoader loader = SeasonLoader.create(SeasonFixtures.BERLIN); + + @Test + @DisplayName("where two seasons overlap, the higher priority wins - whatever order they arrived in") + void higherPriorityWinsRegardlessOfLoadOrder() { + SeasonDefinition low = season("low", 1, OPEN_FROM, OPEN_TO, "minecraft:stone"); + SeasonDefinition high = season("high", 9, OPEN_FROM, OPEN_TO, "minecraft:carved_pumpkin"); + + SeasonPresentation forwards = director(List.of(low, high)).presentationFor(PLAYER); + SeasonPresentation backwards = director(List.of(high, low)).presentationFor(PLAYER); + + assertEquals(Material.CARVED_PUMPKIN, forwards.icon("Survival").orElseThrow()); + assertEquals(forwards.icons(), backwards.icons(), "the result must not depend on the order the files were read in"); + } + + @Test + @DisplayName("every permutation of the same seasons produces the same order") + void everyPermutationProducesTheSameOrder() { + List seasons = new ArrayList<>(List.of(season("a", 5, OPEN_FROM, OPEN_TO, "minecraft:stone"), season("b", 1, OPEN_FROM, OPEN_TO, "minecraft:dirt"), season("c", 9, OPEN_FROM, OPEN_TO, "minecraft:carved_pumpkin"))); + List expected = director(seasons).live().stream().map(SeasonDefinition::id).toList(); + + for (int shuffle = 0; shuffle < 20; shuffle++) { + Collections.shuffle(seasons); + assertEquals(expected, director(seasons).live().stream().map(SeasonDefinition::id).toList()); + } + assertEquals(List.of("b", "a", "c"), expected, "ascending priority, so the highest is applied last"); + } + + @Test + @DisplayName("two seasons with the same priority fall back to the id, never to load order") + void equalPrioritiesFallBackToTheId() { + List seasons = new ArrayList<>(List.of(season("zulu", 3, OPEN_FROM, OPEN_TO, "minecraft:stone"), season("alpha", 3, OPEN_FROM, OPEN_TO, "minecraft:dirt"))); + + assertEquals(List.of("alpha", "zulu"), director(seasons).live().stream().map(SeasonDefinition::id).toList()); + Collections.reverse(seasons); + assertEquals(List.of("alpha", "zulu"), director(seasons).live().stream().map(SeasonDefinition::id).toList()); + } + + @Test + @DisplayName("a season whose window has not opened is invisible to a player") + void aShutSeasonIsInvisible() { + SeasonDirector director = director(List.of(season("future", 1, FUTURE_FROM, FUTURE_TO, "minecraft:carved_pumpkin"))); + + assertEquals(List.of(), director.visibleTo(PLAYER)); + assertTrue(director.presentationFor(PLAYER).isEmpty()); + assertSame(SeasonPresentation.none(), director.presentationFor(PLAYER)); + } + + @Test + @DisplayName("a preview holder sees a season whose window has not opened") + void aPreviewHolderSeesAShutSeason() { + SeasonFixtures.MutableAudience audience = new SeasonFixtures.MutableAudience(); + audience.grant(TEAM, FeatureGate.PREVIEW_PERMISSION); + SeasonDefinition future = season("future", 1, FUTURE_FROM, FUTURE_TO, "minecraft:carved_pumpkin"); + SeasonDirector director = SeasonDirector.of(SeasonFixtures.gate(audience), List.of(future)); + + assertEquals(List.of(future), director.visibleTo(TEAM)); + assertEquals(Material.CARVED_PUMPKIN, director.presentationFor(TEAM).icon("Survival").orElseThrow()); + assertEquals(List.of(), director.visibleTo(PLAYER), "and nobody else does"); + } + + @Test + @DisplayName("preview is reported as preview, so nobody mistakes it for the season being live") + void previewIsReportedAsPreview() { + SeasonFixtures.MutableAudience audience = new SeasonFixtures.MutableAudience(); + audience.grant(TEAM, FeatureGate.PREVIEW_PERMISSION); + SeasonDefinition future = season("future", 1, FUTURE_FROM, FUTURE_TO, "minecraft:carved_pumpkin"); + SeasonDirector director = SeasonDirector.of(SeasonFixtures.gate(audience), List.of(future)); + + assertEquals(FeatureDecision.ALLOWED_PREVIEW, director.decisionFor(future, TEAM)); + assertEquals(FeatureDecision.DENIED_WINDOW, director.decisionFor(future, PLAYER)); + assertFalse(director.live().contains(future), "preview is a permission, not a way to start the season early"); + } + + @Test + @DisplayName("preview widens the window and nothing else - a killed season stays killed") + void previewDoesNotBeatTheKillSwitch() { + SeasonFixtures.MutableAudience audience = new SeasonFixtures.MutableAudience(); + audience.grant(TEAM, FeatureGate.PREVIEW_PERMISSION); + SeasonDefinition killed = new SeasonDefinition("killed", false, 1, ReleaseStage.GA, null, season("killed", 1, FUTURE_FROM, FUTURE_TO, "minecraft:stone").window(), List.of()); + + assertEquals(FeatureDecision.DENIED_KILL_SWITCH, SeasonDirector.of(SeasonFixtures.gate(audience), List.of(killed)).decisionFor(killed, TEAM)); + } + + @Test + @DisplayName("preview does not admit somebody the release stage excludes") + void previewDoesNotWidenTheReleaseStage() { + SeasonFixtures.MutableAudience audience = new SeasonFixtures.MutableAudience(); + audience.grant(PLAYER, FeatureGate.PREVIEW_PERMISSION); + SeasonDefinition internal = new SeasonDefinition("internal-only", true, 1, ReleaseStage.INTERNAL, null, season("internal-only", 1, FUTURE_FROM, FUTURE_TO, "minecraft:stone").window(), List.of()); + + assertEquals(FeatureDecision.DENIED_STAGE, SeasonDirector.of(SeasonFixtures.gate(audience), List.of(internal)).decisionFor(internal, PLAYER)); + } + + @Test + @DisplayName("a switched off season is live for nobody, whatever its window says") + void theKillSwitchBeatsAnOpenWindow() { + SeasonDefinition killed = new SeasonDefinition("killed", false, 1, ReleaseStage.GA, null, season("killed", 1, OPEN_FROM, OPEN_TO, "minecraft:stone").window(), List.of()); + + assertEquals(List.of(), director(List.of(killed)).live()); + } + + @Test + @DisplayName("the world of the highest-priority live season is the one that is asked for") + void theWinningSeasonNamesTheWorld() { + SeasonDefinition low = new SeasonDefinition("low", true, 1, ReleaseStage.GA, "autumn", season("low", 1, OPEN_FROM, OPEN_TO, "minecraft:stone").window(), List.of()); + SeasonDefinition high = new SeasonDefinition("high", true, 9, ReleaseStage.GA, "winter", season("high", 9, OPEN_FROM, OPEN_TO, "minecraft:stone").window(), List.of()); + + assertEquals("winter", director(List.of(low, high)).world().orElseThrow()); + assertEquals("winter", director(List.of(high, low)).world().orElseThrow()); + } + + @Test + @DisplayName("with no seasons installed the director answers everything with nothing") + void noSeasonsIsAValidState() { + SeasonDirector director = director(List.of()); + + assertEquals(List.of(), director.live()); + assertEquals(List.of(), director.visibleTo(PLAYER)); + assertTrue(director.presentationFor(PLAYER).isEmpty()); + assertTrue(director.world().isEmpty()); + } + + private SeasonDirector director(List seasons) { + return SeasonDirector.of(SeasonFixtures.gate(new SeasonFixtures.MutableAudience()), seasons); + } + + private SeasonDefinition season(String id, int priority, String from, String to, String material) { + return this.loader.parse(id, SeasonFixtures.seasonJson(id, priority, from, to, material)); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/SeasonFixtures.java b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonFixtures.java new file mode 100644 index 0000000..762ee34 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonFixtures.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.common.season; + +import net.onelitefeather.titan.common.feature.FeatureAudience; +import net.onelitefeather.titan.common.feature.FeatureGate; +import org.togglz.core.activation.DefaultActivationStrategyProvider; +import org.togglz.core.manager.FeatureManager; +import org.togglz.core.manager.FeatureManagerBuilder; +import org.togglz.core.repository.mem.InMemoryStateRepository; +import org.togglz.core.user.NoOpUserProvider; + +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.HashSet; +import java.util.Set; +import java.util.UUID; + +/** + * Shared fixtures for the season tests: a real {@link FeatureGate} on a fixed clock, a writable + * permission source, and the JSON the tests feed the loader. + * + *

    The gate is the production one, not a stub. A season test that faked the gate would prove + * nothing about US-4.07, since the whole requirement is that seasons go through the same gate + * everything else does. + */ +final class SeasonFixtures { + + /** The zone every season in these tests is planned in. */ + static final ZoneId BERLIN = ZoneId.of("Europe/Berlin"); + + /** "Now" for every test: mid-October 2026, chosen so both open and shut windows are easy. */ + static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z"); + + private SeasonFixtures() { + throw new UnsupportedOperationException("This class cannot be instantiated"); + } + + /** + * Creates a gate on a fixed clock, over the given permission source. + * + * @param audience the permission answers the gate reads + * @return the gate + */ + static FeatureGate gate(FeatureAudience audience) { + FeatureManager manager = new FeatureManagerBuilder().featureEnum(net.onelitefeather.titan.common.feature.TitanFeatures.class).stateRepository(new InMemoryStateRepository()).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build(); + return FeatureGate.with(manager, audience, Clock.fixed(NOW, ZoneOffset.UTC), BERLIN); + } + + /** + * Writes a season file into a directory. + * + * @param directory where to write + * @param name the file name, without the extension + * @param json the file content + * @return the file that was written + * @throws java.io.IOException when the file cannot be written + */ + static Path write(Path directory, String name, String json) throws java.io.IOException { + Path file = directory.resolve(name + SeasonLoader.FILE_EXTENSION); + Files.writeString(file, json); + return file; + } + + /** + * Builds a minimal season file with an explicit window and one navigator icon swap, so a test + * can tell two seasons apart by what they show. + * + * @param id the season id + * @param priority the priority + * @param from the window start, or {@code null} + * @param to the window end, or {@code null} + * @param material the material this season puts on the {@code Survival} icon + * @return the season as JSON + */ + static String seasonJson(String id, int priority, String from, String to, String material) { + String window = from == null && to == null ? "{}" : "{" + (from == null ? "" : "\"from\": \"" + from + "\",") + (to == null ? "" : "\"to\": \"" + to + "\",") + "\"zone\": \"Europe/Berlin\"}"; + return """ + { + "id": "%s", + "priority": %d, + "stage": "ga", + "window": %s, + "effects": [ + { "type": "replace_icon", "destination": "Survival", "material": "%s" } + ] + } + """.formatted(id, priority, window, material); + } + + /** + * A permission source a test can grant permissions in. + */ + static final class MutableAudience implements FeatureAudience { + + private final Set permissions = new HashSet<>(); + private final Set groups = new HashSet<>(); + + MutableAudience grant(UUID playerId, String permission) { + this.permissions.add(playerId + "/" + permission); + return this; + } + + MutableAudience join(UUID playerId, String group) { + this.groups.add(playerId + "/" + group); + return this; + } + + @Override + public boolean hasPermission(UUID playerId, String permission) { + return this.permissions.contains(playerId + "/" + permission); + } + + @Override + public boolean inGroup(UUID playerId, String group) { + return this.groups.contains(playerId + "/" + group); + } + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/SeasonIsolationTest.java b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonIsolationTest.java new file mode 100644 index 0000000..8bb0545 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonIsolationTest.java @@ -0,0 +1,128 @@ +/** + * 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.season; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.lang.reflect.RecordComponent; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * US-4.06: no season may require another, so no deployment order can be wrong. + * + *

    The spec suggests an ArchUnit rule for this, and under a design where each season were its own + * jar of Java that would be exactly right. It is not what was built. A season here is a JSON file; + * there are no per-season classes for ArchUnit to constrain, and a rule over the classes that do + * exist would pass forever without ever having been able to fail — the worst kind of green test. + * + *

    What is checked instead is the property the rule was standing in for, at the level where a + * season actually lives: + * + *

    + */ +class SeasonIsolationTest { + + /** + * The types a season's own data is allowed to be made of. A season type appearing here that is + * not on this list would be a way for one season to point at another. + */ + private static final Set> ALLOWED_SEASON_TYPES = Set.of(SeasonWindow.class, SeasonEffect.class); + + private final SeasonLoader loader = SeasonLoader.create(SeasonFixtures.BERLIN); + + @Test + @DisplayName("a season has no field in which it could name another season") + void aSeasonCannotNameAnotherSeason() { + List offenders = new ArrayList<>(); + for (RecordComponent component : SeasonDefinition.class.getRecordComponents()) { + Class type = componentType(component); + if (isSeasonType(type) && !ALLOWED_SEASON_TYPES.contains(type)) { + offenders.add("SeasonDefinition." + component.getName() + " is a " + type.getSimpleName()); + } + } + assertEquals(List.of(), offenders, "a season that can reference a season has a deployment order"); + } + + @Test + @DisplayName("no effect can name a season either") + void noEffectCanNameASeason() { + List offenders = new ArrayList<>(); + for (Class permitted : SeasonEffect.class.getPermittedSubclasses()) { + for (RecordComponent component : permitted.getRecordComponents()) { + Class type = componentType(component); + if (isSeasonType(type)) { + offenders.add(permitted.getSimpleName() + "." + component.getName() + " is a " + type.getSimpleName()); + } + } + } + assertEquals(List.of(), offenders, "an effect that can reference a season has a deployment order"); + } + + @Test + @DisplayName("every effect type has a record, and every record a type - the two lists cannot drift") + void everyEffectTypeIsReachableFromTheSealedHierarchy() { + Set> permitted = Set.of(SeasonEffect.class.getPermittedSubclasses()); + for (SeasonEffect.Type type : SeasonEffect.Type.values()) { + assertTrue(permitted.contains(type.effectClass()), type.id() + " maps to a class outside the sealed hierarchy"); + } + assertEquals(permitted.size(), SeasonEffect.Type.values().length, "a record with no type id cannot be written in a file; a type id with no record cannot be read"); + } + + @Test + @DisplayName("removing one season leaves the others loading and running") + void removingOneSeasonLeavesTheOthers(@TempDir Path directory) throws IOException { + Path first = SeasonFixtures.write(directory, "first", SeasonFixtures.seasonJson("first", 1, null, null, "minecraft:stone")); + SeasonFixtures.write(directory, "second", SeasonFixtures.seasonJson("second", 2, null, null, "minecraft:dirt")); + + assertEquals(List.of("first", "second"), ids(this.loader.loadAll(directory))); + + Files.delete(first); + + assertEquals(List.of("second"), ids(this.loader.loadAll(directory)), "the season that is left must still load on its own"); + } + + private static List ids(List definitions) { + return definitions.stream().map(SeasonDefinition::id).toList(); + } + + private static Class componentType(RecordComponent component) { + // Unwrap List so a "seasons" field would be caught rather than seen as a plain List. + if (component.getGenericType() instanceof java.lang.reflect.ParameterizedType parameterized && parameterized.getActualTypeArguments().length == 1 && parameterized.getActualTypeArguments()[0] instanceof Class argument) { + return argument; + } + return component.getType(); + } + + private static boolean isSeasonType(Class type) { + return type.getPackageName().equals(SeasonDefinition.class.getPackageName()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/SeasonLoaderTest.java b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonLoaderTest.java new file mode 100644 index 0000000..8b4eb5c --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonLoaderTest.java @@ -0,0 +1,250 @@ +/** + * 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.season; + +import net.minestom.server.coordinate.Pos; +import net.onelitefeather.titan.common.feature.ReleaseStage; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Covers US-4.01, US-4.03 and US-4.04: a season is values in a file, and a file that cannot be + * read is a startup failure that names what is wrong with it. + */ +class SeasonLoaderTest { + + private final SeasonLoader loader = SeasonLoader.create(SeasonFixtures.BERLIN); + + @Test + @DisplayName("a season is read out of a file, values and all") + void readsASeasonFromAFile(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "lanterns", """ + { + "id": "lanterns", + "priority": 42, + "stage": "lite", + "world": "lantern-nights", + "window": { "from": "2026-10-01", "to": "2026-11-05T04:00", "zone": "Europe/Berlin" }, + "effects": [ + { "type": "place_decoration", "position": { "x": 1.5, "y": 65, "z": -3.5 }, "block": "minecraft:jack_o_lantern" }, + { "type": "message_prefix", "prefix": "[Lanterns] " } + ] + } + """); + + SeasonDefinition definition = this.loader.load(file); + + assertEquals("lanterns", definition.id()); + assertEquals(42, definition.priority()); + assertEquals(ReleaseStage.LITE, definition.stage()); + assertEquals("lantern-nights", definition.world()); + assertTrue(definition.enabled(), "a file that does not say otherwise is switched on"); + assertEquals(LocalDateTime.parse("2026-10-01T00:00"), definition.window().from()); + assertEquals(LocalDateTime.parse("2026-11-05T04:00"), definition.window().to()); + assertEquals(SeasonFixtures.BERLIN, definition.window().zone()); + assertEquals(2, definition.effects().size()); + assertEquals(new Pos(1.5, 65, -3.5), ((SeasonEffect.PlaceDecoration) definition.effects().getFirst()).position()); + } + + @Test + @DisplayName("an unknown effect type fails at load and the message names it") + void unknownEffectTypeFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "broken", """ + { + "id": "broken", + "effects": [ { "type": "summon_pumpkin_king", "position": { "x": 0, "y": 0, "z": 0 } } ] + } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("summon_pumpkin_king"), "the message must name the type that was written: " + exception.getMessage()); + assertTrue(exception.getMessage().contains("place_decoration"), "the message must list the types that do exist: " + exception.getMessage()); + assertEquals("broken.json", exception.source()); + } + + @Test + @DisplayName("an effect with no type at all is refused just as loudly") + void missingEffectTypeFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "typeless", """ + { "id": "typeless", "effects": [ { "prefix": "hi" } ] } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("no type"), exception.getMessage()); + } + + @Test + @DisplayName("a misspelt block is caught while reading the file, not while placing it") + void unknownBlockFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "typo", """ + { + "id": "typo", + "effects": [ { "type": "place_decoration", "position": { "x": 0, "y": 0, "z": 0 }, "block": "minecraft:jack_o_lanturn" } ] + } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("jack_o_lanturn"), exception.getMessage()); + } + + @Test + @DisplayName("an effect missing a required value is refused, naming the effect") + void incompleteEffectFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "half", """ + { + "id": "half", + "effects": [ { "type": "place_decoration", "position": { "x": 0, "y": 0, "z": 0 } } ] + } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("place_decoration needs a block"), exception.getMessage()); + } + + @Test + @DisplayName("an unreadable date is refused rather than turned into an open season") + void unreadableWindowFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "whenever", """ + { "id": "whenever", "window": { "from": "1. Oktober" } } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("1. Oktober"), exception.getMessage()); + } + + @Test + @DisplayName("a window that ends before it starts is refused") + void backwardsWindowFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "backwards", """ + { "id": "backwards", "window": { "from": "2026-12-01", "to": "2026-11-01" } } + """); + + assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + } + + @Test + @DisplayName("an unknown stage is refused, not silently narrowed") + void unknownStageFailsAtLoad(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "stagey", """ + { "id": "stagey", "stage": "intern" } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("intern"), exception.getMessage()); + } + + @Test + @DisplayName("a season with no window at all is always within it") + void aSeasonWithoutAWindowIsAlwaysOpen(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "eternal", """ + { "id": "eternal", "stage": "ga" } + """); + + SeasonDefinition definition = this.loader.load(file); + + assertNull(definition.window().from()); + assertNull(definition.window().to()); + assertFalse(definition.window().hasEnd()); + assertEquals(SeasonFixtures.BERLIN, definition.window().zone()); + assertEquals(ReleaseStage.GA, definition.stage()); + } + + @Test + @DisplayName("no season directory is not an error - the lobby runs without seasons") + void anAbsentDirectoryLoadsNothing(@TempDir Path directory) { + assertEquals(List.of(), this.loader.loadAll(directory.resolve("not-there"))); + } + + @Test + @DisplayName("two files claiming the same id are refused, naming both") + void duplicateIdsAreRefused(@TempDir Path directory) throws IOException { + SeasonFixtures.write(directory, "one", SeasonFixtures.seasonJson("twin", 1, null, null, "minecraft:stone")); + SeasonFixtures.write(directory, "two", SeasonFixtures.seasonJson("twin", 2, null, null, "minecraft:dirt")); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.loadAll(directory)); + + assertTrue(exception.getMessage().contains("twin"), exception.getMessage()); + } + + @Test + @DisplayName("a window that names a season needs a resolver, and says so when there is none") + void aNamedWindowWithoutAResolverFailsWithAnExplanation(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "wintry", """ + { "id": "wintry", "window": { "named": "WINTER", "year": 2026 } } + """); + + SeasonConfigurationException exception = assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + + assertTrue(exception.getMessage().contains("WINTER"), exception.getMessage()); + assertTrue(exception.getMessage().contains("NamedWindowResolver"), "the message has to point at the seam: " + exception.getMessage()); + } + + @Test + @DisplayName("the seam works: a resolver turns a named window into dates") + void aNamedWindowIsResolvedByTheInstalledResolver(@TempDir Path directory) throws IOException { + // Stands in for what spec stage 2 will install: a SeasonBoundaryStrategy answering where + // winter starts and ends in a given year. Nothing in the season package knows those types. + NamedWindowResolver resolver = (name, year, zone) -> !"WINTER".equals(name) ? Optional.empty() : Optional.of(new SeasonWindow(LocalDateTime.of(year, 12, 21, 0, 0), LocalDateTime.of(year + 1, 3, 20, 0, 0), zone)); + Path file = SeasonFixtures.write(directory, "wintry", """ + { "id": "wintry", "window": { "named": "WINTER", "year": 2026, "zone": "Europe/Berlin" } } + """); + + SeasonDefinition definition = SeasonLoader.create(SeasonFixtures.BERLIN, resolver).load(file); + + assertEquals(LocalDateTime.of(2026, 12, 21, 0, 0), definition.window().from()); + assertEquals(LocalDateTime.of(2027, 3, 20, 0, 0), definition.window().to()); + } + + @Test + @DisplayName("a window cannot both name a season and spell itself out") + void aNamedWindowAndExplicitDatesAreRefused(@TempDir Path directory) throws IOException { + Path file = SeasonFixtures.write(directory, "both", """ + { "id": "both", "window": { "named": "WINTER", "year": 2026, "from": "2026-12-01" } } + """); + + assertThrows(SeasonConfigurationException.class, () -> this.loader.load(file)); + } + + @Test + @DisplayName("files that are not seasons are ignored, not refused") + void nonSeasonFilesAreIgnored(@TempDir Path directory) throws IOException { + SeasonFixtures.write(directory, "real", SeasonFixtures.seasonJson("real", 0, null, null, "minecraft:stone")); + Files.writeString(directory.resolve("README.md"), "not a season"); + + assertEquals(1, this.loader.loadAll(directory).size()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/SeasonSmokeTest.java b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonSmokeTest.java new file mode 100644 index 0000000..2bdea37 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonSmokeTest.java @@ -0,0 +1,182 @@ +/** + * 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.season; + +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityType; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +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 org.junit.jupiter.api.function.Executable; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.fail; + +/** + * US-4.08: every season the repository ships is run end to end, every build. + * + *

    The requirement comes from a specific and repeated failure. Hypixel shipped the same + * Santa-Says bug two Decembers running, because a season that lies dark for eleven months is + * exercised by nothing — not by the people playing, not by the people building the next feature, + * and not by a test suite that only ever covers the code somebody is currently touching. + * + *

    So this test does not test a season. It tests every season in the {@code seasons} + * directory, by walking the same core path the lobby walks: read the file, put the season into a + * running world, look at the world, take the season back out, look again. A season added next year + * is covered by it the moment its file is committed — which is the only version of "a test case per + * pack" that survives contact with a small team. + * + *

    It also fails when a season's end date is more than twelve months in the past (NFR-010). That + * is not tidiness either: an expired season is exactly the code that will be re-activated one day + * by somebody who assumes it still works. + */ +@ExtendWith(MicrotusExtension.class) +class SeasonSmokeTest { + + /** How far past its end a season may sit before the build insists somebody looks at it. */ + private static final int STALE_AFTER_MONTHS = 12; + + @Test + @DisplayName("every shipped season loads, applies itself to a world, and takes itself back out") + void everyShippedSeasonSurvivesActivationAndDeactivation(Env env) { + List seasons = shipped(); + Assertions.assertFalse(seasons.isEmpty(), "the repository ships at least the example season; if that changed, this test has stopped covering anything"); + + List checks = new ArrayList<>(); + for (SeasonDefinition season : seasons) { + checks.add(() -> smoke(env, season)); + } + assertAll(checks); + } + + @Test + @DisplayName("no shipped season is more than a year past its end date") + void noShippedSeasonHasGoneStale() { + // Deliberately the real clock. The point is that this build, run today, notices that a + // season has been sitting unreviewed since last year. + LocalDateTime staleBefore = LocalDate.now().atStartOfDay().minusMonths(STALE_AFTER_MONTHS); + List stale = new ArrayList<>(); + for (SeasonDefinition season : shipped()) { + LocalDateTime end = season.window().to(); + if (end != null && end.isBefore(staleBefore)) { + stale.add(season.id() + " ended at " + end); + } + } + assertEquals(List.of(), stale, "a season more than " + STALE_AFTER_MONTHS + " months past its end has to be reviewed and re-dated, or deleted"); + } + + /** + * Walks one season through the path it will be walked through when somebody reactivates it: put + * it into a world, check the world actually changed, take it back out, check the world is back + * to what it was, and check that the per-player half of it produces something for a viewer who + * may see it. + */ + private static void smoke(Env env, SeasonDefinition season) { + Instance instance = env.createFlatInstance(); + RecordingSeasonCanvas canvas = new RecordingSeasonCanvas(MinestomSeasonCanvas.of(instance)); + ConfiguredSeason content = ConfiguredSeason.of(season); + + Map before = new LinkedHashMap<>(); + for (SeasonEffect effect : season.effects(SeasonEffect.Scope.WORLD)) { + if (effect instanceof SeasonEffect.PlaceDecoration decoration) { + // Through the canvas, which loads the chunk first: at startup nothing is loaded + // yet, and reading straight from the instance would throw before the season ran. + before.put(decoration.position(), canvas.blockAt(decoration.position())); + } + } + long displaysBefore = displays(instance); + + content.activate(canvas); + env.tick(); + + assertTrue(content.active(), season.id() + " reports itself inactive after being activated"); + for (SeasonEffect effect : season.effects(SeasonEffect.Scope.WORLD)) { + if (effect instanceof SeasonEffect.PlaceDecoration decoration) { + Block placed = instance.getBlock(decoration.position()); + assertTrue(placed.compare(resolve(season, decoration.block())), season.id() + " did not place " + decoration.block().asString() + " at " + decoration.position()); + } + } + long expectedDisplays = season.effects(SeasonEffect.Scope.WORLD).stream().filter(SeasonEffect.PlaceDisplay.class::isInstance).count(); + assertEquals(displaysBefore + expectedDisplays, displays(instance), season.id() + " did not spawn all of its displays"); + + content.deactivate(); + env.tick(); + + assertFalse(content.active(), season.id() + " reports itself active after being deactivated"); + for (Map.Entry entry : before.entrySet()) { + assertTrue(instance.getBlock(entry.getKey()).compare(entry.getValue()), season.id() + " left " + instance.getBlock(entry.getKey()).key().asString() + " behind at " + entry.getKey() + "; expected " + entry.getValue().key().asString()); + } + assertEquals(displaysBefore, displays(instance), season.id() + " left a display behind"); + for (SeasonCanvas.Handle handle : canvas.handles()) { + assertFalse(handle.alive(), season.id() + " left a scheduled task running"); + } + + // The per-player half: whatever the file promised has to actually come out the other end. + SeasonPresentation presentation = SeasonPresentation.of(List.of(season)); + assertEquals(season.effects(SeasonEffect.Scope.PLAYER).isEmpty(), presentation.isEmpty(), season.id() + " declares player-facing effects that produce nothing"); + } + + private static Block resolve(SeasonDefinition season, net.kyori.adventure.key.Key key) { + Block block = Block.fromKey(key); + if (block == null) { + return fail(season.id() + " names the unknown block " + key.asString() + "; the loader should have refused it"); + } + return block; + } + + private static long displays(Instance instance) { + return instance.getEntities().stream().filter(entity -> entity.getEntityType() == EntityType.TEXT_DISPLAY).count(); + } + + /** + * Reads the seasons this repository ships, from the {@code seasons} directory at its root. + * + *

    Found by walking up from the working directory rather than hard-coded, because the working + * directory of a Gradle test is the module and not the repository. + */ + private static List shipped() { + return SeasonLoader.create(SeasonFixtures.BERLIN).loadAll(repositoryRoot().resolve(SeasonLoader.DIRECTORY)); + } + + private static Path repositoryRoot() { + Path candidate = Path.of("").toAbsolutePath(); + while (candidate != null) { + if (Files.isRegularFile(candidate.resolve("settings.gradle.kts"))) { + return candidate; + } + candidate = candidate.getParent(); + } + return fail("could not find the repository root from " + Path.of("").toAbsolutePath()); + } +} diff --git a/common/src/test/java/net/onelitefeather/titan/common/season/SeasonWorldTest.java b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonWorldTest.java new file mode 100644 index 0000000..9be6dc2 --- /dev/null +++ b/common/src/test/java/net/onelitefeather/titan/common/season/SeasonWorldTest.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.season; + +import net.kyori.adventure.text.Component; +import net.kyori.adventure.text.format.NamedTextColor; +import net.minestom.server.coordinate.Pos; +import net.minestom.server.entity.EntityType; +import net.minestom.server.instance.Instance; +import net.minestom.server.instance.block.Block; +import net.minestom.testing.Env; +import net.minestom.testing.extension.MicrotusExtension; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * US-4.02, against a running world rather than against a mock. + * + *

    Every assertion here is about what the world looks like, before and after. That is the point: + * a test that checked {@code deactivate()} had been called would pass just as happily against a + * season that removes nothing, which is the failure this requirement exists to prevent. + */ +@ExtendWith(MicrotusExtension.class) +class SeasonWorldTest { + + private static final Pos DECORATION = new Pos(1.5, 45, 2.5); + + private final SeasonLoader loader = SeasonLoader.create(SeasonFixtures.BERLIN); + + @Test + @DisplayName("a season puts its block into the world and puts the old one back when it ends") + void decorationIsPlacedAndTakenBack(Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(DECORATION, Block.SANDSTONE); + assertTrue(instance.getBlock(DECORATION).compare(Block.SANDSTONE), "the fixture has to start from a known block"); + + ConfiguredSeason season = ConfiguredSeason.of(decorationSeason("lanterns", 1, "minecraft:jack_o_lantern")); + SeasonCanvas canvas = MinestomSeasonCanvas.of(instance); + + season.activate(canvas); + assertTrue(instance.getBlock(DECORATION).compare(Block.JACK_O_LANTERN), "the decoration must actually be in the world"); + + season.deactivate(); + assertTrue(instance.getBlock(DECORATION).compare(Block.SANDSTONE), "the block that was there before must be back, not air"); + } + + @Test + @DisplayName("the block that comes back is the one that was there, not the one the author assumed") + void theRestoredBlockIsTheOneThatWasRead(Env env) { + Instance instance = env.createFlatInstance(); + // Somebody re-built this corner of the lobby since the season was written. + instance.setBlock(DECORATION, Block.OAK_PLANKS); + + ConfiguredSeason season = ConfiguredSeason.of(decorationSeason("lanterns", 1, "minecraft:jack_o_lantern")); + season.activate(MinestomSeasonCanvas.of(instance)); + season.deactivate(); + + assertTrue(instance.getBlock(DECORATION).compare(Block.OAK_PLANKS), "restoring a hard-coded block would have left a hole in the build"); + } + + @Test + @DisplayName("two overlapping seasons unwind to the block that was underneath both") + void overlappingSeasonsUnwindToTheOriginalBlock(Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(DECORATION, Block.SANDSTONE); + SeasonCanvas canvas = MinestomSeasonCanvas.of(instance); + SeasonDirector director = SeasonDirector.of(SeasonFixtures.gate(new SeasonFixtures.MutableAudience()), List.of(decorationSeason("low", 1, "minecraft:jack_o_lantern"), decorationSeason("high", 9, "minecraft:sea_lantern"))); + + director.synchronize(canvas); + assertTrue(instance.getBlock(DECORATION).compare(Block.SEA_LANTERN), "the higher priority is applied last and wins"); + + director.deactivateAll(); + assertTrue(instance.getBlock(DECORATION).compare(Block.SANDSTONE), "unwinding both must reach the block that was there before either"); + } + + @Test + @DisplayName("a display is spawned and removed again") + void displayIsSpawnedAndRemoved(Env env) { + Instance instance = env.createFlatInstance(); + long before = displays(instance); + ConfiguredSeason season = ConfiguredSeason.of(this.loader.parse("displayed", """ + { + "id": "displayed", + "effects": [ { "type": "place_display", "position": { "x": 1.5, "y": 47, "z": 2.5 }, "text": "Lantern Nights" } ] + } + """)); + RecordingSeasonCanvas canvas = new RecordingSeasonCanvas(MinestomSeasonCanvas.of(instance)); + + season.activate(canvas); + env.tick(); + assertEquals(before + 1, displays(instance), "the display must be in the world"); + UUID displayId = canvas.displays().getFirst(); + assertNotNull(instance.getEntityByUuid(displayId)); + + season.deactivate(); + env.tick(); + assertEquals(before, displays(instance), "no leftover display when the season ends"); + assertNull(instance.getEntityByUuid(displayId)); + } + + @Test + @DisplayName("a scheduled ambient sound is cancelled when the season ends") + void scheduledTaskIsCancelled(Env env) { + Instance instance = env.createFlatInstance(); + ConfiguredSeason season = ConfiguredSeason.of(this.loader.parse("noisy", """ + { + "id": "noisy", + "effects": [ { "type": "ambient_sound", "position": { "x": 1.5, "y": 45, "z": 2.5 }, "sound": "minecraft:block.campfire.crackle", "periodSeconds": 1 } ] + } + """)); + RecordingSeasonCanvas canvas = new RecordingSeasonCanvas(MinestomSeasonCanvas.of(instance)); + + season.activate(canvas); + assertEquals(1, canvas.handles().size()); + assertTrue(canvas.handles().getFirst().alive(), "the sound loop is a real Minestom task and it is running"); + + season.deactivate(); + assertFalse(canvas.handles().getFirst().alive(), "a season that ends must stop making noise"); + } + + @Test + @DisplayName("the message prefix is replaced and the old one comes back") + void messagePrefixIsReplacedAndRestored(Env env) { + Instance instance = env.createFlatInstance(); + SeasonCanvas canvas = MinestomSeasonCanvas.of(instance); + Component before = canvas.prefix(); + ConfiguredSeason season = ConfiguredSeason.of(this.loader.parse("prefixed", """ + { "id": "prefixed", "effects": [ { "type": "message_prefix", "prefix": "[Lantern Nights]" } ] } + """)); + + season.activate(canvas); + assertEquals(Component.text("[Lantern Nights]", NamedTextColor.GOLD), canvas.prefix()); + assertEquals(canvas.prefix(), SeasonPrefix.current(), "the prefix the tag reads is the one the season set"); + + season.deactivate(); + assertEquals(before, canvas.prefix(), "the lobby's own prefix must come back"); + assertEquals(SeasonPrefix.DEFAULT, SeasonPrefix.current()); + } + + @Test + @DisplayName("deactivating twice is deactivating once, and deactivating what never ran does nothing") + void deactivationIsIdempotent(Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(DECORATION, Block.SANDSTONE); + ConfiguredSeason season = ConfiguredSeason.of(decorationSeason("lanterns", 1, "minecraft:jack_o_lantern")); + + season.deactivate(); + assertFalse(season.active()); + assertTrue(instance.getBlock(DECORATION).compare(Block.SANDSTONE)); + + season.activate(MinestomSeasonCanvas.of(instance)); + season.deactivate(); + // A second, later block change must survive the second deactivate: the undo stack is empty + // and must stay empty rather than replaying the first restore. + instance.setBlock(DECORATION, Block.GOLD_BLOCK); + season.deactivate(); + + assertTrue(instance.getBlock(DECORATION).compare(Block.GOLD_BLOCK), "a spent undo stack must not be replayed"); + } + + @Test + @DisplayName("activating twice does not stack a second copy of the same decoration") + void activationIsIdempotent(Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(DECORATION, Block.SANDSTONE); + ConfiguredSeason season = ConfiguredSeason.of(decorationSeason("lanterns", 1, "minecraft:jack_o_lantern")); + SeasonCanvas canvas = MinestomSeasonCanvas.of(instance); + + season.activate(canvas); + // Without the guard the second activation would record "restore jack_o_lantern" as the undo + // of the first, and the sandstone would never come back. + season.activate(canvas); + season.deactivate(); + + assertTrue(instance.getBlock(DECORATION).compare(Block.SANDSTONE)); + } + + @Test + @DisplayName("when the window shuts, the next synchronise takes the decoration back out") + void aClosingWindowRemovesTheDecoration(Env env) { + Instance instance = env.createFlatInstance(); + instance.setBlock(DECORATION, Block.SANDSTONE); + SeasonCanvas canvas = MinestomSeasonCanvas.of(instance); + // Open at the fixture's "now", and shut once the kill switch is thrown - which is what an + // operator reaches for and what NFR-004 says must not need a restart. + SeasonDefinition open = decorationSeason("lanterns", 1, "minecraft:jack_o_lantern"); + SeasonDirector running = SeasonDirector.of(SeasonFixtures.gate(new SeasonFixtures.MutableAudience()), List.of(open)); + + assertTrue(running.synchronize(canvas)); + assertTrue(instance.getBlock(DECORATION).compare(Block.JACK_O_LANTERN)); + assertFalse(running.synchronize(canvas), "nothing changed, so nothing is touched"); + + running.deactivateAll(); + assertTrue(instance.getBlock(DECORATION).compare(Block.SANDSTONE)); + } + + private SeasonDefinition decorationSeason(String id, int priority, String block) { + return this.loader.parse(id, """ + { + "id": "%s", + "priority": %d, + "stage": "ga", + "effects": [ { "type": "place_decoration", "position": { "x": 1.5, "y": 45, "z": 2.5 }, "block": "%s" } ] + } + """.formatted(id, priority, block)); + } + + private static long displays(Instance instance) { + return instance.getEntities().stream().filter(entity -> entity.getEntityType() == EntityType.TEXT_DISPLAY).count(); + } +} diff --git a/docs/rollout-log.md b/docs/rollout-log.md index 4e1d620..fef547e 100644 --- a/docs/rollout-log.md +++ b/docs/rollout-log.md @@ -55,6 +55,28 @@ 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. +## Saisons + +Eine Saison ist keine Konstante in `TitanFeatures`, sondern eine Datei in +`seasons/` neben dem Prozess — Zeitfenster, Freigabestufe und Notausschalter +stehen darin und werden von demselben `FeatureGate` ausgewertet wie ein Flag. +Der Aufbau der Datei steht in [`seasons/README.md`](../seasons/README.md). + +Den Stand zeigt `/season list`: je Saison Priorität, Freigabestufe, +Zeitfenster, Welt, Notausschalter und ob sie gerade läuft. + +**Vorschau.** Wer `titan.season.preview` hält, sieht die spielerbezogenen +Inhalte einer Saison — heute die Navigator-Icons — auch außerhalb ihres +Zeitfensters. Die Berechtigung weitet nur das Zeitfenster: sie hebt keinen +Notausschalter auf und lässt niemanden an einer Freigabestufe vorbei. Weltdeko +kann sie nicht vorab zeigen; ein Block liegt in der gemeinsamen Welt oder nicht. + +**Ablauf einer Saison.** Endet das Zeitfenster oder wird der Notausschalter +gezogen, nimmt die Lobby innerhalb von fünf Sekunden alles zurück, was die +Saison gesetzt hat — Blöcke auf den zuvor gelesenen Zustand, Anzeigen entfernt, +geplante Aufgaben abgebrochen. Das geschieht auch beim Herunterfahren, damit +eine mitten in der Saison gestoppte Lobby keine Deko in den Weltdateien lässt. + ## Verlauf | Datum | Feature | von → nach | Grund | Verantwortlich | diff --git a/docs/spec-lobby-saison-events.md b/docs/spec-lobby-saison-events.md index db94933..707a715 100644 --- a/docs/spec-lobby-saison-events.md +++ b/docs/spec-lobby-saison-events.md @@ -188,14 +188,45 @@ Abschnitt 6a. | ID | Story | Akzeptanzkriterium (EARS) | Schnittstelle | Priorität | Status | |---|---|---|---|---|---| -| US-4.01 | Als Betreiber möchte ich eine Saison als eigenes Paket ausliefern, damit sie ohne Kernänderung kommt und geht. | The Lobby shall Saison-Inhalte aus einem separat deploybaren Paket laden. | `SeasonalContent`-Contract | Should | offen | -| US-4.02 | Als Entwickler möchte ich, dass ein Paket sich vollständig zurückbaut, damit nach Saisonende keine Reste bleiben. | When ein Saison-Paket deaktiviert wird, shall es alle von ihm gesetzten Blöcke, Anzeigen und geplanten Aufgaben entfernen. | `SeasonalContent#deactivate` | Must | offen | -| US-4.03 | Als Betreiber möchte ich saisonale Werte ohne Neubau ändern, damit Textänderungen kein Deployment brauchen. | The Saison-Inhalte shall Materialien, Texte, Positionen und Zeitfenster aus einer Konfigurationsdatei beziehen. | JSON im Paket | Should | offen | -| US-4.04 | Als Entwickler möchte ich, dass ein unbekannter Effekt-Typ beim Übersetzen auffällt, nicht im Betrieb. | If eine Saison-Konfiguration einen unbekannten Effekt-Typ enthält, then shall das Laden mit einer benannten Fehlermeldung fehlschlagen. | `sealed interface SeasonEffect` | Should | offen | -| US-4.05 | Als Betreiber möchte ich bei zwei gleichzeitigen Paketen eine feste Reihenfolge, damit das Ergebnis nicht von der Ladereihenfolge abhängt. | Where mehrere Saison-Pakete gleichzeitig aktiv sind, shall die Lobby sie nach einem im Paket hinterlegten Prioritätswert anwenden. | Paket-Manifest | Should | offen | -| US-4.06 | Als Betreiber möchte ich, dass ein Paket nicht ein anderes voraussetzt, damit Deployment-Reihenfolgen egal sind. | The Saison-Pakete shall einander nicht direkt referenzieren. | ArchUnit-Regel | Must | offen | -| US-4.07 | Als Betreiber möchte ich vor dem Livegang sehen, wie es aussieht, ohne dass Spieler es sehen. | Where ein Spieler die Berechtigung `titan.season.preview` hat, shall die Lobby ihm Saison-Inhalte auch außerhalb des Zeitfensters zeigen. | `FeatureGate` | Should | offen | -| US-4.08 | Als Betreiber möchte ich beim Reaktivieren einer alten Saison Gewissheit, dass sie noch funktioniert. | Before eine Saison erneut aktiviert wird, shall ein Testlauf ihrer Kernpfade erfolgreich durchlaufen sein. | Testfall je Paket | Should | offen | +| US-4.01 | Als Betreiber möchte ich eine Saison als eigenes Paket ausliefern, damit sie ohne Kernänderung kommt und geht. | The Lobby shall Saison-Inhalte aus einem separat deploybaren Paket laden. | `SeasonalContent`-Contract | Should | umgesetzt | +| US-4.02 | Als Entwickler möchte ich, dass ein Paket sich vollständig zurückbaut, damit nach Saisonende keine Reste bleiben. | When ein Saison-Paket deaktiviert wird, shall es alle von ihm gesetzten Blöcke, Anzeigen und geplanten Aufgaben entfernen. | `SeasonalContent#deactivate` | Must | umgesetzt | +| US-4.03 | Als Betreiber möchte ich saisonale Werte ohne Neubau ändern, damit Textänderungen kein Deployment brauchen. | The Saison-Inhalte shall Materialien, Texte, Positionen und Zeitfenster aus einer Konfigurationsdatei beziehen. | JSON im Paket | Should | umgesetzt | +| US-4.04 | Als Entwickler möchte ich, dass ein unbekannter Effekt-Typ beim Übersetzen auffällt, nicht im Betrieb. | If eine Saison-Konfiguration einen unbekannten Effekt-Typ enthält, then shall das Laden mit einer benannten Fehlermeldung fehlschlagen. | `sealed interface SeasonEffect` | Should | umgesetzt | +| US-4.05 | Als Betreiber möchte ich bei zwei gleichzeitigen Paketen eine feste Reihenfolge, damit das Ergebnis nicht von der Ladereihenfolge abhängt. | Where mehrere Saison-Pakete gleichzeitig aktiv sind, shall die Lobby sie nach einem im Paket hinterlegten Prioritätswert anwenden. | Paket-Manifest | Should | umgesetzt | +| US-4.06 | Als Betreiber möchte ich, dass ein Paket nicht ein anderes voraussetzt, damit Deployment-Reihenfolgen egal sind. | The Saison-Pakete shall einander nicht direkt referenzieren. | ArchUnit-Regel | Must | umgesetzt | +| US-4.07 | Als Betreiber möchte ich vor dem Livegang sehen, wie es aussieht, ohne dass Spieler es sehen. | Where ein Spieler die Berechtigung `titan.season.preview` hat, shall die Lobby ihm Saison-Inhalte auch außerhalb des Zeitfensters zeigen. | `FeatureGate` | Should | teilweise | + +**Zu US-4.01 — das „Paket" ist eine Datei, kein Jar.** Der Plan +([`event-modi-plan.md`](event-modi-plan.md), Abschnitt 2) sah ein +Extension-Jar je Saison vor. Umgesetzt ist die Datenhälfte davon: eine +JSON-Datei in `seasons/` plus, falls gewünscht, ein Weltverzeichnis. Der Grund +steht im Research selbst — dekorative Saisons bewegen die Spielerzahl +messbar nicht, also muss eine Saison nahezu kostenlos hinzuzufügen sein. Ein +Jar je Saison heißt Build, Review und Deployment je Saison; das ist genau der +Aufwand, den die Zahlen nicht rechtfertigen. Sobald eine Saison ein *Verb* +braucht statt eines *Werts*, ist der Weg über einen neuen `SeasonEffect` im +Kern — nicht über ein Jar, das niemand mehr anfasst. + +**Zu US-4.06 — statt einer ArchUnit-Regel.** Die Spalte nennt ArchUnit, und +bei einer Saison je Jar wäre das richtig gewesen. Bei Saisons als Daten gibt es +keine Klassen je Saison, die eine Regel einschränken könnte; eine Regel über +die vorhandenen Klassen wäre grün, ohne je rot werden zu können. Geprüft wird +stattdessen die Eigenschaft selbst, auf der Ebene, auf der eine Saison lebt: +`SeasonIsolationTest` stellt per Reflection fest, dass weder +`SeasonDefinition` noch ein `SeasonEffect` ein Feld hat, in dem eine Saison +eine andere nennen könnte (ein solches Feld lässt den Test fehlschlagen), und +dass das Entfernen einer beliebigen Datei die übrigen unberührt lädt. + +**Zu US-4.07 — was Vorschau kann und was nicht.** Die Prüfung sitzt in +`FeatureGate` und nirgends sonst: eine zweite Berechtigungsprüfung für +saisonale Inhalte gibt es nicht. Wirksam ist sie für alles, was beim Anzeigen +pro Spieler entschieden wird — heute die Navigator-Icons. Deko, Anzeigen und +Klänge stehen dagegen in der gemeinsamen Welt; ein Block liegt dort oder nicht, +und keine Berechtigung kann ihn für einen einzelnen Spieler verbergen. Für +Weltinhalte heißt Vorschau deshalb: eine Lobby mit geöffnetem Zeitfenster +starten, deren Freigabestufe sie beim Team hält. Das ist eine Eigenschaft von +Blöcken, nicht des Gates. +| US-4.08 | Als Betreiber möchte ich beim Reaktivieren einer alten Saison Gewissheit, dass sie noch funktioniert. | Before eine Saison erneut aktiviert wird, shall ein Testlauf ihrer Kernpfade erfolgreich durchlaufen sein. | Testfall je Paket | Should | umgesetzt | ### Stufe 5 — Build-Server im Navigator @@ -356,8 +387,8 @@ bekommen den Zeitpunkt übergeben, statt selbst auf die Uhr zu sehen. Die - [x] Ein Feature lässt sich nacheinander auf intern, lite und ga stellen, ohne dass Code geändert wird. - [x] Der Notausschalter wirkt innerhalb von zwei Sekunden und schlägt Stufe und Zeitfenster. — *Einschränkung: die Prüfung erfolgt beim Zeichnen des Menüs. Wer den Navigator bereits offen hat, sieht das alte Bild bis zum nächsten Öffnen. Ein abgelehnter Eintrag bekommt keinen Klick-Handler, und `InventoryPreClickEvent` wird global abgebrochen — das Fenster ist also eng, aber vorhanden. Die Prüfung zur Klickzeit ist mit Stufe 5 in `GuardedDeliver` nachgezogen, greift dort aber nur für die Berechtigung eines Eintrags, nicht für den Notausschalter.* - [x] 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. +- [x] Die Lobby startet ohne Saison-Paket vollständig funktionsfähig. Fehlt das Verzeichnis `seasons/`, läuft die Lobby ohne saisonalen Inhalt weiter; eine unlesbare Saison-Datei bricht dagegen den Start ab und nennt Datei und Wert. +- [x] Ein Saison-Paket lässt sich entfernen, ohne dass Reste in der Welt zurückbleiben. — *Nachgewiesen gegen eine laufende Welt: `SeasonWorldTest` und `SeasonSmokeTest` vergleichen den Weltzustand vor und nach der Deaktivierung, nicht den Aufruf der Methode. Wiederhergestellt wird der Block, der tatsächlich gelesen wurde, kein angenommener. Nicht abgedeckt: was ein Bauteam während einer laufenden Saison an derselben Position ändert — dessen Änderung wird beim Saisonende überschrieben.* - [ ] Der Rollout-Stand jedes Features ist in `docs/rollout-log.md` nachvollziehbar. --- diff --git a/seasons/README.md b/seasons/README.md new file mode 100644 index 0000000..34b8c99 --- /dev/null +++ b/seasons/README.md @@ -0,0 +1,63 @@ +# Seasons + +One file per season, read at startup from this directory (next to the running +process, alongside `worlds/` and `app.json`). Adding a season is adding a file +and, if it wants its own lobby, a world directory under `worlds/`. It is never +adding Java — if it ever is, the design in `docs/spec-lobby-saison-events.md` +stage 4 has failed and the effect belongs in +`net.onelitefeather.titan.common.season.SeasonEffect` first. + +`example-lantern-nights.json` is a fixture, not content. It ships with +`"enabled": false` so it never places anything in a real lobby, and exists to be +copied and to be exercised by `SeasonSmokeTest` — which is the point of US-4.08: +code that lies dark for eleven months is exercised by nothing unless something +exercises it deliberately. + +## The fields + +| Field | Meaning | +|---|---| +| `id` | Lowercase letters, digits, `-` and `_`. Also the name the release gate knows the season by. Must be unique across this directory. | +| `enabled` | The kill switch. Missing means `true`; `false` makes the season invisible whatever its window says. | +| `priority` | Which season wins where two overlap. Higher is applied later and therefore on top. Missing means `0`. | +| `stage` | `internal`, `lite` or `ga` — the audience the season's per-player content is released to. Missing means `internal`, the narrowest. | +| `world` | The directory under `worlds/` this season wants the lobby to load. Optional. | +| `window.from` | Inclusive start, `2026-12-01` or `2026-12-01T18:00`. Optional. | +| `window.to` | Exclusive end, same formats. Optional. | +| `window.zone` | Zone `from` and `to` are read in. Optional, defaults to `Europe/Berlin`. | +| `effects` | What the season does. | + +A window may instead name a season — `{"named": "WINTER", "year": 2026}` — once +spec stage 2 installs a resolver for the astronomical boundaries. Until then a +file that does so fails to load with a message saying exactly that, rather than +running all year. + +## The effects + +| `type` | Fields | What it does | What ending the season undoes | +|---|---|---|---| +| `place_decoration` | `position`, `block` | Puts a block into the world. | Puts back whatever block was read at that position first. | +| `place_display` | `position`, `text` (MiniMessage) | Spawns a floating text display. | Removes the display. | +| `ambient_sound` | `position`, `sound`, `periodSeconds` | Plays a sound on a loop. | Cancels the scheduled task. | +| `replace_icon` | `destination`, `material` | Swaps a navigator icon. | Nothing to undo — computed per viewer, never written down. | +| `message_prefix` | `prefix` (MiniMessage) | Replaces what the `` tag resolves to in every message the lobby writes. | Puts back the prefix that was in force before, which is Titan's own unless another season set one. | + +A `type` that is not in this table makes the lobby refuse to start, with the +unknown type and the file name in the message. That is deliberate: a season is +looked at once a year, and a typo that is tolerated at startup is a typo nobody +finds until the season is live. + +## Preview + +A holder of `titan.season.preview` sees the per-player half — today, the +navigator icons — outside the window, and `/season list` shows which seasons are +loaded and which are live. + +Everything else is in the world everybody shares: a block is there or it is not, +and no permission can show it to one player. Previewing decoration means opening +the window on a lobby whose release stage keeps it to the team, not walking into +the live lobby and seeing pumpkins nobody else sees. + +## Rollout + +Stage changes belong in `docs/rollout-log.md`, like any other feature. diff --git a/seasons/example-lantern-nights.json b/seasons/example-lantern-nights.json new file mode 100644 index 0000000..88f1b7d --- /dev/null +++ b/seasons/example-lantern-nights.json @@ -0,0 +1,39 @@ +{ + "id": "example-lantern-nights", + "enabled": false, + "priority": 100, + "stage": "internal", + "world": "lantern-nights", + "window": { + "from": "2026-12-01", + "to": "2026-12-27", + "zone": "Europe/Berlin" + }, + "effects": [ + { + "type": "place_decoration", + "position": { "x": 0.5, "y": 65, "z": 0.5 }, + "block": "minecraft:jack_o_lantern" + }, + { + "type": "place_display", + "position": { "x": 0.5, "y": 66.5, "z": 0.5 }, + "text": "Lantern Nights" + }, + { + "type": "ambient_sound", + "position": { "x": 0.5, "y": 65, "z": 0.5 }, + "sound": "minecraft:block.campfire.crackle", + "periodSeconds": 30 + }, + { + "type": "replace_icon", + "destination": "Survival", + "material": "minecraft:carved_pumpkin" + }, + { + "type": "message_prefix", + "prefix": "[Lantern Nights] " + } + ] +}