From 7b5afd9a7ccd586a04c62b16d0a21b2c72e6cefb Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:03:55 +0200 Subject: [PATCH 1/4] feat(feature): let the gate answer for a state it does not own, and honour a preview permission Two additions to FeatureGate, both needed by the season packages. decide(FeatureState, UUID) makes the state an argument rather than something the gate looks up. A season's window lives in its own JSON file, not in the Togglz repository, so without this a season would need its own copy of the kill switch, the release stage and the window comparison - three things that must not exist twice. The preview step implements US-4.07. A holder of titan.season.preview passes the window check and is told so, by ALLOWED_PREVIEW rather than ALLOWED, so a status readout cannot be mistaken for "the season is live". It is asked after the release stage, not before, which keeps it to widening the window: "let me look at it early" and "let me see what I am not cleared for" are different requests and only the first one is being granted here. --- .../titan/common/feature/FeatureDecision.java | 28 +++++++-- .../titan/common/feature/FeatureGate.java | 61 +++++++++++++++++-- 2 files changed, 79 insertions(+), 10 deletions(-) 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. From 7a9f5e0c5449bc48ed358c73fcac9e3c248e8cea Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:04:07 +0200 Subject: [PATCH 2/4] feat(season): load seasons from configuration and make them take themselves back out A season is a JSON file next to the process plus, if it wants one, a world directory. There is no per-season Java and no per-season deployment, which is the whole point: measured telemetry says cosmetic seasonal events move concurrent players by roughly nothing, so a decorative season is only worth having if it is nearly free to add. The stable half is SeasonDirector, SeasonWindow and FeatureGate - who decides, in which order, and when. The seasonal half is values in a file. US-4.04 is caught twice, deliberately. A type nobody implements is refused by SeasonLoader with the type named and the known types listed, at startup rather than at the moment a player would have seen it. A type that exists but that some switch forgot does not compile: the hierarchy is sealed and every switch over it is exhaustive with no default. US-4.02 is the reason SeasonCanvas is shaped the way it is. Every operation on it has an inverse, each effect pushes its own undo as it is applied, and deactivation pops the stack in reverse - so what comes back is the block that was actually read, not the one the season author assumed, and two overlapping seasons unwind to what was underneath both. The tests assert the world before and after rather than that a method ran; deleting the body of the undo loop fails eight of them. Priority resolution is explicit and total: ascending priority, ties broken by id, so every permutation of the same files produces the same order. Seasons cannot reference each other because there is no field in which one could name another - checked by reflection, so adding such a field breaks the test. --- .../titan/common/season/ConfiguredSeason.java | 173 +++++++ .../common/season/MinestomSeasonCanvas.java | 148 ++++++ .../common/season/NamedWindowResolver.java | 73 +++ .../titan/common/season/SeasonCanvas.java | 130 +++++ .../season/SeasonConfigurationException.java | 70 +++ .../titan/common/season/SeasonDefinition.java | 121 +++++ .../titan/common/season/SeasonDirector.java | 260 ++++++++++ .../titan/common/season/SeasonEffect.java | 349 ++++++++++++++ .../titan/common/season/SeasonLoader.java | 452 ++++++++++++++++++ .../titan/common/season/SeasonPrefix.java | 73 +++ .../common/season/SeasonPresentation.java | 130 +++++ .../titan/common/season/SeasonWindow.java | 102 ++++ .../titan/common/season/SeasonalContent.java | 81 ++++ .../titan/common/season/package-info.java | 40 ++ .../utils/component/TitanMiniMessageImpl.java | 18 +- .../common/season/RecordingSeasonCanvas.java | 109 +++++ .../common/season/SeasonDirectorTest.java | 182 +++++++ .../titan/common/season/SeasonFixtures.java | 137 ++++++ .../common/season/SeasonIsolationTest.java | 128 +++++ .../titan/common/season/SeasonLoaderTest.java | 250 ++++++++++ .../titan/common/season/SeasonSmokeTest.java | 182 +++++++ .../titan/common/season/SeasonWorldTest.java | 235 +++++++++ seasons/README.md | 59 +++ seasons/example-lantern-nights.json | 39 ++ 24 files changed, 3536 insertions(+), 5 deletions(-) create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/ConfiguredSeason.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/MinestomSeasonCanvas.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/NamedWindowResolver.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonCanvas.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonConfigurationException.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonDefinition.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonDirector.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonEffect.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonLoader.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonPrefix.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonPresentation.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonWindow.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/SeasonalContent.java create mode 100644 common/src/main/java/net/onelitefeather/titan/common/season/package-info.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/RecordingSeasonCanvas.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/SeasonDirectorTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/SeasonFixtures.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/SeasonIsolationTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/SeasonLoaderTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/SeasonSmokeTest.java create mode 100644 common/src/test/java/net/onelitefeather/titan/common/season/SeasonWorldTest.java create mode 100644 seasons/README.md create mode 100644 seasons/example-lantern-nights.json 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/seasons/README.md b/seasons/README.md new file mode 100644 index 0000000..14657aa --- /dev/null +++ b/seasons/README.md @@ -0,0 +1,59 @@ +# 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) | Puts a prefix in front of lobby messages. | Nothing to undo — same reason. | + +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 — icons and prefixes +— outside the window. Decoration is a block in a world everybody shares, so no +permission can show it to one player; previewing that means opening the window on +a lobby whose release stage keeps it to the team. + +## 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] " + } + ] +} From dfa898dabbae0db8a9a33474fde435180e7a1959 Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:07:20 +0200 Subject: [PATCH 3/4] feat(app): run the installed seasons and let them re-skin a navigator icon The lobby loads seasons/ at boot, puts the live ones into the world, and looks again every five seconds so a window that opens or a kill switch that is thrown takes effect without a restart. Shutdown runs the same undo the end of a season runs, so a lobby that stops mid-season does not leave decoration in the world files. The navigator asks the director per player rather than once per lobby. That is what makes preview mean anything for an icon: a holder of titan.season.preview opens the menu and sees next month's icon while everybody else still sees this month's, and no permission is checked a second time - the gate already decided. /season list is the counterpart to /season status. A season's window lives in its own file rather than in the Togglz repository, so without it the seasons would be invisible to the one command whose job is to spare an operator a trip to the log. The world a season names is read and logged but not yet acted on: choosing the lobby world is spec stage 1 and is not on this branch. --- .../net/onelitefeather/titan/app/Titan.java | 32 +++++++++- .../titan/app/commands/SeasonCommand.java | 59 ++++++++++++++++++- .../titan/app/helper/NavigationHelper.java | 49 +++++++++++++-- .../titan/app/commands/SeasonCommandTest.java | 42 ++++++++++++- .../app/helper/NavigationHelperTest.java | 50 ++++++++++++++++ .../common/navigator/NavigatorEntry.java | 18 +++++- 6 files changed, 238 insertions(+), 12 deletions(-) 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/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. * From 4eb69b6b56b3bfaf9227a03f491b4ff28150e32e Mon Sep 17 00:00:00 2001 From: TheMeinerLP Date: Fri, 28 Aug 2026 12:08:33 +0200 Subject: [PATCH 4/4] docs: record stage 4 and where it departs from the plan Two departures are written down rather than glossed over. The plan called for one extension jar per season; what is built is the data half of that, a JSON file plus a world directory. The reason is the research itself: cosmetic seasons move the player count by roughly nothing, so a jar per season buys a build, a review and a deployment for a return that is not there. US-4.06 names an ArchUnit rule. With seasons as data there are no per-season classes for such a rule to constrain, so it would pass forever without ever being able to fail. What is checked instead is the property itself - no field exists in which one season could name another, and removing any one file leaves the rest loading. US-4.07 is marked partial on purpose. Preview works for what is decided per viewer and cannot work for a block in a shared world; the limit is a property of blocks rather than of the gate, and saying so is more useful than a tick. --- docs/rollout-log.md | 22 ++++++++++++++ docs/spec-lobby-saison-events.md | 51 +++++++++++++++++++++++++------- seasons/README.md | 14 +++++---- 3 files changed, 72 insertions(+), 15 deletions(-) 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 index 14657aa..34b8c99 100644 --- a/seasons/README.md +++ b/seasons/README.md @@ -40,7 +40,7 @@ running all year. | `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) | Puts a prefix in front of lobby messages. | Nothing to undo — same reason. | +| `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 @@ -49,10 +49,14 @@ finds until the season is live. ## Preview -A holder of `titan.season.preview` sees the per-player half — icons and prefixes -— outside the window. Decoration is a block in a world everybody shares, so no -permission can show it to one player; previewing that means opening the window on -a lobby whose release stage keeps it to the team. +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