Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 29 additions & 3 deletions app/src/main/java/net/onelitefeather/titan/app/Titan.java
Original file line number Diff line number Diff line change
Expand Up @@ -46,21 +46,31 @@
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;
import java.time.ZoneId;

public final class Titan {

private static final Logger LOGGER = LoggerFactory.getLogger(Titan.class);

private final Path path;
private final EventNode<Event> eventNode = EventNode.all("titan");
private final Deliver deliver;
private final MapProvider mapProvider;
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);
Expand Down Expand Up @@ -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() {
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -42,28 +44,79 @@
* {@value ReleaseStage#INTERNAL_PERMISSION} — the same permission that defines the internal
* audience — and, like {@code /stop}, is always available from the server console.
*
* <p>{@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 {

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<Component> seasonLines() {
List<SeasonDefinition> definitions = this.seasons.definitions();
List<Component> 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<SeasonDefinition> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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).
*
* <p>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 {
Expand Down Expand Up @@ -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<UUID, PersonalInventoryBuilder> 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) {
Expand Down Expand Up @@ -164,10 +173,14 @@ List<NavigatorLayout.Placement> layoutFor(UUID playerId) {
* @return the public entries, followed by the reachable build servers in a stable order
*/
private List<NavigatorEntry> 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<NavigatorEntry> 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())) {
Expand All @@ -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.
*
Expand All @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -65,14 +68,16 @@ class SeasonCommandTest {
private static final Instant NOW = Instant.parse("2026-10-15T12:00:00Z");

private InMemoryStateRepository repository;
private FeatureGate gate;
private SeasonCommand command;

@BeforeEach
void setUp() {
this.repository = new InMemoryStateRepository();
FeatureManager featureManager = new FeatureManagerBuilder().featureEnum(TitanFeatures.class).stateRepository(this.repository).userProvider(new NoOpUserProvider()).activationStrategyProvider(new DefaultActivationStrategyProvider()).build();
FeatureGate gate = FeatureGate.with(featureManager, FeatureAudience.denyAll(), Clock.fixed(NOW, ZoneOffset.UTC), BERLIN);
this.command = new SeasonCommand(gate);
this.gate = gate;
this.command = new SeasonCommand(gate, SeasonDirector.of(gate, List.of()));
}

private static String plain(Component component) {
Expand All @@ -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<String> 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<String> 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) {
Expand Down
Loading
Loading