Skip to content
Merged
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
23 changes: 23 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ dependencies {
implementation(libs.kotlin.stdlib.jdk8)

testImplementation(platform(libs.aonyx.bom))
// compileOnly does not reach the test classpath, and LuckPermsFeatureAudienceTest needs the
// API types to stand in for a running LuckPerms. Same adventure exclude as the main source
// set: the API artifact pulls an adventure version Minestom does not agree with.
testImplementation(libs.luckperms.api) {
exclude(group = "net.kyori.adventure")
}
testImplementation(libs.minestom)
testImplementation(libs.aves)
testImplementation(libs.cyano)
Expand Down Expand Up @@ -87,6 +93,23 @@ tasks {
exclude("META-INF/*.SF", "META-INF/*.DSA", "META-INF/*.RSA")
exclude("module-info.class", "META-INF/versions/**/module-info.class")
duplicatesStrategy = DuplicatesStrategy.EXCLUDE
// EXCLUDE keeps the first copy of every duplicate path and pre-empts
// mergeServiceFiles(), so a service file shipped by two jars would lose all but
// one set of entries. Titan and togglz-core both ship
// META-INF/services/org.togglz.core.spi.ActivationStrategy (the season window here,
// the built-in strategies there) and both must survive - let those paths through so
// the merge transformer sees every copy.
// ServiceFileTransformer, which mergeServiceFiles() installs, deliberately does NOT
// handle META-INF/services/org.codehaus.groovy.runtime.ExtensionModule - that descriptor
// is not a service file and is merged by GroovyExtensionModuleTransformer instead. Letting
// it through as INCLUDE would concatenate two copies verbatim into an unparsable file. No
// Groovy is on the classpath today, so keep the exception narrow and explicit rather than
// widening the pattern above.
filesMatching("META-INF/services/**") {
if (path != "META-INF/services/org.codehaus.groovy.runtime.ExtensionModule") {
duplicatesStrategy = DuplicatesStrategy.INCLUDE
}
}
}
test {
useJUnitPlatform()
Expand Down
28 changes: 27 additions & 1 deletion app/src/main/java/net/onelitefeather/titan/app/Titan.java
Original file line number Diff line number Diff line change
Expand Up @@ -25,21 +25,28 @@
import net.minestom.server.event.item.PickupItemEvent;
import net.minestom.server.event.player.*;
import net.minestom.server.instance.InstanceContainer;
import net.minestom.server.timer.TaskSchedule;
import net.onelitefeather.butterfly.minestom.Butterfly;
import net.onelitefeather.titan.api.deliver.Deliver;
import net.onelitefeather.titan.app.commands.EndCommand;
import net.onelitefeather.titan.app.commands.SeasonCommand;
import net.onelitefeather.titan.app.commands.StopCommand;
import net.onelitefeather.titan.app.feature.LuckPermsFeatureAudience;
import net.onelitefeather.titan.app.helper.NavigationHelper;
import net.onelitefeather.titan.app.listener.*;
import net.onelitefeather.titan.app.player.TitanPlayer;
import net.onelitefeather.titan.common.config.AppConfigProvider;
import net.onelitefeather.titan.common.feature.FeatureGate;
import net.onelitefeather.titan.common.feature.SeasonWindowActivationStrategy;
import net.onelitefeather.titan.common.deliver.DeliverProvider;
import net.onelitefeather.titan.common.event.EntityDismountEvent;
import net.onelitefeather.titan.common.helper.BlockHandlerHelper;
import net.onelitefeather.titan.common.map.MapProvider;
import net.onelitefeather.titan.common.utils.Cancelable;

import java.nio.file.Path;
import java.time.Clock;
import java.time.ZoneId;

public final class Titan {

Expand All @@ -49,23 +56,41 @@ public final class Titan {
private final MapProvider mapProvider;
private final AppConfigProvider appConfigProvider;
private final NavigationHelper navigationHelper;
private final FeatureGate featureGate;

public Titan() {
this(Clock.system(SeasonWindowActivationStrategy.DEFAULT_ZONE), SeasonWindowActivationStrategy.DEFAULT_ZONE);
}

/**
* Creates the lobby with an explicit time source, so seasons and release windows can be tested
* without waiting for real time (NFR-007).
*
* @param clock the time source release windows are evaluated against
* @param zone the zone seasons are planned in
*/
public Titan(Clock clock, ZoneId zone) {
MinecraftServer.getConnectionManager().setPlayerProvider(TitanPlayer::new);
this.path = Path.of("");
BlockHandlerHelper.registerAll();
InstanceContainer instance = MinecraftServer.getInstanceManager().createInstanceContainer();
MinecraftServer.getInstanceManager().registerInstance(instance);
this.mapProvider = MapProvider.create(this.path, instance);
this.appConfigProvider = AppConfigProvider.create(this.path);
this.navigationHelper = NavigationHelper.instance(this.deliver);
this.featureGate = FeatureGate.create(LuckPermsFeatureAudience.create(), clock, zone);
this.navigationHelper = NavigationHelper.instance(this.deliver, this.featureGate);
}

public void initialize() {
initListeners();
initCommands();
Butterfly butterfly = Butterfly.create();
butterfly.load();
// Stages live in a flag file that is reloaded in the background, so a stage change is a
// difference between two observations rather than an event. Walk the features once a
// second so a transition is logged even while nobody is online (US-3.09).
MinecraftServer.getSchedulerManager().scheduleTask(
this.featureGate::pollStageTransitions, TaskSchedule.seconds(1), TaskSchedule.seconds(1));
MinecraftServer.getSchedulerManager().buildShutdownTask(this::terminate);
MinecraftServer.getSchedulerManager().buildShutdownTask(butterfly::terminate);
}
Expand All @@ -77,6 +102,7 @@ public void terminate() {
private void initCommands() {
MinecraftServer.getCommandManager().register(new EndCommand());
MinecraftServer.getCommandManager().register(new StopCommand());
MinecraftServer.getCommandManager().register(new SeasonCommand(this.featureGate));
}

private void initListeners() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,6 @@
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.CommandContext;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

public final class EndCommand extends Command {
Expand All @@ -30,12 +29,12 @@ public EndCommand() {
addSyntax(this::execute);
}

private void execute(@NotNull CommandSender commandSender, @NotNull CommandContext commandContext) {
private void execute(CommandSender commandSender, CommandContext commandContext) {
MinecraftServer.stopCleanly();
System.exit(0);
}

private boolean hasPermission(@NotNull CommandSender commandSender, @Nullable String s) {
private boolean hasPermission(CommandSender commandSender, @Nullable String s) {
return false;
// return commandSender.hasPermission("titan.command.end") ||
// commandSender.hasPermission("lobby.end");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
/**
* Copyright (C) 2025 OneLiteFeather Network
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package net.onelitefeather.titan.app.commands;

import net.kyori.adventure.permission.PermissionChecker;
import net.kyori.adventure.text.Component;
import net.kyori.adventure.text.format.NamedTextColor;
import net.kyori.adventure.util.TriState;
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.command.builder.arguments.ArgumentType;
import net.minestom.server.entity.Player;
import net.onelitefeather.titan.common.feature.FeatureGate;
import net.onelitefeather.titan.common.feature.FeatureStatus;
import net.onelitefeather.titan.common.feature.ReleaseStage;
import org.jetbrains.annotations.Nullable;

import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;

/**
* Shows the rollout state of every feature to the team: release stage, time window and kill switch
* per feature (US-3.08).
*
* <p>Togglz ships an admin console, but it is a servlet application; a Minestom process has no
* servlet container, so a command is what replaces it. The command is bound to
* {@value ReleaseStage#INTERNAL_PERMISSION} — the same permission that defines the internal
* audience — and, like {@code /stop}, is always available from the server console.
*
* @author TheMeinerLP
* @version 1.0.0
* @since 1.15.0
*/
public final class SeasonCommand extends Command {

private static final DateTimeFormatter WINDOW_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");

private final FeatureGate featureGate;

/**
* Creates the command.
*
* @param featureGate the gate the status is read from
*/
public SeasonCommand(FeatureGate featureGate) {
super("season");
this.featureGate = featureGate;
setCondition(SeasonCommand::canUse);
setDefaultExecutor((sender, context) -> sender.sendMessage(
Component.text("Usage: /season status", NamedTextColor.RED)));
addSyntax((sender, context) -> sendStatus(sender), ArgumentType.Literal("status"));
}

/**
* Renders one feature as a single chat line: name, stage, window and kill switch.
*
* @param status the feature status to render
* @return the line shown to the sender
*/
static Component describe(FeatureStatus status) {
Component line = Component.text(status.feature(), NamedTextColor.WHITE).append(Component.text(" | stage ", NamedTextColor.DARK_GRAY)).append(describeStage(status)).append(Component.text(" | window ", NamedTextColor.DARK_GRAY)).append(describeWindow(status));
return line.append(Component.text(" | kill switch ", NamedTextColor.DARK_GRAY)).append(status.killSwitchEngaged() ? Component.text("engaged", NamedTextColor.RED) : Component.text("off", NamedTextColor.GREEN));
}

private static Component describeStage(FeatureStatus status) {
Component stage = Component.text(status.stage().id(), stageColor(status.stage()));
if (status.stageReadable()) {
return stage;
}
// The gate fell back to the narrowest stage. Say so, and name the value that was written:
// "intern" and "premium" are both plausible typos for the ids this project actually uses.
return stage.append(Component.text(" (unreadable: '" + status.unknownStage() + "' is not internal, lite or ga)", NamedTextColor.RED));
}

private static Component describeWindow(FeatureStatus status) {
if (!status.windowReadable()) {
// Never print "always" here: the gate is denying everyone, and a status that says the
// feature runs unbounded would send the operator looking in the wrong place.
return Component.text("unreadable: " + status.windowProblem(), NamedTextColor.RED);
}
if (!status.hasWindow()) {
return Component.text("always", NamedTextColor.GRAY);
}
String from = status.from() == null ? "-∞" : WINDOW_FORMAT.format(status.from());
String to = status.to() == null ? "∞" : WINDOW_FORMAT.format(status.to());
NamedTextColor color = status.withinWindow() ? NamedTextColor.GREEN : NamedTextColor.GOLD;
return Component.text(from + " to " + to + " (" + status.zone().getId() + ", ", color).append(Component.text(status.withinWindow() ? "open)" : "closed)", color));
}

private static NamedTextColor stageColor(ReleaseStage stage) {
return switch (stage) {
case INTERNAL -> NamedTextColor.RED;
case LITE -> NamedTextColor.GOLD;
case GA -> NamedTextColor.GREEN;
};
}

private static boolean canUse(CommandSender sender, @Nullable String commandString) {
if (!(sender instanceof Player)) {
return true;
}
return sender.getOrDefault(PermissionChecker.POINTER, PermissionChecker.always(TriState.FALSE)).test(ReleaseStage.INTERNAL_PERMISSION);
}

/**
* Builds the lines {@code /season status} prints: one header plus one line per feature.
*
* @return the rendered status, in the order the gate reports the features
*/
List<Component> statusLines() {
List<FeatureStatus> statuses = this.featureGate.statuses();
List<Component> lines = new ArrayList<>();
lines.add(Component.text("Feature rollout (" + statuses.size() + ")", NamedTextColor.YELLOW));
if (statuses.isEmpty()) {
lines.add(Component.text("No features are registered.", NamedTextColor.GRAY));
return List.copyOf(lines);
}
for (FeatureStatus status : statuses) {
lines.add(describe(status));
}
return List.copyOf(lines);
}

private void sendStatus(CommandSender sender) {
statusLines().forEach(sender::sendMessage);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@
import net.minestom.server.command.CommandSender;
import net.minestom.server.command.builder.Command;
import net.minestom.server.entity.Player;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;

/**
Expand All @@ -49,7 +48,7 @@ public StopCommand() {
}));
}

private boolean canStop(@NotNull CommandSender sender, @Nullable String commandString) {
private boolean canStop(CommandSender sender, @Nullable String commandString) {
if (!(sender instanceof Player)) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
/**
* Commands the lobby registers with Minestom's command manager.
*/
@NotNullByDefault
package net.onelitefeather.titan.app.commands;

import org.jetbrains.annotations.NotNullByDefault;
Loading
Loading