feat(docs): complete PlayHours mod implementation with comprehensive documentation
- Add complete PlayHours mod source code with all features: * Schedule enforcement with per-day schedules and midnight-spanning support * Login control with configurable thresholds and exemptions * Warnings and auto-kick system with countdown functionality * Force modes (NORMAL/FORCE_OPEN/FORCE_CLOSED) for maintenance * Whitelist/blacklist system for player access control * Date exceptions for holidays and special events * Multi-language support (English/French) with smart time formatting * LuckPerms integration with vanilla ops fallback * Dynamic MOTD system with real-time schedule display * Comprehensive command system with permission integration * TOML configuration with hot-reload support - Add comprehensive documentation suite: * Installation guide with step-by-step setup instructions * Complete configuration reference with all options * Commands reference with usage examples * Features overview with detailed explanations * MOTD system configuration and customization guide * Permissions system documentation with LuckPerms integration * Technical details covering architecture and limitations * Usage examples with real-world scenarios * Changelog with version history - Add resource files: * Language files (en_us.json, fr_fr.json) with localized messages * Mod metadata (mods.toml) with proper Forge configuration * Resource pack metadata (pack.mcmeta) - Update build configuration: * Gradle build system with proper dependencies * Project properties and version management * Development environment setup - Restructure documentation: * Replace old README.txt with new comprehensive README.md * Create modular documentation structure in docs/ directory * Add cross-references and navigation between documents * Include quick start guide and common use cases This commit represents the complete v1.0.0 release of PlayHours, a production-ready server operation hours enforcement mod for Minecraft Forge 1.20.1.
This commit is contained in:
57
src/main/java/com/mrkayjaydee/playhours/PlayHoursMod.java
Normal file
57
src/main/java/com/mrkayjaydee/playhours/PlayHoursMod.java
Normal file
@@ -0,0 +1,57 @@
|
||||
package com.mrkayjaydee.playhours;
|
||||
|
||||
import com.mojang.logging.LogUtils;
|
||||
import com.mrkayjaydee.playhours.command.HoursCommand;
|
||||
import com.mrkayjaydee.playhours.config.ConfigEventHandler;
|
||||
import com.mrkayjaydee.playhours.config.ServerConfig;
|
||||
import com.mrkayjaydee.playhours.events.LoginGuard;
|
||||
import com.mrkayjaydee.playhours.events.TickScheduler;
|
||||
import net.minecraftforge.common.MinecraftForge;
|
||||
import net.minecraftforge.event.RegisterCommandsEvent;
|
||||
import net.minecraftforge.eventbus.api.IEventBus;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.ModLoadingContext;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
|
||||
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
/**
|
||||
* PlayHours - Server-side mod for enforcing operating hours.
|
||||
* Provides schedule-based access control with warnings, auto-kick, and admin commands.
|
||||
* Compatible with LuckPerms for permissions (soft dependency).
|
||||
*/
|
||||
@Mod(PlayHoursMod.MODID)
|
||||
public class PlayHoursMod {
|
||||
public static final String MODID = "playhours";
|
||||
public static final Logger LOGGER = LogUtils.getLogger();
|
||||
|
||||
/**
|
||||
* Mod constructor. Registers config, events, and command handlers.
|
||||
*/
|
||||
public PlayHoursMod() {
|
||||
IEventBus modEventBus = FMLJavaModLoadingContext.get().getModEventBus();
|
||||
modEventBus.register(ConfigEventHandler.class);
|
||||
// Register as COMMON so the file is created under <server>/config (root), not per-world
|
||||
ModLoadingContext.get().registerConfig(net.minecraftforge.fml.config.ModConfig.Type.COMMON, ServerConfig.SPEC, MODID + ".toml");
|
||||
modEventBus.addListener(this::onCommonSetup);
|
||||
|
||||
// Register gameplay/event listeners
|
||||
MinecraftForge.EVENT_BUS.register(new LoginGuard());
|
||||
MinecraftForge.EVENT_BUS.register(new TickScheduler());
|
||||
MinecraftForge.EVENT_BUS.register(this);
|
||||
|
||||
LOGGER.info("PlayHours loading... (modId={})", MODID);
|
||||
}
|
||||
|
||||
@SubscribeEvent
|
||||
public void onRegisterCommands(RegisterCommandsEvent event) {
|
||||
LOGGER.info("Registering /hours command tree");
|
||||
HoursCommand.register(event.getDispatcher());
|
||||
}
|
||||
|
||||
private void onCommonSetup(final FMLCommonSetupEvent event) {
|
||||
LOGGER.info("PlayHours common setup initialized");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.permissions.PermissionChecker;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
/**
|
||||
* Utility class for building common command patterns.
|
||||
* Reduces code duplication in command registration.
|
||||
*/
|
||||
public final class CommandBuilder {
|
||||
private CommandBuilder() {}
|
||||
|
||||
/**
|
||||
* Creates a literal command with permission requirements.
|
||||
*
|
||||
* @param literal the command literal
|
||||
* @param requiresAdmin whether admin permission is required
|
||||
* @return a literal argument builder
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> literal(String literal, boolean requiresAdmin) {
|
||||
return Commands.literal(literal)
|
||||
.requires(src -> src.getPlayer() == null ||
|
||||
(requiresAdmin ? PermissionChecker.hasAdmin(src.getPlayer()) : PermissionChecker.hasView(src.getPlayer())));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a command that requires admin permission.
|
||||
*
|
||||
* @param literal the command literal
|
||||
* @return a literal argument builder
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> adminLiteral(String literal) {
|
||||
return literal(literal, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a command that requires view permission.
|
||||
*
|
||||
* @param literal the command literal
|
||||
* @return a literal argument builder
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> viewLiteral(String literal) {
|
||||
return literal(literal, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a save and rebuild helper for commands that modify config.
|
||||
*
|
||||
* @param src the command source
|
||||
*/
|
||||
public static void saveAndRebuild(CommandSourceStack src) {
|
||||
com.mrkayjaydee.playhours.config.ConfigEventHandler.save();
|
||||
com.mrkayjaydee.playhours.core.ScheduleService.get().rebuildFromConfig();
|
||||
src.sendSuccess(() -> com.mrkayjaydee.playhours.text.Messages.settingsUpdated(), true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.ArgumentBuilder;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.config.*;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Builder for day-specific command nodes.
|
||||
* Reduces duplication in day command registration.
|
||||
*/
|
||||
public final class DayCommandBuilder {
|
||||
private DayCommandBuilder() {}
|
||||
|
||||
/**
|
||||
* Creates a day command node with add/clear operations for periods.
|
||||
*
|
||||
* @param dayName the day name (e.g., "mon", "tue")
|
||||
* @param configValue the config value to modify
|
||||
* @return a command node
|
||||
*/
|
||||
public static ArgumentBuilder<CommandSourceStack, ?> createDayNode(String dayName,
|
||||
net.minecraftforge.common.ForgeConfigSpec.ConfigValue<List<? extends String>> configValue) {
|
||||
return Commands.literal(dayName)
|
||||
.then(Commands.literal("periods")
|
||||
.then(Commands.literal("add")
|
||||
.then(Commands.argument("range", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String range = StringArgumentType.getString(ctx, "range");
|
||||
TimeRangeValidator.validateRange(range);
|
||||
List<String> list = new ArrayList<>(configValue.get());
|
||||
list.add(range);
|
||||
configValue.set(list);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear")
|
||||
.executes(ctx -> {
|
||||
configValue.set(new ArrayList<>());
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all day command nodes.
|
||||
*
|
||||
* @return a command node containing all day commands
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> createAllDayNodes() {
|
||||
return Commands.literal("day")
|
||||
.then(createDayNode("mon", DaysConfig.MON))
|
||||
.then(createDayNode("tue", DaysConfig.TUE))
|
||||
.then(createDayNode("wed", DaysConfig.WED))
|
||||
.then(createDayNode("thu", DaysConfig.THU))
|
||||
.then(createDayNode("fri", DaysConfig.FRI))
|
||||
.then(createDayNode("sat", DaysConfig.SAT))
|
||||
.then(createDayNode("sun", DaysConfig.SUN));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.CommandDispatcher;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.core.TimeRange;
|
||||
import com.mrkayjaydee.playhours.core.ForceModeFormatter;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import com.mrkayjaydee.playhours.config.*;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.TextStyle;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Main entry point for /hours command registration.
|
||||
* Delegates to specialized builders for different command groups.
|
||||
*/
|
||||
public final class HoursCommand {
|
||||
private HoursCommand() {}
|
||||
|
||||
/**
|
||||
* Registers the /hours command tree with the Minecraft command dispatcher.
|
||||
* @param d the command dispatcher
|
||||
*/
|
||||
public static void register(CommandDispatcher<CommandSourceStack> d) {
|
||||
d.register(Commands.literal("hours")
|
||||
.requires(src -> src.hasPermission(0))
|
||||
.then(registerStatusCommand())
|
||||
.then(registerForceCommand())
|
||||
.then(registerReloadCommand())
|
||||
.then(SetCommandBuilder.build())
|
||||
.then(registerExceptionsCommand())
|
||||
.then(ListsCommandBuilder.build())
|
||||
.then(MOTDCommandBuilder.build())
|
||||
.then(MessagesCommandBuilder.build())
|
||||
);
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> registerStatusCommand() {
|
||||
return CommandBuilder.viewLiteral("status")
|
||||
.executes(ctx -> {
|
||||
if (!ConfigEventHandler.isReady()) {
|
||||
ctx.getSource().sendFailure(Messages.configNotReady());
|
||||
return 0;
|
||||
}
|
||||
ScheduleService s = ScheduleService.get();
|
||||
ZonedDateTime now = ZonedDateTime.now(s.getZoneId());
|
||||
boolean open = s.isOpen(now);
|
||||
String nextClose = s.nextClose(now).map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-");
|
||||
var no = s.nextOpen(now);
|
||||
String day = no.map(z -> z.getDayOfWeek().getDisplayName(TextStyle.FULL, Messages.getJavaLocale())).orElse("-");
|
||||
String time = no.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-");
|
||||
String modeDisplay = ForceModeFormatter.format(s.getForceMode());
|
||||
ctx.getSource().sendSuccess(() -> Messages.statusLine(modeDisplay, open, nextClose, day, time), false);
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> registerForceCommand() {
|
||||
return CommandBuilder.adminLiteral("force")
|
||||
.then(Commands.literal("normal").executes(ctx -> setForce("NORMAL", ctx.getSource())))
|
||||
.then(Commands.literal("open").executes(ctx -> setForce("FORCE_OPEN", ctx.getSource())))
|
||||
.then(Commands.literal("close").executes(ctx -> setForce("FORCE_CLOSED", ctx.getSource())));
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> registerReloadCommand() {
|
||||
return CommandBuilder.adminLiteral("reload")
|
||||
.executes(ctx -> {
|
||||
com.mrkayjaydee.playhours.PlayHoursMod.LOGGER.info("/hours reload invoked by {}", ctx.getSource().getTextName());
|
||||
ConfigEventHandler.reloadFromDisk();
|
||||
ctx.getSource().sendSuccess(() -> Messages.configReloaded(), true);
|
||||
return 1;
|
||||
});
|
||||
}
|
||||
|
||||
private static LiteralArgumentBuilder<CommandSourceStack> registerExceptionsCommand() {
|
||||
return CommandBuilder.adminLiteral("exceptions")
|
||||
.then(Commands.literal("add-open").then(Commands.argument("spec", StringArgumentType.greedyString()).executes(ctx -> {
|
||||
String spec = StringArgumentType.getString(ctx, "spec");
|
||||
List<String> list = new ArrayList<>(ExceptionsConfig.OPEN_DATES.get());
|
||||
list.add(spec);
|
||||
ExceptionsConfig.OPEN_DATES.set(list);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("add-closed").then(Commands.argument("spec", StringArgumentType.greedyString()).executes(ctx -> {
|
||||
String spec = StringArgumentType.getString(ctx, "spec");
|
||||
List<String> list = new ArrayList<>(ExceptionsConfig.CLOSED_DATES.get());
|
||||
list.add(spec);
|
||||
ExceptionsConfig.CLOSED_DATES.set(list);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear").executes(ctx -> {
|
||||
ExceptionsConfig.OPEN_DATES.set(new ArrayList<>());
|
||||
ExceptionsConfig.CLOSED_DATES.set(new ArrayList<>());
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
}));
|
||||
}
|
||||
|
||||
private static int setForce(String mode, CommandSourceStack src) {
|
||||
GeneralConfig.FORCE_MODE.set(mode);
|
||||
CommandBuilder.saveAndRebuild(src);
|
||||
if ("FORCE_OPEN".equals(mode)) src.sendSuccess(() -> Messages.forceOpen(), true);
|
||||
if ("FORCE_CLOSED".equals(mode)) src.sendSuccess(() -> Messages.forceClosed(), true);
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.arguments.BoolArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.config.ListsConfig;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Builds /hours lists command variants for whitelist and blacklist management.
|
||||
* Provides add, remove, toggle, enable/disable, and clear operations.
|
||||
*/
|
||||
public final class ListsCommandBuilder {
|
||||
private ListsCommandBuilder() {}
|
||||
|
||||
/**
|
||||
* Builds the complete /hours lists command node.
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build() {
|
||||
return CommandBuilder.adminLiteral("lists")
|
||||
.then(Commands.literal("whitelist")
|
||||
.then(Commands.literal("toggle").then(Commands.argument("player", StringArgumentType.word()).executes(ctx -> {
|
||||
String p = StringArgumentType.getString(ctx, "player");
|
||||
List<String> set = new ArrayList<>(ListsConfig.WHITELIST.get());
|
||||
if (set.contains(p)) set.remove(p); else set.add(p);
|
||||
ListsConfig.WHITELIST.set(set);
|
||||
ListsConfig.WHITELIST_ENABLED.set(true);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool()).executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
ListsConfig.WHITELIST_ENABLED.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("add").then(Commands.argument("player", StringArgumentType.word()).executes(ctx -> {
|
||||
String p = StringArgumentType.getString(ctx, "player");
|
||||
List<String> set = new ArrayList<>(ListsConfig.WHITELIST.get());
|
||||
if (!set.contains(p)) set.add(p);
|
||||
ListsConfig.WHITELIST.set(set);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("remove").then(Commands.argument("player", StringArgumentType.word()).executes(ctx -> {
|
||||
String p = StringArgumentType.getString(ctx, "player");
|
||||
List<String> set = new ArrayList<>(ListsConfig.WHITELIST.get());
|
||||
set.remove(p);
|
||||
ListsConfig.WHITELIST.set(set);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear").executes(ctx -> {
|
||||
ListsConfig.WHITELIST.set(new ArrayList<>());
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("blacklist")
|
||||
.then(Commands.literal("toggle").then(Commands.argument("player", StringArgumentType.word()).executes(ctx -> {
|
||||
String p = StringArgumentType.getString(ctx, "player");
|
||||
List<String> set = new ArrayList<>(ListsConfig.BLACKLIST.get());
|
||||
if (set.contains(p)) set.remove(p); else set.add(p);
|
||||
ListsConfig.BLACKLIST.set(set);
|
||||
ListsConfig.BLACKLIST_ENABLED.set(true);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("enabled").then(Commands.argument("enabled", BoolArgumentType.bool()).executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
ListsConfig.BLACKLIST_ENABLED.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("add").then(Commands.argument("player", StringArgumentType.word()).executes(ctx -> {
|
||||
String p = StringArgumentType.getString(ctx, "player");
|
||||
List<String> set = new ArrayList<>(ListsConfig.BLACKLIST.get());
|
||||
if (!set.contains(p)) set.add(p);
|
||||
ListsConfig.BLACKLIST.set(set);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("remove").then(Commands.argument("player", StringArgumentType.word()).executes(ctx -> {
|
||||
String p = StringArgumentType.getString(ctx, "player");
|
||||
List<String> set = new ArrayList<>(ListsConfig.BLACKLIST.get());
|
||||
set.remove(p);
|
||||
ListsConfig.BLACKLIST.set(set);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear").executes(ctx -> {
|
||||
ListsConfig.BLACKLIST.set(new ArrayList<>());
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.arguments.BoolArgumentType;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.config.MOTDConfig;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
/**
|
||||
* Builds /hours motd command variants for MOTD (Message of the Day) configuration.
|
||||
* Handles display options, colors, updates, and rotation settings.
|
||||
*/
|
||||
public final class MOTDCommandBuilder {
|
||||
private MOTDCommandBuilder() {}
|
||||
|
||||
/**
|
||||
* Builds the complete /hours motd command node.
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build() {
|
||||
return CommandBuilder.adminLiteral("motd")
|
||||
.then(Commands.literal("show_status").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_STATUS.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("show_next_open").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_NEXT_OPEN.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("show_next_close").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_NEXT_CLOSE.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("show_schedule_times").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_SCHEDULE_TIMES.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("show_on_second_line").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_ON_SECOND_LINE.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("separator").then(Commands.argument("separator", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String sep = StringArgumentType.getString(ctx, "separator");
|
||||
MOTDConfig.SEPARATOR.set(sep);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("custom_format").then(Commands.argument("format", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String format = StringArgumentType.getString(ctx, "format");
|
||||
MOTDConfig.CUSTOM_FORMAT.set(format);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("use_colors").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.USE_COLORS.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("open_color").then(Commands.argument("color", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
String color = StringArgumentType.getString(ctx, "color");
|
||||
MOTDConfig.OPEN_COLOR.set(color);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("closed_color").then(Commands.argument("color", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
String color = StringArgumentType.getString(ctx, "color");
|
||||
MOTDConfig.CLOSED_COLOR.set(color);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("info_color").then(Commands.argument("color", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
String color = StringArgumentType.getString(ctx, "color");
|
||||
MOTDConfig.INFO_COLOR.set(color);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("show_force_mode").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_FORCE_MODE.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("show_countdown").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.SHOW_COUNTDOWN.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("countdown_threshold").then(Commands.argument("minutes", IntegerArgumentType.integer(0, 1440))
|
||||
.executes(ctx -> {
|
||||
int m = IntegerArgumentType.getInteger(ctx, "minutes");
|
||||
MOTDConfig.COUNTDOWN_THRESHOLD_MINUTES.set(m);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("update_delay").then(Commands.argument("seconds", IntegerArgumentType.integer(1, 600))
|
||||
.executes(ctx -> {
|
||||
int s = IntegerArgumentType.getInteger(ctx, "seconds");
|
||||
MOTDConfig.UPDATE_DELAY_SECONDS.set(s);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("rotation_enabled").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
MOTDConfig.ROTATION_ENABLED.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("rotation_interval").then(Commands.argument("seconds", IntegerArgumentType.integer(1, 3600))
|
||||
.executes(ctx -> {
|
||||
int s = IntegerArgumentType.getInteger(ctx, "seconds");
|
||||
MOTDConfig.ROTATION_INTERVAL_SECONDS.set(s);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.config.MessagesConfig;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
/**
|
||||
* Builds /hours messages command variants for custom message configuration.
|
||||
* Allows customization of all player-facing messages and notifications.
|
||||
*/
|
||||
public final class MessagesCommandBuilder {
|
||||
private MessagesCommandBuilder() {}
|
||||
|
||||
/**
|
||||
* Builds the complete /hours messages command node.
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build() {
|
||||
return CommandBuilder.adminLiteral("messages")
|
||||
.then(Commands.literal("access_denied").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.ACCESS_DENIED.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("threshold_denied").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.THRESHOLD_DENIED.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("warn").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.WARN.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("kick").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.KICK.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("force_open").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.FORCE_OPEN.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("force_closed").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.FORCE_CLOSED.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("status_line").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.STATUS_LINE.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("status_open").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.STATUS_OPEN.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("status_closed").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.STATUS_CLOSED.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("countdown").then(Commands.argument("message", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String msg = StringArgumentType.getString(ctx, "message");
|
||||
MessagesConfig.COUNTDOWN.set(msg);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
import com.mojang.brigadier.arguments.BoolArgumentType;
|
||||
import com.mojang.brigadier.arguments.IntegerArgumentType;
|
||||
import com.mojang.brigadier.arguments.StringArgumentType;
|
||||
import com.mojang.brigadier.builder.LiteralArgumentBuilder;
|
||||
import com.mrkayjaydee.playhours.config.*;
|
||||
import net.minecraft.commands.CommandSourceStack;
|
||||
import net.minecraft.commands.Commands;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Builds /hours set command variants for general configuration.
|
||||
* Handles timezone, thresholds, warnings, countdown, exemptions, locale, and default periods.
|
||||
*/
|
||||
public final class SetCommandBuilder {
|
||||
private SetCommandBuilder() {}
|
||||
|
||||
/**
|
||||
* Builds the complete /hours set command node.
|
||||
*/
|
||||
public static LiteralArgumentBuilder<CommandSourceStack> build() {
|
||||
return CommandBuilder.adminLiteral("set")
|
||||
.then(Commands.literal("timezone").then(Commands.argument("zoneId", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String zone = StringArgumentType.getString(ctx, "zoneId");
|
||||
GeneralConfig.TIMEZONE.set(zone);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("threshold").then(Commands.argument("minutes", IntegerArgumentType.integer(0, 1440))
|
||||
.executes(ctx -> {
|
||||
int m = IntegerArgumentType.getInteger(ctx, "minutes");
|
||||
GeneralConfig.CLOSING_THRESHOLD_MINUTES.set(m);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("deny_threshold_login").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
GeneralConfig.DENY_LOGIN_DURING_THRESHOLD.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("warnings").then(Commands.argument("minutes", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String arg = StringArgumentType.getString(ctx, "minutes");
|
||||
List<Integer> list = TimeRangeValidator.parseMinutesList(arg);
|
||||
GeneralConfig.WARNING_MINUTES.set(list);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("countdown").then(Commands.argument("seconds", IntegerArgumentType.integer(0, 60))
|
||||
.executes(ctx -> {
|
||||
int s = IntegerArgumentType.getInteger(ctx, "seconds");
|
||||
GeneralConfig.COUNTDOWN_SECONDS.set(s);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("exempt_bypass_schedule").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
GeneralConfig.EXEMPT_BYPASS_SCHEDULE.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("exempt_bypass_threshold").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
GeneralConfig.EXEMPT_BYPASS_THRESHOLD.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("kick_exempt").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
GeneralConfig.KICK_EXEMPT.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("message_locale").then(Commands.argument("locale", StringArgumentType.word())
|
||||
.executes(ctx -> {
|
||||
String locale = StringArgumentType.getString(ctx, "locale");
|
||||
GeneralConfig.MESSAGE_LOCALE.set(locale);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("motd_enabled").then(Commands.argument("enabled", BoolArgumentType.bool())
|
||||
.executes(ctx -> {
|
||||
boolean enabled = BoolArgumentType.getBool(ctx, "enabled");
|
||||
GeneralConfig.MOTD_ENABLED.set(enabled);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("default").then(Commands.literal("periods")
|
||||
.then(Commands.literal("add").then(Commands.argument("range", StringArgumentType.greedyString())
|
||||
.executes(ctx -> {
|
||||
String r = StringArgumentType.getString(ctx, "range");
|
||||
TimeRangeValidator.validateRange(r);
|
||||
List<String> list = new ArrayList<>(DefaultsConfig.PERIODS.get());
|
||||
list.add(r);
|
||||
DefaultsConfig.PERIODS.set(list);
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
})))
|
||||
.then(Commands.literal("clear").executes(ctx -> {
|
||||
DefaultsConfig.PERIODS.set(new ArrayList<>());
|
||||
CommandBuilder.saveAndRebuild(ctx.getSource());
|
||||
return 1;
|
||||
}))))
|
||||
.then(DayCommandBuilder.createAllDayNodes());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.mrkayjaydee.playhours.command;
|
||||
|
||||
/**
|
||||
* Validates time range strings for commands.
|
||||
* Separates validation logic from command execution.
|
||||
*/
|
||||
public final class TimeRangeValidator {
|
||||
private TimeRangeValidator() {}
|
||||
|
||||
/**
|
||||
* Validates a time range string.
|
||||
*
|
||||
* @param range the time range string to validate
|
||||
* @throws IllegalArgumentException if the range is invalid
|
||||
*/
|
||||
public static void validateRange(String range) {
|
||||
com.mrkayjaydee.playhours.core.TimeRangeValidator.validateRange(range);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a list of minute values from a space-separated string.
|
||||
*
|
||||
* @param input the input string
|
||||
* @return a list of integers
|
||||
* @throws NumberFormatException if any value is not a valid integer
|
||||
*/
|
||||
public static java.util.List<Integer> parseMinutesList(String input) {
|
||||
java.util.List<Integer> list = new java.util.ArrayList<>();
|
||||
for (String s : input.split(" ")) {
|
||||
if (!s.isBlank()) {
|
||||
list.add(Integer.parseInt(s.trim()));
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import com.mrkayjaydee.playhours.PlayHoursMod;
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.config.ModConfig;
|
||||
import net.minecraftforge.fml.event.config.ModConfigEvent;
|
||||
|
||||
/**
|
||||
* Handles configuration loading and reloading events.
|
||||
* Separates config event handling from config structure definition.
|
||||
*/
|
||||
public final class ConfigEventHandler {
|
||||
private ConfigEventHandler() {}
|
||||
|
||||
private static ModConfig serverConfig;
|
||||
private static volatile boolean ready = false;
|
||||
|
||||
public static boolean isReady() {
|
||||
return ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles config loading and reloading events.
|
||||
* Rebuilds schedule cache and reloads messages when config changes.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onConfigEvent(final ModConfigEvent event) {
|
||||
ModConfig cfg = event.getConfig();
|
||||
if (cfg.getType() == ModConfig.Type.COMMON && cfg.getModId().equals(PlayHoursMod.MODID)) {
|
||||
serverConfig = cfg;
|
||||
ready = true;
|
||||
PlayHoursMod.LOGGER.info("PlayHours SERVER config bound: {}", cfg.getFileName());
|
||||
}
|
||||
PlayHoursMod.LOGGER.info("PlayHours config (re)loaded: {}", cfg.getFileName());
|
||||
|
||||
// Rebuild caches and reload messages
|
||||
try {
|
||||
ScheduleService.get().rebuildFromConfig();
|
||||
PlayHoursMod.LOGGER.info("PlayHours schedule rebuilt from config");
|
||||
} catch (Throwable t) {
|
||||
PlayHoursMod.LOGGER.error("Failed to rebuild schedule from config", t);
|
||||
}
|
||||
|
||||
try {
|
||||
Messages.reloadFromConfig();
|
||||
PlayHoursMod.LOGGER.info("PlayHours messages reloaded (locale={})", Messages.getJavaLocale());
|
||||
} catch (Throwable t) {
|
||||
PlayHoursMod.LOGGER.error("Failed to reload messages from config", t);
|
||||
}
|
||||
|
||||
// Persist generated/default config to disk on first load and every reload
|
||||
try {
|
||||
save();
|
||||
PlayHoursMod.LOGGER.info("PlayHours config saved to disk");
|
||||
} catch (Throwable t) {
|
||||
PlayHoursMod.LOGGER.error("Failed to save PlayHours config after load/reload", t);
|
||||
}
|
||||
}
|
||||
|
||||
public static void save() {
|
||||
try {
|
||||
if (serverConfig != null) {
|
||||
serverConfig.save();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
PlayHoursMod.LOGGER.warn("Failed to save PlayHours config: {}", t.toString());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reloads the config from disk and rebuilds all caches.
|
||||
* This is used by the /hours reload command to pick up manual config file edits.
|
||||
*/
|
||||
public static void reloadFromDisk() {
|
||||
try {
|
||||
if (serverConfig != null) {
|
||||
PlayHoursMod.LOGGER.info("Reloading config from disk: {}", serverConfig.getFileName());
|
||||
|
||||
// Get the underlying file config and reload it from disk
|
||||
com.electronwill.nightconfig.core.file.FileConfig fileConfig =
|
||||
(com.electronwill.nightconfig.core.file.FileConfig) serverConfig.getConfigData();
|
||||
|
||||
// Reload the file from disk
|
||||
fileConfig.load();
|
||||
PlayHoursMod.LOGGER.info("Config file reloaded from disk");
|
||||
|
||||
// Force ForgeConfigSpec to re-read the values from the file config
|
||||
// This is necessary because ForgeConfigSpec caches values
|
||||
ServerConfig.SPEC.afterReload();
|
||||
PlayHoursMod.LOGGER.info("Config spec refreshed");
|
||||
|
||||
// Rebuild schedule from the reloaded config
|
||||
ScheduleService.get().rebuildFromConfig();
|
||||
PlayHoursMod.LOGGER.info("Schedule rebuilt from reloaded config");
|
||||
|
||||
// Reload messages with new locale/overrides
|
||||
Messages.reloadFromConfig();
|
||||
PlayHoursMod.LOGGER.info("Messages reloaded (locale={})", Messages.getJavaLocale());
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
PlayHoursMod.LOGGER.error("Failed to reload config from disk", t);
|
||||
throw t;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Day-specific schedule configuration for PlayHours.
|
||||
* Contains opening periods for each day of the week.
|
||||
*/
|
||||
public final class DaysConfig {
|
||||
private DaysConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> MON;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> TUE;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> WED;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> THU;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> FRI;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> SAT;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> SUN;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("days");
|
||||
MON = builder.comment("Monday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("monday", new ArrayList<>(), o -> o instanceof String);
|
||||
TUE = builder.comment("Tuesday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("tuesday", new ArrayList<>(), o -> o instanceof String);
|
||||
WED = builder.comment("Wednesday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("wednesday", new ArrayList<>(), o -> o instanceof String);
|
||||
THU = builder.comment("Thursday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("thursday", new ArrayList<>(), o -> o instanceof String);
|
||||
FRI = builder.comment("Friday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("friday", new ArrayList<>(), o -> o instanceof String);
|
||||
SAT = builder.comment("Saturday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("saturday", new ArrayList<>(), o -> o instanceof String);
|
||||
SUN = builder.comment("Sunday-specific opening periods. Format: 'hh:mm AM-hh:mm PM'. Empty => use defaults.")
|
||||
.defineListAllowEmpty("sunday", new ArrayList<>(), o -> o instanceof String);
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Default schedule configuration for PlayHours.
|
||||
* Contains default daily opening periods used for any day without a specific override.
|
||||
*/
|
||||
public final class DefaultsConfig {
|
||||
private DefaultsConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> PERIODS;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("defaults");
|
||||
PERIODS = builder.comment(
|
||||
"Default daily opening periods used for any day without a specific override.",
|
||||
"Format: 12-hour time ranges 'hh:mm AM-hh:mm PM'.",
|
||||
"Examples: '09:00 AM-12:00 PM', '10:00 PM-02:00 AM' (spans midnight).",
|
||||
"Provide zero or more ranges.")
|
||||
.defineListAllowEmpty("periods", List.of("09:00 AM-06:00 PM"), o -> o instanceof String);
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Exception configuration for PlayHours.
|
||||
* Contains date-specific overrides for open and closed dates.
|
||||
*/
|
||||
public final class ExceptionsConfig {
|
||||
private ExceptionsConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> OPEN_DATES;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> CLOSED_DATES;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("exceptions");
|
||||
OPEN_DATES = builder.comment(
|
||||
"Force OPEN overrides.",
|
||||
"Two accepted formats:",
|
||||
"- Full day: 'YYYY-MM-DD' (opens for the entire day)",
|
||||
"- Window: 'YYYY-MM-DD hh:mm AM-hh:mm PM' (opens only for that range).",
|
||||
"Examples: '2025-12-25', '2025-12-31 07:00 PM-11:30 PM'.")
|
||||
.defineListAllowEmpty("open_dates", new ArrayList<>(), o -> o instanceof String);
|
||||
CLOSED_DATES = builder.comment(
|
||||
"Force CLOSED overrides (take priority over normal schedule).",
|
||||
"Two accepted formats:",
|
||||
"- Full day: 'YYYY-MM-DD'",
|
||||
"- Window: 'YYYY-MM-DD hh:mm AM-hh:mm PM'.")
|
||||
.defineListAllowEmpty("closed_dates", new ArrayList<>(), o -> o instanceof String);
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* General configuration settings for PlayHours.
|
||||
* Contains timezone, force mode, thresholds, warnings, and exemption settings.
|
||||
*/
|
||||
public final class GeneralConfig {
|
||||
private GeneralConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.ConfigValue<String> TIMEZONE;
|
||||
public static ForgeConfigSpec.ConfigValue<String> FORCE_MODE;
|
||||
public static ForgeConfigSpec.IntValue CLOSING_THRESHOLD_MINUTES;
|
||||
public static ForgeConfigSpec.BooleanValue DENY_LOGIN_DURING_THRESHOLD;
|
||||
public static ForgeConfigSpec.BooleanValue KICK_EXEMPT;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends Integer>> WARNING_MINUTES;
|
||||
public static ForgeConfigSpec.IntValue COUNTDOWN_SECONDS;
|
||||
public static ForgeConfigSpec.BooleanValue EXEMPT_BYPASS_SCHEDULE;
|
||||
public static ForgeConfigSpec.BooleanValue EXEMPT_BYPASS_THRESHOLD;
|
||||
public static ForgeConfigSpec.ConfigValue<String> MESSAGE_LOCALE;
|
||||
public static ForgeConfigSpec.BooleanValue MOTD_ENABLED;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("general");
|
||||
TIMEZONE = builder.comment(
|
||||
"Server timezone used to evaluate schedules.",
|
||||
"Must be a valid IANA ZoneId (examples: America/New_York, Europe/Paris, Asia/Tokyo).",
|
||||
"All time ranges and exceptions are interpreted in this timezone.")
|
||||
.define("timezone", "Europe/Paris");
|
||||
FORCE_MODE = builder.comment(
|
||||
"Operating mode override:",
|
||||
"- NORMAL: obey schedule and exceptions",
|
||||
"- FORCE_OPEN: always allow (blacklist still denies; exempt always allows)",
|
||||
"- FORCE_CLOSED: always deny (exempt still allows unless you kick them at close)")
|
||||
.define("force_mode", "NORMAL");
|
||||
CLOSING_THRESHOLD_MINUTES = builder.comment(
|
||||
"Number of minutes before the next scheduled close considered the 'closing threshold'.",
|
||||
"Used together with deny_login_during_threshold to optionally deny NEW logins during the threshold.",
|
||||
"Range: 0..1440 minutes.")
|
||||
.defineInRange("closing_threshold_minutes", 0, 0, 24 * 60);
|
||||
DENY_LOGIN_DURING_THRESHOLD = builder.comment(
|
||||
"If true, NEW logins during the closing threshold are denied with the threshold message.",
|
||||
"Existing players remain until the actual close time.")
|
||||
.define("deny_login_during_threshold", true);
|
||||
KICK_EXEMPT = builder.comment(
|
||||
"If true, even exempt players are kicked at close time.",
|
||||
"If false, exempt players are never kicked by the mod.")
|
||||
.define("kick_exempt", false);
|
||||
WARNING_MINUTES = builder.comment(
|
||||
"Minutes-before-close at which a broadcast warning is sent.",
|
||||
"Provide integers in minutes; example: [15, 10, 5, 1].",
|
||||
"The list is processed every second; duplicates are ignored for a given minute.")
|
||||
.defineList("warning_minutes", Arrays.asList(15, 10, 5, 1), o -> o instanceof Integer i && i >= 0 && i < 24 * 60);
|
||||
COUNTDOWN_SECONDS = builder.comment(
|
||||
"Number of seconds before closing to start countdown messages.",
|
||||
"Set to 0 to disable countdown. Range: 0-60 seconds.",
|
||||
"Example: 5 = sends 'Closing in 5s', 'Closing in 4s', etc.")
|
||||
.defineInRange("countdown_seconds", 5, 0, 60);
|
||||
EXEMPT_BYPASS_SCHEDULE = builder.comment(
|
||||
"If true, exempt players can join even when server is closed.",
|
||||
"Exempt players: ops level 2+, playhours.exempt, playhours.admin permissions.")
|
||||
.define("exempt_bypass_schedule", true);
|
||||
EXEMPT_BYPASS_THRESHOLD = builder.comment(
|
||||
"If true, exempt players can join during closing threshold.",
|
||||
"Only applies when deny_login_during_threshold is true.")
|
||||
.define("exempt_bypass_threshold", true);
|
||||
MESSAGE_LOCALE = builder.comment(
|
||||
"Locale (language) for messages (resource bundle name).",
|
||||
"Examples: en_us, fr_fr. If blank or missing, defaults to en_us.")
|
||||
.define("message_locale", "en_us");
|
||||
MOTD_ENABLED = builder.comment(
|
||||
"Enable MOTD (Message of the Day) modification to show server schedule.",
|
||||
"When enabled, server list will display opening/closing times dynamically.")
|
||||
.define("motd_enabled", true);
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Whitelist and blacklist configuration for PlayHours.
|
||||
* Contains player name lists for access control.
|
||||
*/
|
||||
public final class ListsConfig {
|
||||
private ListsConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.BooleanValue WHITELIST_ENABLED;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> WHITELIST;
|
||||
public static ForgeConfigSpec.BooleanValue BLACKLIST_ENABLED;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> BLACKLIST;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("lists");
|
||||
WHITELIST_ENABLED = builder.comment(
|
||||
"If true, names in 'whitelist' are always allowed (unless FORCE_CLOSED).",
|
||||
"Names are matched case-insensitively.")
|
||||
.define("whitelist_enabled", false);
|
||||
WHITELIST = builder.comment(
|
||||
"Lowercase player names to always allow when whitelist_enabled=true.")
|
||||
.defineListAllowEmpty("whitelist", new ArrayList<>(), o -> o instanceof String);
|
||||
BLACKLIST_ENABLED = builder.comment(
|
||||
"If true, names in 'blacklist' are always denied (even if schedule is open).",
|
||||
"Exempt permission still allows.")
|
||||
.define("blacklist_enabled", false);
|
||||
BLACKLIST = builder.comment(
|
||||
"Lowercase player names to always deny when blacklist_enabled=true.")
|
||||
.defineListAllowEmpty("blacklist", new ArrayList<>(), o -> o instanceof String);
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
158
src/main/java/com/mrkayjaydee/playhours/config/MOTDConfig.java
Normal file
158
src/main/java/com/mrkayjaydee/playhours/config/MOTDConfig.java
Normal file
@@ -0,0 +1,158 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MOTD (Message of the Day) configuration for PlayHours.
|
||||
* Controls how server schedule information is displayed in the server list.
|
||||
*/
|
||||
public final class MOTDConfig {
|
||||
private MOTDConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_STATUS;
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_NEXT_OPEN;
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_NEXT_CLOSE;
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_SCHEDULE_TIMES;
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_ON_SECOND_LINE;
|
||||
public static ForgeConfigSpec.ConfigValue<String> SEPARATOR;
|
||||
public static ForgeConfigSpec.ConfigValue<String> CUSTOM_FORMAT;
|
||||
public static ForgeConfigSpec.BooleanValue USE_COLORS;
|
||||
public static ForgeConfigSpec.ConfigValue<String> OPEN_COLOR;
|
||||
public static ForgeConfigSpec.ConfigValue<String> CLOSED_COLOR;
|
||||
public static ForgeConfigSpec.ConfigValue<String> INFO_COLOR;
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_FORCE_MODE;
|
||||
public static ForgeConfigSpec.BooleanValue SHOW_COUNTDOWN;
|
||||
public static ForgeConfigSpec.IntValue COUNTDOWN_THRESHOLD_MINUTES;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> CUSTOM_LINES;
|
||||
public static ForgeConfigSpec.IntValue UPDATE_DELAY_SECONDS;
|
||||
public static ForgeConfigSpec.BooleanValue ROTATION_ENABLED;
|
||||
public static ForgeConfigSpec.IntValue ROTATION_INTERVAL_SECONDS;
|
||||
public static ForgeConfigSpec.ConfigValue<List<? extends String>> ROTATION_TEMPLATES;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("motd");
|
||||
builder.comment(
|
||||
"MOTD (Message of the Day) settings for server list display.",
|
||||
"Configure what schedule information appears when players view your server in the multiplayer list."
|
||||
);
|
||||
|
||||
SHOW_STATUS = builder.comment(
|
||||
"Show server open/closed status in MOTD.",
|
||||
"Displays whether the server is currently open or closed.")
|
||||
.define("show_status", true);
|
||||
|
||||
SHOW_NEXT_OPEN = builder.comment(
|
||||
"Show next opening time when server is closed.",
|
||||
"Displays the day and time when the server will next open.")
|
||||
.define("show_next_open", true);
|
||||
|
||||
SHOW_NEXT_CLOSE = builder.comment(
|
||||
"Show next closing time when server is open.",
|
||||
"Displays the time when the server will close.")
|
||||
.define("show_next_close", true);
|
||||
|
||||
SHOW_SCHEDULE_TIMES = builder.comment(
|
||||
"Show detailed schedule times.",
|
||||
"Displays a summary of server operating hours.")
|
||||
.define("show_schedule_times", false);
|
||||
|
||||
SHOW_ON_SECOND_LINE = builder.comment(
|
||||
"Place schedule information on the second MOTD line.",
|
||||
"If false, information is appended to the first line.",
|
||||
"Note: The second line may be limited in character count.")
|
||||
.define("show_on_second_line", true);
|
||||
|
||||
SEPARATOR = builder.comment(
|
||||
"Separator between MOTD elements.",
|
||||
"Used to separate different pieces of information.")
|
||||
.define("separator", " | ");
|
||||
|
||||
CUSTOM_FORMAT = builder.comment(
|
||||
"Custom MOTD format string (leave blank to use default).",
|
||||
"Placeholders: %status%, %nextopen%, %nextclose%, %openday%, %opentime%, %closetime%",
|
||||
"Example: 'Status: %status% - Next open: %openday% at %opentime%'",
|
||||
"If blank, the mod will auto-generate MOTD based on other settings.",
|
||||
"NOTE: Minecraft MOTD limit is 2 lines × ~59 chars per line. Longer text will be truncated.")
|
||||
.define("custom_format", "");
|
||||
|
||||
USE_COLORS = builder.comment(
|
||||
"Enable colored MOTD messages.",
|
||||
"Uses Minecraft color codes for status messages.")
|
||||
.define("use_colors", true);
|
||||
|
||||
OPEN_COLOR = builder.comment(
|
||||
"Color code for 'open' status (Minecraft format codes).",
|
||||
"Examples: green, dark_green, aqua. Or use § codes like §a.",
|
||||
"See: https://minecraft.fandom.com/wiki/Formatting_codes")
|
||||
.define("open_color", "green");
|
||||
|
||||
CLOSED_COLOR = builder.comment(
|
||||
"Color code for 'closed' status.",
|
||||
"Examples: red, dark_red, gold.")
|
||||
.define("closed_color", "red");
|
||||
|
||||
INFO_COLOR = builder.comment(
|
||||
"Color code for informational text.",
|
||||
"Examples: gray, yellow, white.")
|
||||
.define("info_color", "gray");
|
||||
|
||||
SHOW_FORCE_MODE = builder.comment(
|
||||
"Show force mode status (FORCE_OPEN/FORCE_CLOSED) in MOTD.",
|
||||
"Useful for admins to see at a glance if schedule is overridden.")
|
||||
.define("show_force_mode", true);
|
||||
|
||||
SHOW_COUNTDOWN = builder.comment(
|
||||
"Show countdown timer when server is closing soon.",
|
||||
"Displays time remaining until close.")
|
||||
.define("show_countdown", true);
|
||||
|
||||
COUNTDOWN_THRESHOLD_MINUTES = builder.comment(
|
||||
"Minutes before close to start showing countdown in MOTD.",
|
||||
"Range: 0-1440 minutes. Set to 0 to disable.",
|
||||
"Example: 30 = show 'Closing in 30 minutes' when close is within 30 min.")
|
||||
.defineInRange("countdown_threshold_minutes", 30, 0, 24 * 60);
|
||||
|
||||
CUSTOM_LINES = builder.comment(
|
||||
"Custom static text lines to add to MOTD (advanced).",
|
||||
"Each entry is a separate line. Leave empty for automatic formatting.",
|
||||
"Supports placeholders: %status%, %nextopen%, %nextclose%, %openday%, %opentime%, %closetime%",
|
||||
"Example: ['Welcome!', 'Status: %status%', 'Next open: %openday%']",
|
||||
"MINECRAFT LIMITS: Maximum 2 lines displayed. Each line ~59 characters max.",
|
||||
"Longer lines will be truncated by Minecraft.")
|
||||
.defineList("custom_lines", Arrays.asList(), o -> o instanceof String);
|
||||
|
||||
UPDATE_DELAY_SECONDS = builder.comment(
|
||||
"Delay in seconds between MOTD updates.",
|
||||
"Lower values update more frequently but use more server resources.",
|
||||
"Higher values update less frequently but may show stale information.",
|
||||
"Range: 1-600 seconds. Recommended: 30-60 seconds.")
|
||||
.defineInRange("update_delay_seconds", 60, 1, 600);
|
||||
|
||||
ROTATION_ENABLED = builder.comment(
|
||||
"Enable MOTD rotation to cycle through multiple MOTDs.",
|
||||
"When enabled, the MOTD will switch between different templates at regular intervals.",
|
||||
"Each template should contain 1-2 lines (max ~59 chars per line).")
|
||||
.define("rotation_enabled", false);
|
||||
|
||||
ROTATION_INTERVAL_SECONDS = builder.comment(
|
||||
"Seconds between MOTD rotations.",
|
||||
"Each rotation_interval_seconds, the MOTD will switch to the next template.",
|
||||
"Range: 1-3600 seconds. Recommended: 10-30 seconds for variety.")
|
||||
.defineInRange("rotation_interval_seconds", 15, 1, 3600);
|
||||
|
||||
ROTATION_TEMPLATES = builder.comment(
|
||||
"List of MOTD templates to rotate through.",
|
||||
"Each entry is a complete MOTD (1-2 lines, separate with \\n).",
|
||||
"Supports placeholders: %status%, %nextopen%, %nextclose%, %openday%, %opentime%, %closetime%",
|
||||
"Example: ['Status: %status%', 'Closes: %closetime%\\nNext: %nextopen%']",
|
||||
"Leave empty or set rotation_enabled=false to disable rotation.",
|
||||
"MINECRAFT LIMITS: Maximum 2 lines per template, ~59 characters per line.")
|
||||
.defineList("rotation_templates", Arrays.asList(), o -> o instanceof String);
|
||||
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
/**
|
||||
* Message configuration for PlayHours.
|
||||
* Contains custom message overrides for various events.
|
||||
*/
|
||||
public final class MessagesConfig {
|
||||
private MessagesConfig() {}
|
||||
|
||||
public static ForgeConfigSpec.ConfigValue<String> ACCESS_DENIED;
|
||||
public static ForgeConfigSpec.ConfigValue<String> THRESHOLD_DENIED;
|
||||
public static ForgeConfigSpec.ConfigValue<String> WARN;
|
||||
public static ForgeConfigSpec.ConfigValue<String> KICK;
|
||||
public static ForgeConfigSpec.ConfigValue<String> FORCE_OPEN;
|
||||
public static ForgeConfigSpec.ConfigValue<String> FORCE_CLOSED;
|
||||
public static ForgeConfigSpec.ConfigValue<String> STATUS_LINE;
|
||||
public static ForgeConfigSpec.ConfigValue<String> STATUS_OPEN;
|
||||
public static ForgeConfigSpec.ConfigValue<String> STATUS_CLOSED;
|
||||
public static ForgeConfigSpec.ConfigValue<String> COUNTDOWN;
|
||||
public static ForgeConfigSpec.ConfigValue<String> CONFIG_NOT_READY;
|
||||
public static ForgeConfigSpec.ConfigValue<String> UNEXPECTED_ERROR;
|
||||
public static ForgeConfigSpec.ConfigValue<String> CONFIG_RELOADED;
|
||||
public static ForgeConfigSpec.ConfigValue<String> INVALID_TIME_RANGE;
|
||||
public static ForgeConfigSpec.ConfigValue<String> FAILED_CLEAR_DEFAULT_PERIODS;
|
||||
public static ForgeConfigSpec.ConfigValue<String> SETTINGS_UPDATED;
|
||||
|
||||
static void init(ForgeConfigSpec.Builder builder) {
|
||||
builder.push("messages");
|
||||
ACCESS_DENIED = builder.comment(
|
||||
"Custom text for access denied on login (server closed).",
|
||||
"Placeholders: %openday%, %opentime%.")
|
||||
.define("access_denied", "");
|
||||
THRESHOLD_DENIED = builder.comment(
|
||||
"Custom text when deny_login_during_threshold denies new logins.",
|
||||
"Placeholders: %openday%, %opentime%.")
|
||||
.define("threshold_denied", "");
|
||||
WARN = builder.comment(
|
||||
"Broadcast warning format when approaching close.",
|
||||
"Placeholders: %minutes%, %s% (plural suffix), %closetime%.")
|
||||
.define("warn", "");
|
||||
KICK = builder.comment(
|
||||
"Kick message at close time.",
|
||||
"Placeholders: %openday%, %opentime%.")
|
||||
.define("kick", "");
|
||||
FORCE_OPEN = builder.comment("Notification when force mode is set to FORCE_OPEN.")
|
||||
.define("force_open", "");
|
||||
FORCE_CLOSED = builder.comment("Notification when force mode is set to FORCE_CLOSED.")
|
||||
.define("force_closed", "");
|
||||
STATUS_LINE = builder.comment(
|
||||
"Text used by /hours status.",
|
||||
"Placeholders: %mode%, %isopen%, %closetime%, %openday%, %opentime%.")
|
||||
.define("status_line", "");
|
||||
STATUS_OPEN = builder.comment(
|
||||
"Text displayed when server is open.",
|
||||
"Used in status messages. No placeholders.")
|
||||
.define("status_open", "");
|
||||
STATUS_CLOSED = builder.comment(
|
||||
"Text displayed when server is closed.",
|
||||
"Used in status messages. No placeholders.")
|
||||
.define("status_closed", "");
|
||||
COUNTDOWN = builder.comment(
|
||||
"Countdown message sent every second before closing.",
|
||||
"Placeholders: %seconds%")
|
||||
.define("countdown", "");
|
||||
CONFIG_NOT_READY = builder.comment(
|
||||
"Error message when config is not ready.",
|
||||
"No placeholders.")
|
||||
.define("config_not_ready", "");
|
||||
UNEXPECTED_ERROR = builder.comment(
|
||||
"Generic error message for unexpected errors.",
|
||||
"No placeholders.")
|
||||
.define("unexpected_error", "");
|
||||
CONFIG_RELOADED = builder.comment(
|
||||
"Success message when config is reloaded.",
|
||||
"No placeholders.")
|
||||
.define("config_reloaded", "");
|
||||
INVALID_TIME_RANGE = builder.comment(
|
||||
"Error message for invalid time range format.",
|
||||
"No placeholders.")
|
||||
.define("invalid_time_range", "");
|
||||
FAILED_CLEAR_DEFAULT_PERIODS = builder.comment(
|
||||
"Error message when clearing default periods fails.",
|
||||
"No placeholders.")
|
||||
.define("failed_clear_default_periods", "");
|
||||
SETTINGS_UPDATED = builder.comment(
|
||||
"Success message when settings are updated.",
|
||||
"No placeholders.")
|
||||
.define("settings_updated", "");
|
||||
builder.pop();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.mrkayjaydee.playhours.config;
|
||||
|
||||
import net.minecraftforge.common.ForgeConfigSpec;
|
||||
|
||||
/**
|
||||
* Main Forge SERVER configuration for PlayHours.
|
||||
* Delegates to individual config classes for better organization.
|
||||
*/
|
||||
public final class ServerConfig {
|
||||
private ServerConfig() {}
|
||||
|
||||
private static final ForgeConfigSpec.Builder BUILDER = new ForgeConfigSpec.Builder();
|
||||
|
||||
public static final ForgeConfigSpec SPEC;
|
||||
static {
|
||||
// Initialize all config sections in order
|
||||
GeneralConfig.init(BUILDER);
|
||||
DefaultsConfig.init(BUILDER);
|
||||
DaysConfig.init(BUILDER);
|
||||
ExceptionsConfig.init(BUILDER);
|
||||
ListsConfig.init(BUILDER);
|
||||
MessagesConfig.init(BUILDER);
|
||||
MOTDConfig.init(BUILDER);
|
||||
|
||||
SPEC = BUILDER.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
import com.mrkayjaydee.playhours.PlayHoursMod;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Handles open and closed date exceptions.
|
||||
* Separates exception logic from main schedule logic.
|
||||
*/
|
||||
public final class ExceptionHandler {
|
||||
private ExceptionHandler() {}
|
||||
|
||||
public static ExceptionHandler create() {
|
||||
return new ExceptionHandler();
|
||||
}
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
private volatile List<String> openExceptions = List.of();
|
||||
private volatile List<String> closedExceptions = List.of();
|
||||
|
||||
/**
|
||||
* Updates the exception lists.
|
||||
*/
|
||||
public void updateExceptions(List<String> openExceptions, List<String> closedExceptions) {
|
||||
this.openExceptions = List.copyOf(Objects.requireNonNull(openExceptions, "openExceptions"));
|
||||
this.closedExceptions = List.copyOf(Objects.requireNonNull(closedExceptions, "closedExceptions"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given time falls within a closed exception.
|
||||
*
|
||||
* @param dateTime the time to check
|
||||
* @return true if the time is in a closed exception
|
||||
*/
|
||||
public boolean isClosedException(ZonedDateTime dateTime) {
|
||||
String date = dateTime.format(DATE_FORMAT);
|
||||
|
||||
// Full-day closed if present
|
||||
if (closedExceptions.stream().anyMatch(s -> s.trim().equals(date))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Timed closed entries
|
||||
for (String exception : closedExceptions) {
|
||||
if (exception.contains(" ")) {
|
||||
String[] parts = exception.split(" ", 2);
|
||||
if (!parts[0].equals(date)) continue;
|
||||
|
||||
try {
|
||||
TimeRange range = TimeRange.parse(parts[1]);
|
||||
if (range.contains(dateTime.toLocalTime())) {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayHoursMod.LOGGER.debug("Failed to parse closed exception '{}': {}", exception, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given time falls within an open exception.
|
||||
*
|
||||
* @param dateTime the time to check
|
||||
* @return true if the time is in an open exception
|
||||
*/
|
||||
public boolean isOpenException(ZonedDateTime dateTime) {
|
||||
String date = dateTime.format(DATE_FORMAT);
|
||||
|
||||
for (String exception : openExceptions) {
|
||||
if (exception.equals(date)) return true; // whole day open
|
||||
|
||||
if (exception.contains(" ")) {
|
||||
String[] parts = exception.split(" ", 2);
|
||||
if (!parts[0].equals(date)) continue;
|
||||
|
||||
try {
|
||||
TimeRange range = TimeRange.parse(parts[1]);
|
||||
if (range.contains(dateTime.toLocalTime())) {
|
||||
return true;
|
||||
}
|
||||
} catch (Exception e) {
|
||||
PlayHoursMod.LOGGER.debug("Failed to parse open exception '{}': {}", exception, e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
22
src/main/java/com/mrkayjaydee/playhours/core/ForceMode.java
Normal file
22
src/main/java/com/mrkayjaydee/playhours/core/ForceMode.java
Normal file
@@ -0,0 +1,22 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
/**
|
||||
* Defines the operational mode of the server.
|
||||
* Controls whether the server schedule is enforced or overridden.
|
||||
*/
|
||||
public enum ForceMode {
|
||||
/**
|
||||
* Normal operation - obey schedule and exceptions.
|
||||
*/
|
||||
NORMAL,
|
||||
|
||||
/**
|
||||
* Force open - always allow access (blacklist still denies; exempt always allows).
|
||||
*/
|
||||
FORCE_OPEN,
|
||||
|
||||
/**
|
||||
* Force closed - always deny access (exempt still allows unless kick_exempt is true).
|
||||
*/
|
||||
FORCE_CLOSED
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import com.mrkayjaydee.playhours.text.MessageKeys;
|
||||
|
||||
/**
|
||||
* Utility for formatting ForceMode values into user-friendly display strings.
|
||||
* Converts enum values to readable text suitable for player-facing messages.
|
||||
* Strings are loaded from language files via the Messages system.
|
||||
*/
|
||||
public final class ForceModeFormatter {
|
||||
private ForceModeFormatter() {}
|
||||
|
||||
/**
|
||||
* Formats a ForceMode enum into a user-friendly display string.
|
||||
* Uses localized strings from language files.
|
||||
*
|
||||
* @param mode the force mode to format
|
||||
* @return a user-friendly localized string representation
|
||||
*/
|
||||
public static String format(ForceMode mode) {
|
||||
return switch (mode) {
|
||||
case NORMAL -> Messages.get(MessageKeys.MODE_NORMAL);
|
||||
case FORCE_OPEN -> Messages.get(MessageKeys.MODE_FORCE_OPEN);
|
||||
case FORCE_CLOSED -> Messages.get(MessageKeys.MODE_FORCE_CLOSED);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a ForceMode enum into a shortened, compact display string.
|
||||
* Uses localized strings from language files.
|
||||
*
|
||||
* @param mode the force mode to format
|
||||
* @return a compact localized string representation
|
||||
*/
|
||||
public static String formatShort(ForceMode mode) {
|
||||
return switch (mode) {
|
||||
case NORMAL -> Messages.get(MessageKeys.MODE_NORMAL);
|
||||
case FORCE_OPEN -> Messages.get(MessageKeys.MOTD_FORCE_OPEN);
|
||||
case FORCE_CLOSED -> Messages.get(MessageKeys.MOTD_FORCE_CLOSED);
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
import java.util.Locale;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Handles player access checking based on whitelist, blacklist, and force mode.
|
||||
* Separates player-specific access logic from schedule logic.
|
||||
*/
|
||||
public final class PlayerAccessChecker {
|
||||
private PlayerAccessChecker() {}
|
||||
|
||||
public static PlayerAccessChecker create() {
|
||||
return new PlayerAccessChecker();
|
||||
}
|
||||
|
||||
private volatile ForceMode forceMode = ForceMode.NORMAL;
|
||||
private volatile boolean whitelistEnabled = false;
|
||||
private volatile boolean blacklistEnabled = false;
|
||||
private volatile Set<String> whitelist = Set.of();
|
||||
private volatile Set<String> blacklist = Set.of();
|
||||
|
||||
/**
|
||||
* Updates the access checker configuration.
|
||||
*/
|
||||
public void updateConfig(ForceMode forceMode, boolean whitelistEnabled, boolean blacklistEnabled,
|
||||
Set<String> whitelist, Set<String> blacklist) {
|
||||
this.forceMode = Objects.requireNonNull(forceMode, "forceMode");
|
||||
this.whitelistEnabled = whitelistEnabled;
|
||||
this.blacklistEnabled = blacklistEnabled;
|
||||
this.whitelist = Set.copyOf(Objects.requireNonNull(whitelist, "whitelist"));
|
||||
this.blacklist = Set.copyOf(Objects.requireNonNull(blacklist, "blacklist"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a player is allowed access based on force mode and lists.
|
||||
* Does not consider schedule - that should be checked separately.
|
||||
*
|
||||
* @param playerName the player name (case-insensitive)
|
||||
* @param isExempt whether the player has exempt permission
|
||||
* @return true if the player is allowed by force mode and lists
|
||||
*/
|
||||
public boolean isPlayerAllowed(String playerName, boolean isExempt) {
|
||||
if (isExempt) return true;
|
||||
if (forceMode == ForceMode.FORCE_OPEN) return true;
|
||||
if (forceMode == ForceMode.FORCE_CLOSED) return false;
|
||||
|
||||
String key = playerName == null ? "" : playerName.toLowerCase(Locale.ROOT);
|
||||
if (blacklistEnabled && blacklist.contains(key)) return false;
|
||||
if (whitelistEnabled && whitelist.contains(key)) return true;
|
||||
|
||||
return true; // Default allow if not in any list
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current force mode.
|
||||
*/
|
||||
public ForceMode getForceMode() {
|
||||
return forceMode;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.EnumMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Handles schedule calculations for next open/close times.
|
||||
* Separates schedule calculation logic from other concerns.
|
||||
*/
|
||||
public final class ScheduleCalculator {
|
||||
private ScheduleCalculator() {}
|
||||
|
||||
public static ScheduleCalculator create() {
|
||||
return new ScheduleCalculator();
|
||||
}
|
||||
|
||||
private static final int DEFAULT_SEARCH_DAYS = 14;
|
||||
|
||||
private volatile Map<DayOfWeek, List<TimeRange>> dayToRanges = new EnumMap<>(DayOfWeek.class);
|
||||
private volatile ZoneId zoneId = ZoneId.of("Europe/Paris");
|
||||
|
||||
/**
|
||||
* Updates the schedule configuration.
|
||||
*/
|
||||
public void updateSchedule(Map<DayOfWeek, List<TimeRange>> dayToRanges, ZoneId zoneId) {
|
||||
this.dayToRanges = new EnumMap<>(Objects.requireNonNull(dayToRanges, "dayToRanges"));
|
||||
this.zoneId = Objects.requireNonNull(zoneId, "zoneId");
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server schedule is open at the specified time.
|
||||
* Does not consider exceptions or force modes - only the base schedule.
|
||||
*
|
||||
* @param now the time to check
|
||||
* @return true if the schedule is open at this time
|
||||
*/
|
||||
public boolean isScheduleOpen(ZonedDateTime now) {
|
||||
DayOfWeek dow = now.getDayOfWeek();
|
||||
LocalTime time = now.toLocalTime();
|
||||
|
||||
// Check today's ranges
|
||||
List<TimeRange> today = dayToRanges.getOrDefault(dow, List.of());
|
||||
for (TimeRange range : today) {
|
||||
if (range.contains(time)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Check yesterday's midnight-spanning ranges
|
||||
List<TimeRange> yesterday = dayToRanges.getOrDefault(dow.minus(1), List.of());
|
||||
for (TimeRange range : yesterday) {
|
||||
if (range.spansMidnight() && time.isBefore(range.getEnd().plusSeconds(1))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next closing time from the current moment.
|
||||
* Only returns a value if the server is currently open.
|
||||
*
|
||||
* @param now the current time
|
||||
* @return the next closing time, or empty if server is not currently open
|
||||
*/
|
||||
public Optional<ZonedDateTime> nextClose(ZonedDateTime now) {
|
||||
if (!isScheduleOpen(now)) return Optional.empty();
|
||||
|
||||
LocalTime time = now.toLocalTime();
|
||||
DayOfWeek dow = now.getDayOfWeek();
|
||||
|
||||
// Collect all relevant ranges
|
||||
List<TimeRange> candidates = List.copyOf(dayToRanges.getOrDefault(dow, List.of()));
|
||||
List<TimeRange> yesterday = dayToRanges.getOrDefault(dow.minus(1), List.of());
|
||||
for (TimeRange range : yesterday) {
|
||||
if (range.spansMidnight()) {
|
||||
candidates.add(range);
|
||||
}
|
||||
}
|
||||
|
||||
for (TimeRange range : candidates) {
|
||||
if (range.contains(time)) {
|
||||
LocalDateTime endDt = LocalDateTime.of(now.toLocalDate(), range.getEnd());
|
||||
|
||||
if (range.spansMidnight() && time.isBefore(range.getEnd().plusSeconds(1))) {
|
||||
// We are in the after-midnight part
|
||||
} else if (range.spansMidnight() && !time.isAfter(range.getEnd())) {
|
||||
endDt = LocalDateTime.of(now.toLocalDate().plusDays(1), range.getEnd());
|
||||
}
|
||||
|
||||
if (!range.spansMidnight() && time.isAfter(range.getEnd())) continue;
|
||||
|
||||
ZonedDateTime z = endDt.atZone(zoneId);
|
||||
if (!z.isBefore(now)) return Optional.of(z);
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next opening time from the current moment.
|
||||
* Searches up to two weeks ahead for the next scheduled open period.
|
||||
*
|
||||
* @param now the current time
|
||||
* @return the next opening time, or empty if none found in the next 14 days
|
||||
*/
|
||||
public Optional<ZonedDateTime> nextOpen(ZonedDateTime now) {
|
||||
if (isScheduleOpen(now)) return Optional.of(now);
|
||||
|
||||
for (int d = 0; d < DEFAULT_SEARCH_DAYS; d++) {
|
||||
ZonedDateTime day = now.plusDays(d);
|
||||
DayOfWeek dow = day.getDayOfWeek();
|
||||
LocalDate date = day.toLocalDate();
|
||||
|
||||
for (TimeRange range : dayToRanges.getOrDefault(dow, List.of())) {
|
||||
LocalDateTime startDt = LocalDateTime.of(date, range.getStart());
|
||||
|
||||
if (d == 0 && startDt.isBefore(now.toLocalDateTime())) {
|
||||
// Already passed today start
|
||||
if (range.spansMidnight()) {
|
||||
// If we are before end and past start, we would be open already; handled earlier
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
return Optional.of(startDt.atZone(zoneId));
|
||||
}
|
||||
|
||||
if (range.spansMidnight()) {
|
||||
// Open at start, even if that is today and time passed
|
||||
if (d > 0) return Optional.of(startDt.atZone(zoneId));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
import com.mrkayjaydee.playhours.PlayHoursMod;
|
||||
import com.mrkayjaydee.playhours.config.*;
|
||||
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Central service for managing server operation schedules.
|
||||
* Coordinates between PlayerAccessChecker, ScheduleCalculator, and ExceptionHandler.
|
||||
* All schedule checks are timezone-aware and support midnight-spanning periods.
|
||||
*/
|
||||
public final class ScheduleService {
|
||||
|
||||
private static final ScheduleService INSTANCE = new ScheduleService();
|
||||
|
||||
public static ScheduleService get() { return INSTANCE; }
|
||||
|
||||
private final PlayerAccessChecker accessChecker = PlayerAccessChecker.create();
|
||||
private final ScheduleCalculator scheduleCalculator = ScheduleCalculator.create();
|
||||
private final ExceptionHandler exceptionHandler = ExceptionHandler.create();
|
||||
|
||||
private volatile ZoneId zoneId = ZoneId.of("Europe/Paris");
|
||||
private volatile List<Integer> warningMinutes = List.of(15, 10, 5, 1);
|
||||
private volatile int closingThresholdMinutes = 0;
|
||||
private volatile boolean denyLoginDuringThreshold = true;
|
||||
private volatile boolean kickExempt = false;
|
||||
private volatile boolean exemptBypassSchedule = true;
|
||||
private volatile boolean exemptBypassThreshold = true;
|
||||
|
||||
private ScheduleService() {
|
||||
// Use safe defaults until config is loaded; rebuilt via ModConfigEvent
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuilds the schedule cache from the current ServerConfig values.
|
||||
* Should be called after any configuration changes.
|
||||
*/
|
||||
public void rebuildFromConfig() {
|
||||
this.zoneId = ZoneId.of(GeneralConfig.TIMEZONE.get());
|
||||
this.warningMinutes = GeneralConfig.WARNING_MINUTES.get().stream()
|
||||
.map(Object::toString)
|
||||
.map(Integer::parseInt)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.collect(Collectors.toList());
|
||||
this.closingThresholdMinutes = GeneralConfig.CLOSING_THRESHOLD_MINUTES.get();
|
||||
this.denyLoginDuringThreshold = GeneralConfig.DENY_LOGIN_DURING_THRESHOLD.get();
|
||||
this.kickExempt = GeneralConfig.KICK_EXEMPT.get();
|
||||
this.exemptBypassSchedule = GeneralConfig.EXEMPT_BYPASS_SCHEDULE.get();
|
||||
this.exemptBypassThreshold = GeneralConfig.EXEMPT_BYPASS_THRESHOLD.get();
|
||||
|
||||
// Update access checker
|
||||
ForceMode forceMode = ForceMode.valueOf(GeneralConfig.FORCE_MODE.get().toUpperCase(Locale.ROOT));
|
||||
boolean whitelistEnabled = ListsConfig.WHITELIST_ENABLED.get();
|
||||
boolean blacklistEnabled = ListsConfig.BLACKLIST_ENABLED.get();
|
||||
Set<String> whitelist = new HashSet<>(ListsConfig.WHITELIST.get());
|
||||
Set<String> blacklist = new HashSet<>(ListsConfig.BLACKLIST.get());
|
||||
accessChecker.updateConfig(forceMode, whitelistEnabled, blacklistEnabled, whitelist, blacklist);
|
||||
|
||||
// Update exception handler
|
||||
List<String> openExceptions = new ArrayList<>(ExceptionsConfig.OPEN_DATES.get());
|
||||
List<String> closedExceptions = new ArrayList<>(ExceptionsConfig.CLOSED_DATES.get());
|
||||
exceptionHandler.updateExceptions(openExceptions, closedExceptions);
|
||||
|
||||
// Update schedule calculator
|
||||
Map<DayOfWeek, List<TimeRange>> dayToRanges = new EnumMap<>(DayOfWeek.class);
|
||||
List<TimeRange> defaults = parseRanges(DefaultsConfig.PERIODS.get());
|
||||
dayToRanges.put(DayOfWeek.MONDAY, parseOrDefault(DaysConfig.MON.get(), defaults));
|
||||
dayToRanges.put(DayOfWeek.TUESDAY, parseOrDefault(DaysConfig.TUE.get(), defaults));
|
||||
dayToRanges.put(DayOfWeek.WEDNESDAY, parseOrDefault(DaysConfig.WED.get(), defaults));
|
||||
dayToRanges.put(DayOfWeek.THURSDAY, parseOrDefault(DaysConfig.THU.get(), defaults));
|
||||
dayToRanges.put(DayOfWeek.FRIDAY, parseOrDefault(DaysConfig.FRI.get(), defaults));
|
||||
dayToRanges.put(DayOfWeek.SATURDAY, parseOrDefault(DaysConfig.SAT.get(), defaults));
|
||||
dayToRanges.put(DayOfWeek.SUNDAY, parseOrDefault(DaysConfig.SUN.get(), defaults));
|
||||
scheduleCalculator.updateSchedule(dayToRanges, zoneId);
|
||||
}
|
||||
|
||||
private static List<TimeRange> parseOrDefault(List<? extends String> raw, List<TimeRange> dflt) {
|
||||
if (raw == null || raw.isEmpty()) return dflt;
|
||||
List<TimeRange> list = parseRanges(raw);
|
||||
return list.isEmpty() ? dflt : list;
|
||||
}
|
||||
|
||||
private static List<TimeRange> parseRanges(List<? extends String> raw) {
|
||||
List<TimeRange> list = new ArrayList<>();
|
||||
for (String s : raw) {
|
||||
try {
|
||||
list.add(TimeRange.parse(s));
|
||||
} catch (Exception e) {
|
||||
// Invalid time range format - skip and continue
|
||||
PlayHoursMod.LOGGER.debug("Failed to parse time range '{}': {}", s, e.getMessage());
|
||||
}
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
public ZoneId getZoneId() { return zoneId; }
|
||||
public List<Integer> getWarningMinutes() { return warningMinutes; }
|
||||
public int getClosingThresholdMinutes() { return closingThresholdMinutes; }
|
||||
public boolean isDenyLoginDuringThreshold() { return denyLoginDuringThreshold; }
|
||||
public boolean isKickExempt() { return kickExempt; }
|
||||
public boolean isExemptBypassSchedule() { return exemptBypassSchedule; }
|
||||
public boolean isExemptBypassThreshold() { return exemptBypassThreshold; }
|
||||
|
||||
public ForceMode getForceMode() { return accessChecker.getForceMode(); }
|
||||
|
||||
/**
|
||||
* Checks if the server is open for a specific player by name.
|
||||
* Applies force mode, blacklist/whitelist, and schedule checks.
|
||||
*
|
||||
* @param name the player name (case-insensitive)
|
||||
* @param isExempt whether the player has exempt permission and bypass is enabled
|
||||
* @return true if the player is allowed to connect
|
||||
*/
|
||||
public boolean isOpenForName(String name, boolean isExempt) {
|
||||
if (!accessChecker.isPlayerAllowed(name, isExempt)) {
|
||||
return false;
|
||||
}
|
||||
// Exempt players bypass schedule check if exemptBypassSchedule is enabled
|
||||
if (isExempt) {
|
||||
return true;
|
||||
}
|
||||
return isOpen(ZonedDateTime.now(zoneId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the server is open at the specified time based on schedule and exceptions.
|
||||
* Does not consider player-specific whitelists/blacklists.
|
||||
*
|
||||
* @param now the time to check
|
||||
* @return true if the server schedule is open at this time
|
||||
*/
|
||||
public boolean isOpen(ZonedDateTime now) {
|
||||
ForceMode forceMode = accessChecker.getForceMode();
|
||||
if (forceMode == ForceMode.FORCE_OPEN) return true;
|
||||
if (forceMode == ForceMode.FORCE_CLOSED) return false;
|
||||
|
||||
if (exceptionHandler.isClosedException(now)) return false;
|
||||
if (exceptionHandler.isOpenException(now)) return true;
|
||||
|
||||
return scheduleCalculator.isScheduleOpen(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next closing time from the current moment.
|
||||
* Only returns a value if the server is currently open.
|
||||
*
|
||||
* @param now the current time
|
||||
* @return the next closing time, or empty if server is not currently open
|
||||
*/
|
||||
public Optional<ZonedDateTime> nextClose(ZonedDateTime now) {
|
||||
if (!isOpen(now)) return Optional.empty();
|
||||
return scheduleCalculator.nextClose(now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculates the next opening time from the current moment.
|
||||
* Searches up to two weeks ahead for the next scheduled open period.
|
||||
*
|
||||
* @param now the current time
|
||||
* @return the next opening time, or empty if none found in the next 14 days
|
||||
*/
|
||||
public Optional<ZonedDateTime> nextOpen(ZonedDateTime now) {
|
||||
if (isOpen(now)) return Optional.of(now);
|
||||
|
||||
// Check exceptions first
|
||||
for (int d = 0; d < 14; d++) {
|
||||
ZonedDateTime day = now.plusDays(d);
|
||||
if (exceptionHandler.isClosedException(day)) continue;
|
||||
if (exceptionHandler.isOpenException(day)) return Optional.of(day);
|
||||
}
|
||||
|
||||
return scheduleCalculator.nextOpen(now);
|
||||
}
|
||||
}
|
||||
|
||||
200
src/main/java/com/mrkayjaydee/playhours/core/TimeRange.java
Normal file
200
src/main/java/com/mrkayjaydee/playhours/core/TimeRange.java
Normal file
@@ -0,0 +1,200 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
import java.time.LocalTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* Represents a time range with start and end times (12-hour AM/PM format).
|
||||
* Supports midnight-spanning ranges where end < start.
|
||||
* Examples: "09:00 AM-05:00 PM", "10:00 PM-02:00 AM"
|
||||
*/
|
||||
public final class TimeRange {
|
||||
private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("hh:mm a", java.util.Locale.ENGLISH);
|
||||
|
||||
private final LocalTime start;
|
||||
private final LocalTime end;
|
||||
|
||||
public TimeRange(LocalTime start, LocalTime end) {
|
||||
this.start = Objects.requireNonNull(start, "start");
|
||||
this.end = Objects.requireNonNull(end, "end");
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new TimeRange builder.
|
||||
*
|
||||
* @return a new TimeRangeBuilder
|
||||
*/
|
||||
public static TimeRangeBuilder builder() {
|
||||
return new TimeRangeBuilder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Builder for TimeRange objects.
|
||||
*/
|
||||
public static final class TimeRangeBuilder {
|
||||
private LocalTime start;
|
||||
private LocalTime end;
|
||||
|
||||
private TimeRangeBuilder() {}
|
||||
|
||||
/**
|
||||
* Sets the start time.
|
||||
*
|
||||
* @param start the start time
|
||||
* @return this builder
|
||||
*/
|
||||
public TimeRangeBuilder start(LocalTime start) {
|
||||
this.start = start;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the end time.
|
||||
*
|
||||
* @param end the end time
|
||||
* @return this builder
|
||||
*/
|
||||
public TimeRangeBuilder end(LocalTime end) {
|
||||
this.end = end;
|
||||
return this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the TimeRange.
|
||||
*
|
||||
* @return a new TimeRange
|
||||
*/
|
||||
public TimeRange build() {
|
||||
return new TimeRange(start, end);
|
||||
}
|
||||
}
|
||||
|
||||
public LocalTime getStart() { return start; }
|
||||
public LocalTime getEnd() { return end; }
|
||||
|
||||
/**
|
||||
* Checks if this time range spans midnight (e.g., 22:00-02:00).
|
||||
* @return true if end time is before start time
|
||||
*/
|
||||
public boolean spansMidnight() {
|
||||
return end.isBefore(start);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given time falls within this range.
|
||||
* Correctly handles midnight-spanning ranges.
|
||||
*
|
||||
* @param time the time to check
|
||||
* @return true if time is within this range
|
||||
*/
|
||||
public boolean contains(LocalTime time) {
|
||||
if (!spansMidnight()) {
|
||||
return !time.isBefore(start) && !time.isAfter(end);
|
||||
}
|
||||
// Midnight span: covers [start..24:00] U [00:00..end]
|
||||
return !time.isBefore(start) || !time.isAfter(end);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a time range from a string in 12-hour AM/PM format "hh:mm AM-hh:mm PM".
|
||||
*
|
||||
* @param value the string to parse (e.g., "09:00 AM-05:00 PM" or "10:00 PM-02:00 AM")
|
||||
* @return the parsed TimeRange
|
||||
* @throws IllegalArgumentException if the format is invalid
|
||||
*/
|
||||
public static TimeRange parse(String value) {
|
||||
String[] parts = value.trim().split("-");
|
||||
if (parts.length != 2) throw new IllegalArgumentException("Invalid time range: " + value);
|
||||
try {
|
||||
LocalTime s = LocalTime.parse(parts[0].trim(), FORMAT);
|
||||
LocalTime e = LocalTime.parse(parts[1].trim(), FORMAT);
|
||||
return new TimeRange(s, e);
|
||||
} catch (DateTimeParseException ex) {
|
||||
throw new IllegalArgumentException("Invalid time format in range: " + value + " (expected format: hh:mm AM-hh:mm PM)", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return start.format(FORMAT) + "-" + end.format(FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a LocalTime to 12-hour AM/PM format string.
|
||||
* @param time the time to format
|
||||
* @return formatted time string (e.g., "09:00 AM")
|
||||
*/
|
||||
public static String formatTime(LocalTime time) {
|
||||
return time.format(FORMAT);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a LocalTime using the provided locale.
|
||||
* For French locale, uses 24-hour format (HH:mm).
|
||||
* For other locales, uses 12-hour AM/PM format (hh:mm a).
|
||||
* Falls back to English if locale is null.
|
||||
*/
|
||||
public static String formatTime(LocalTime time, java.util.Locale locale) {
|
||||
if (locale == null) locale = java.util.Locale.ENGLISH;
|
||||
|
||||
// French locale prefers 24-hour format (18:00 instead of 06:00 PM)
|
||||
String pattern = locale.getLanguage().equals("fr") ? "HH:mm" : "hh:mm a";
|
||||
java.time.format.DateTimeFormatter fmt = java.time.format.DateTimeFormatter.ofPattern(pattern, locale);
|
||||
return time.format(fmt);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this time range is valid (start and end are not null).
|
||||
*
|
||||
* @return true if the range is valid
|
||||
*/
|
||||
public boolean isValid() {
|
||||
return start != null && end != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the duration of this time range.
|
||||
* For midnight-spanning ranges, returns the duration until midnight plus the duration after midnight.
|
||||
*
|
||||
* @return the duration of this range
|
||||
*/
|
||||
public java.time.Duration getDuration() {
|
||||
if (!spansMidnight()) {
|
||||
return java.time.Duration.between(start, end);
|
||||
} else {
|
||||
// Midnight-spanning range: duration from start to midnight + duration from midnight to end
|
||||
java.time.Duration toMidnight = java.time.Duration.between(start, java.time.LocalTime.MIDNIGHT);
|
||||
java.time.Duration fromMidnight = java.time.Duration.between(java.time.LocalTime.MIDNIGHT, end);
|
||||
return toMidnight.plus(fromMidnight);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if this time range overlaps with another time range.
|
||||
*
|
||||
* @param other the other time range
|
||||
* @return true if the ranges overlap
|
||||
*/
|
||||
public boolean overlaps(TimeRange other) {
|
||||
if (other == null) return false;
|
||||
|
||||
if (!spansMidnight() && !other.spansMidnight()) {
|
||||
// Neither spans midnight - simple case
|
||||
return !start.isAfter(other.end) && !end.isBefore(other.start);
|
||||
} else if (spansMidnight() && !other.spansMidnight()) {
|
||||
// This spans midnight, other doesn't
|
||||
return other.contains(start) || other.contains(end) ||
|
||||
(other.start.isAfter(start) && other.end.isBefore(end));
|
||||
} else if (!spansMidnight() && other.spansMidnight()) {
|
||||
// Other spans midnight, this doesn't
|
||||
return contains(other.start) || contains(other.end) ||
|
||||
(start.isAfter(other.start) && end.isBefore(other.end));
|
||||
} else {
|
||||
// Both span midnight
|
||||
return true; // Two midnight-spanning ranges always overlap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.mrkayjaydee.playhours.core;
|
||||
|
||||
/**
|
||||
* Validates time range strings and provides validation utilities.
|
||||
* Separates validation logic from parsing logic.
|
||||
*/
|
||||
public final class TimeRangeValidator {
|
||||
private TimeRangeValidator() {}
|
||||
|
||||
/**
|
||||
* Validates a time range string format.
|
||||
*
|
||||
* @param range the time range string to validate
|
||||
* @return true if the format is valid
|
||||
*/
|
||||
public static boolean isValidFormat(String range) {
|
||||
if (range == null || range.trim().isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String[] parts = range.trim().split("-");
|
||||
if (parts.length != 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
TimeRange.parse(range);
|
||||
return true;
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a time range string and throws an exception if invalid.
|
||||
*
|
||||
* @param range the time range string to validate
|
||||
* @throws IllegalArgumentException if the range is invalid
|
||||
*/
|
||||
public static void validateRange(String range) {
|
||||
if (!isValidFormat(range)) {
|
||||
throw new IllegalArgumentException("Invalid time range format: " + range);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.permissions.PermissionChecker;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import com.mrkayjaydee.playhours.config.ConfigEventHandler;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraftforge.event.entity.player.PlayerEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Handles login enforcement based on server operating hours and thresholds.
|
||||
* Disconnects players immediately after login if they are not permitted during current schedule.
|
||||
*/
|
||||
@Mod.EventBusSubscriber
|
||||
public class LoginGuard {
|
||||
|
||||
// Note: Using PlayerLoggedInEvent as per PLAN.md fallback strategy.
|
||||
// Early denial could be implemented via ServerLoginNetworkEvent.CheckLogin if needed.
|
||||
|
||||
/**
|
||||
* Checks if player login is permitted based on current schedule, threshold, and exemptions.
|
||||
* Disconnects the player immediately if access is denied.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
|
||||
if (!(event.getEntity() instanceof ServerPlayer player)) return;
|
||||
if (!ConfigEventHandler.isReady()) {
|
||||
// Defer checks until config ready to avoid early access
|
||||
com.mrkayjaydee.playhours.PlayHoursMod.LOGGER.debug("LoginGuard deferred: config not ready for {}", player.getGameProfile().getName());
|
||||
return;
|
||||
}
|
||||
ScheduleService sched = ScheduleService.get();
|
||||
boolean exempt = PermissionChecker.isExempt(player);
|
||||
ZonedDateTime now = ZonedDateTime.now(sched.getZoneId());
|
||||
|
||||
// First check: is the player allowed by schedule/lists/force mode?
|
||||
boolean bypassSchedule = exempt && sched.isExemptBypassSchedule();
|
||||
if (!sched.isOpenForName(player.getGameProfile().getName(), bypassSchedule)) {
|
||||
ScheduleFormatter.FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(sched.nextOpen(now));
|
||||
player.connection.disconnect(Messages.accessDenied(nextOpen.day, nextOpen.time));
|
||||
return;
|
||||
}
|
||||
|
||||
// Second check: threshold - deny login if within closing threshold
|
||||
boolean bypassThreshold = exempt && sched.isExemptBypassThreshold();
|
||||
if (!bypassThreshold && sched.isOpen(now) && sched.isDenyLoginDuringThreshold()) {
|
||||
Optional<ZonedDateTime> nextClose = sched.nextClose(now);
|
||||
if (nextClose.isPresent()) {
|
||||
long minutesUntilClose = Duration.between(now, nextClose.get()).toMinutes();
|
||||
if (minutesUntilClose >= 0 && minutesUntilClose <= sched.getClosingThresholdMinutes()) {
|
||||
ScheduleFormatter.FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(sched.nextOpen(nextClose.get().plusMinutes(1)));
|
||||
player.connection.disconnect(Messages.thresholdDenied(nextOpen.day, nextOpen.time));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
218
src/main/java/com/mrkayjaydee/playhours/events/MOTDBuilder.java
Normal file
218
src/main/java/com/mrkayjaydee/playhours/events/MOTDBuilder.java
Normal file
@@ -0,0 +1,218 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.config.MOTDConfig;
|
||||
import com.mrkayjaydee.playhours.core.ForceMode;
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.events.ScheduleFormatter.FormattedSchedule;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.network.chat.MutableComponent;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Builds MOTD components based on configuration.
|
||||
* Supports multiple MOTD strategies: rotating, custom lines, custom format, and automatic.
|
||||
*/
|
||||
public final class MOTDBuilder {
|
||||
private MOTDBuilder() {}
|
||||
|
||||
private static int currentRotationIndex = 0;
|
||||
private static long lastRotationTime = 0;
|
||||
private static List<? extends String> lastRotationTemplates = null;
|
||||
|
||||
/**
|
||||
* Builds the MOTD component based on configuration and current schedule state.
|
||||
* Priority: rotation → custom lines → custom format → automatic
|
||||
*
|
||||
* @param scheduleService the schedule service
|
||||
* @param now the current time
|
||||
* @return the MOTD component
|
||||
*/
|
||||
public static Component build(ScheduleService scheduleService, ZonedDateTime now) {
|
||||
// Check for rotation first
|
||||
if (MOTDConfig.ROTATION_ENABLED.get()) {
|
||||
List<? extends String> templates = MOTDConfig.ROTATION_TEMPLATES.get();
|
||||
if (templates != null && !templates.isEmpty()) {
|
||||
return buildRotating(scheduleService, now, templates);
|
||||
}
|
||||
}
|
||||
|
||||
// Check for custom lines
|
||||
List<? extends String> customLines = MOTDConfig.CUSTOM_LINES.get();
|
||||
if (customLines != null && !customLines.isEmpty()) {
|
||||
return buildCustomLines(scheduleService, now, customLines);
|
||||
}
|
||||
|
||||
// Check for custom format
|
||||
String customFormat = MOTDConfig.CUSTOM_FORMAT.get();
|
||||
if (customFormat != null && !customFormat.trim().isEmpty()) {
|
||||
return buildCustomFormat(scheduleService, now, customFormat);
|
||||
}
|
||||
|
||||
// Build automatic MOTD
|
||||
return buildAutomatic(scheduleService, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a rotating MOTD that cycles through configured templates.
|
||||
*/
|
||||
private static Component buildRotating(ScheduleService scheduleService, ZonedDateTime now, List<? extends String> templates) {
|
||||
if (templates.isEmpty()) {
|
||||
return buildCustomLines(scheduleService, now, MOTDConfig.CUSTOM_LINES.get());
|
||||
}
|
||||
|
||||
// Check if template list changed
|
||||
if (lastRotationTemplates != templates) {
|
||||
lastRotationTemplates = templates;
|
||||
currentRotationIndex = 0;
|
||||
lastRotationTime = System.currentTimeMillis();
|
||||
}
|
||||
|
||||
// Calculate time elapsed since last rotation
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long rotationIntervalMillis = MOTDConfig.ROTATION_INTERVAL_SECONDS.get() * 1000L;
|
||||
long timeSinceLastRotation = currentTime - lastRotationTime;
|
||||
|
||||
// Check if we should advance to next template
|
||||
if (timeSinceLastRotation >= rotationIntervalMillis) {
|
||||
// Move to next template
|
||||
currentRotationIndex = (currentRotationIndex + 1) % templates.size();
|
||||
lastRotationTime = currentTime;
|
||||
}
|
||||
|
||||
String template = templates.get(currentRotationIndex);
|
||||
return MOTDFormatter.formatLine(scheduleService, now, template);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds MOTD from custom line configuration.
|
||||
*/
|
||||
private static Component buildCustomLines(ScheduleService scheduleService, ZonedDateTime now, List<? extends String> customLines) {
|
||||
MutableComponent result = Component.empty();
|
||||
|
||||
for (int i = 0; i < customLines.size(); i++) {
|
||||
String line = customLines.get(i);
|
||||
Component formatted = MOTDFormatter.formatLine(scheduleService, now, line);
|
||||
result.append(formatted);
|
||||
|
||||
if (i < customLines.size() - 1) {
|
||||
result.append(Component.literal("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds MOTD from custom format string.
|
||||
*/
|
||||
private static Component buildCustomFormat(ScheduleService scheduleService, ZonedDateTime now, String format) {
|
||||
return MOTDFormatter.formatLine(scheduleService, now, format);
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds automatic MOTD based on configuration flags.
|
||||
*/
|
||||
private static Component buildAutomatic(ScheduleService scheduleService, ZonedDateTime now) {
|
||||
List<Component> parts = new ArrayList<>();
|
||||
|
||||
// Get schedule information
|
||||
boolean isOpen = scheduleService.isOpen(now);
|
||||
ForceMode forceMode = scheduleService.getForceMode();
|
||||
Optional<ZonedDateTime> nextClose = scheduleService.nextClose(now);
|
||||
FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(scheduleService.nextOpen(now));
|
||||
|
||||
// Show force mode if enabled
|
||||
if (MOTDConfig.SHOW_FORCE_MODE.get() && forceMode != ForceMode.NORMAL) {
|
||||
String forceModeText = forceMode == ForceMode.FORCE_OPEN
|
||||
? Messages.get("msg.motd_force_open")
|
||||
: Messages.get("msg.motd_force_closed");
|
||||
parts.add(MOTDFormatter.colorize(forceModeText, ChatFormatting.GOLD));
|
||||
}
|
||||
|
||||
// Show status if enabled
|
||||
if (MOTDConfig.SHOW_STATUS.get()) {
|
||||
String statusKey = isOpen ? "msg.motd_status_open" : "msg.motd_status_closed";
|
||||
String statusText = Messages.get(statusKey);
|
||||
ChatFormatting statusColor = isOpen
|
||||
? MOTDColorParser.parseColor(MOTDConfig.OPEN_COLOR.get())
|
||||
: MOTDColorParser.parseColor(MOTDConfig.CLOSED_COLOR.get());
|
||||
parts.add(MOTDFormatter.colorize(statusText, statusColor));
|
||||
}
|
||||
|
||||
// Show countdown if enabled and applicable
|
||||
if (MOTDConfig.SHOW_COUNTDOWN.get() && isOpen && nextClose.isPresent()) {
|
||||
addCountdownIfApplicable(parts, nextClose.get(), now);
|
||||
}
|
||||
|
||||
// Show next close if enabled and open
|
||||
if (MOTDConfig.SHOW_NEXT_CLOSE.get() && isOpen && nextClose.isPresent()) {
|
||||
addNextClose(parts, nextClose.get());
|
||||
}
|
||||
|
||||
// Show next open if enabled and closed
|
||||
if (MOTDConfig.SHOW_NEXT_OPEN.get() && !isOpen) {
|
||||
addNextOpen(parts, nextOpen);
|
||||
}
|
||||
|
||||
// Combine parts
|
||||
if (parts.isEmpty()) {
|
||||
return Component.literal("");
|
||||
}
|
||||
|
||||
return combineParts(parts);
|
||||
}
|
||||
|
||||
private static void addCountdownIfApplicable(List<Component> parts, ZonedDateTime nextClose, ZonedDateTime now) {
|
||||
long minutesUntilClose = java.time.temporal.ChronoUnit.MINUTES.between(now, nextClose);
|
||||
int countdownThreshold = MOTDConfig.COUNTDOWN_THRESHOLD_MINUTES.get();
|
||||
|
||||
if (countdownThreshold > 0 && minutesUntilClose <= countdownThreshold && minutesUntilClose > 0) {
|
||||
String countdownText = Messages.get("msg.motd_countdown")
|
||||
.replace("%minutes%", String.valueOf(minutesUntilClose));
|
||||
parts.add(MOTDFormatter.colorize(countdownText, ChatFormatting.YELLOW));
|
||||
}
|
||||
}
|
||||
|
||||
private static void addNextClose(List<Component> parts, ZonedDateTime nextClose) {
|
||||
String closeTime = com.mrkayjaydee.playhours.core.TimeRange.formatTime(nextClose.toLocalTime(), Messages.getJavaLocale());
|
||||
String closeText = Messages.get("msg.motd_next_close")
|
||||
.replace("%closetime%", closeTime);
|
||||
ChatFormatting infoColor = MOTDColorParser.parseColor(MOTDConfig.INFO_COLOR.get());
|
||||
parts.add(MOTDFormatter.colorize(closeText, infoColor));
|
||||
}
|
||||
|
||||
private static void addNextOpen(List<Component> parts, FormattedSchedule nextOpen) {
|
||||
String openText = Messages.get("msg.motd_next_open")
|
||||
.replace("%openday%", nextOpen.day)
|
||||
.replace("%opentime%", nextOpen.time);
|
||||
ChatFormatting infoColor = MOTDColorParser.parseColor(MOTDConfig.INFO_COLOR.get());
|
||||
parts.add(MOTDFormatter.colorize(openText, infoColor));
|
||||
}
|
||||
|
||||
private static Component combineParts(List<Component> parts) {
|
||||
String separator = MOTDConfig.SEPARATOR.get();
|
||||
boolean useSecondLine = MOTDConfig.SHOW_ON_SECOND_LINE.get();
|
||||
|
||||
MutableComponent result = Component.empty();
|
||||
|
||||
if (useSecondLine) {
|
||||
// Put on second line
|
||||
result.append(Component.literal("\n"));
|
||||
}
|
||||
|
||||
for (int i = 0; i < parts.size(); i++) {
|
||||
result.append(parts.get(i));
|
||||
if (i < parts.size() - 1) {
|
||||
result.append(Component.literal(separator));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.PlayHoursMod;
|
||||
import net.minecraft.ChatFormatting;
|
||||
|
||||
/**
|
||||
* Utility for parsing color names and codes to Minecraft ChatFormatting.
|
||||
* Handles both named colors (e.g., "green") and section sign codes (e.g., "§a").
|
||||
*/
|
||||
public final class MOTDColorParser {
|
||||
private MOTDColorParser() {}
|
||||
|
||||
/**
|
||||
* Parses a color name or code to ChatFormatting.
|
||||
*
|
||||
* @param colorStr color name (e.g., "green", "red") or § code (e.g., "§a")
|
||||
* @return the ChatFormatting color, or GRAY as default
|
||||
*/
|
||||
public static ChatFormatting parseColor(String colorStr) {
|
||||
if (colorStr == null || colorStr.trim().isEmpty()) {
|
||||
return ChatFormatting.GRAY;
|
||||
}
|
||||
|
||||
String normalized = colorStr.trim().toUpperCase().replace(" ", "_");
|
||||
|
||||
try {
|
||||
return ChatFormatting.valueOf(normalized);
|
||||
} catch (IllegalArgumentException e) {
|
||||
// Try parsing § codes
|
||||
if (colorStr.startsWith("§") && colorStr.length() == 2) {
|
||||
ChatFormatting byCode = ChatFormatting.getByCode(colorStr.charAt(1));
|
||||
if (byCode != null) {
|
||||
return byCode;
|
||||
}
|
||||
}
|
||||
|
||||
PlayHoursMod.LOGGER.warn("Invalid color code '{}', using GRAY", colorStr);
|
||||
return ChatFormatting.GRAY;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.config.MOTDConfig;
|
||||
import com.mrkayjaydee.playhours.core.ForceModeFormatter;
|
||||
import com.mrkayjaydee.playhours.core.ForceMode;
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.core.TimeRange;
|
||||
import com.mrkayjaydee.playhours.events.ScheduleFormatter.FormattedSchedule;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import com.mrkayjaydee.playhours.text.MessageKeys;
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Formats MOTD lines with placeholder replacement and color application.
|
||||
* Replaces dynamic placeholders like %status%, %mode%, %isopen%, etc. with actual values.
|
||||
*/
|
||||
public final class MOTDFormatter {
|
||||
private MOTDFormatter() {}
|
||||
|
||||
/**
|
||||
* Formats a line with placeholder replacement and coloring.
|
||||
*/
|
||||
public static Component formatLine(ScheduleService scheduleService, ZonedDateTime now, String format) {
|
||||
boolean isOpen = scheduleService.isOpen(now);
|
||||
Optional<ZonedDateTime> nextClose = scheduleService.nextClose(now);
|
||||
FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(scheduleService.nextOpen(now));
|
||||
ForceMode forceMode = scheduleService.getForceMode();
|
||||
long minutesUntilClose = nextClose.isPresent() ? ChronoUnit.MINUTES.between(now, nextClose.get()) : 0;
|
||||
|
||||
// Replace placeholders
|
||||
String formatted = format
|
||||
.replace("%status%", isOpen
|
||||
? Messages.get("msg.motd_status_open")
|
||||
: Messages.get("msg.motd_status_closed"))
|
||||
.replace("%mode%", ForceModeFormatter.format(forceMode))
|
||||
.replace("%isopen%", isOpen ? Messages.get(MessageKeys.YES) : Messages.get(MessageKeys.NO))
|
||||
.replace("%nextopen%", nextOpen.day + Messages.get(MessageKeys.DAY_TIME_SEPARATOR) + nextOpen.time)
|
||||
.replace("%openday%", nextOpen.day)
|
||||
.replace("%opentime%", nextOpen.time)
|
||||
.replace("%closetime%", nextClose.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-"))
|
||||
.replace("%nextclose%", nextClose.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-"))
|
||||
.replace("%minutes%", String.valueOf(minutesUntilClose));
|
||||
|
||||
// Apply coloring if enabled
|
||||
if (MOTDConfig.USE_COLORS.get()) {
|
||||
return parseFormattedText(formatted);
|
||||
}
|
||||
|
||||
return Component.literal(formatted);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses text with color codes and returns a Component.
|
||||
*/
|
||||
private static Component parseFormattedText(String text) {
|
||||
// Simple implementation - can be enhanced to support inline color codes
|
||||
return Component.literal(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Colorizes a component with the specified color.
|
||||
*/
|
||||
public static Component colorize(String text, ChatFormatting color) {
|
||||
if (MOTDConfig.USE_COLORS.get()) {
|
||||
return Component.literal(text).withStyle(color);
|
||||
}
|
||||
return Component.literal(text);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.PlayHoursMod;
|
||||
import com.mrkayjaydee.playhours.config.GeneralConfig;
|
||||
import com.mrkayjaydee.playhours.config.MOTDConfig;
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import net.minecraft.network.chat.Component;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
/**
|
||||
* Handles server MOTD (Message of the Day) customization.
|
||||
* Orchestrates periodic MOTD updates based on current schedule state.
|
||||
*/
|
||||
@Mod.EventBusSubscriber(modid = PlayHoursMod.MODID)
|
||||
public final class MOTDHandler {
|
||||
|
||||
private static long lastMOTDUpdate = 0;
|
||||
|
||||
private MOTDHandler() {}
|
||||
|
||||
/**
|
||||
* Handles server tick to periodically update MOTD with schedule information.
|
||||
*
|
||||
* @param event the server tick event
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onServerTick(TickEvent.ServerTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) return;
|
||||
if (event.getServer() == null) return;
|
||||
|
||||
try {
|
||||
// Check if MOTD feature is enabled
|
||||
if (!GeneralConfig.MOTD_ENABLED.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Only update periodically based on configured delay
|
||||
long currentTime = System.currentTimeMillis();
|
||||
long updateIntervalMillis = MOTDConfig.UPDATE_DELAY_SECONDS.get() * 1000L;
|
||||
if (currentTime - lastMOTDUpdate < updateIntervalMillis) {
|
||||
return;
|
||||
}
|
||||
lastMOTDUpdate = currentTime;
|
||||
|
||||
MinecraftServer server = event.getServer();
|
||||
|
||||
// Get current schedule information
|
||||
ScheduleService scheduleService = ScheduleService.get();
|
||||
ZonedDateTime now = ZonedDateTime.now(scheduleService.getZoneId());
|
||||
|
||||
// Build MOTD component
|
||||
Component motd = MOTDBuilder.build(scheduleService, now);
|
||||
|
||||
// Validate and truncate to Minecraft limits (2 lines, 59 chars per line)
|
||||
Component validatedMotd = MOTDValidator.validateAndTruncate(motd);
|
||||
|
||||
// Apply MOTD to server
|
||||
server.setMotd(validatedMotd.getString());
|
||||
|
||||
} catch (Exception e) {
|
||||
PlayHoursMod.LOGGER.error("Error updating MOTD", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.PlayHoursMod;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
/**
|
||||
* Validates and truncates MOTD to comply with Minecraft protocol limits.
|
||||
* Enforces: 2 lines maximum, ~59 characters per line.
|
||||
*/
|
||||
public final class MOTDValidator {
|
||||
private MOTDValidator() {}
|
||||
|
||||
private static final int MINECRAFT_LINE_LIMIT = 59;
|
||||
private static final int MINECRAFT_MAX_LINES = 2;
|
||||
|
||||
/**
|
||||
* Validates and truncates MOTD to comply with Minecraft protocol limits.
|
||||
* Minecraft MOTD: 2 lines max, ~59 characters per line
|
||||
*
|
||||
* @param motd the MOTD component to validate
|
||||
* @return the validated/truncated MOTD component
|
||||
*/
|
||||
public static Component validateAndTruncate(Component motd) {
|
||||
String text = motd.getString();
|
||||
String[] lines = text.split("\n", -1);
|
||||
|
||||
// Limit to 2 lines maximum (Minecraft displays 2 lines in server list)
|
||||
if (lines.length > MINECRAFT_MAX_LINES) {
|
||||
PlayHoursMod.LOGGER.warn("MOTD has {} lines but Minecraft only displays {}. Truncating.", lines.length, MINECRAFT_MAX_LINES);
|
||||
lines = new String[]{lines[0], lines[1]};
|
||||
}
|
||||
|
||||
// Truncate each line to ~59 characters (Minecraft MOTD line width limit)
|
||||
StringBuilder validatedMotd = new StringBuilder();
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String line = lines[i];
|
||||
if (line.length() > MINECRAFT_LINE_LIMIT) {
|
||||
PlayHoursMod.LOGGER.warn("MOTD line {} is {} chars but Minecraft limit is ~{}. Truncating.", i + 1, line.length(), MINECRAFT_LINE_LIMIT);
|
||||
line = line.substring(0, Math.min(line.length(), MINECRAFT_LINE_LIMIT));
|
||||
}
|
||||
validatedMotd.append(line);
|
||||
if (i < lines.length - 1) {
|
||||
validatedMotd.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
return Component.literal(validatedMotd.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.permissions.PermissionChecker;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Handles player kicking logic for schedule enforcement.
|
||||
* Separates kick logic from tick scheduling.
|
||||
*/
|
||||
public final class PlayerKickHandler {
|
||||
private PlayerKickHandler() {}
|
||||
|
||||
/**
|
||||
* Kicks players at closing time based on schedule and exemptions.
|
||||
*
|
||||
* @param players the list of players to check
|
||||
* @param scheduleService the schedule service
|
||||
* @param nextClose the next closing time
|
||||
*/
|
||||
public static void kickPlayersAtClose(List<ServerPlayer> players, ScheduleService scheduleService, ZonedDateTime nextClose) {
|
||||
ScheduleFormatter.FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(scheduleService.nextOpen(nextClose.plusMinutes(1)));
|
||||
|
||||
for (ServerPlayer player : players) {
|
||||
boolean exempt = PermissionChecker.isExempt(player);
|
||||
if (!exempt || scheduleService.isKickExempt()) {
|
||||
player.connection.disconnect(Messages.kick(nextOpen.day, nextOpen.time));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.core.TimeRange;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.TextStyle;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* Handles formatting of schedule information for display.
|
||||
* Separates formatting logic from event handling.
|
||||
*/
|
||||
public final class ScheduleFormatter {
|
||||
private ScheduleFormatter() {}
|
||||
|
||||
/**
|
||||
* Formats the next opening time for display.
|
||||
*
|
||||
* @param nextOpen the next opening time
|
||||
* @return formatted day and time strings
|
||||
*/
|
||||
public static FormattedSchedule formatNextOpen(Optional<ZonedDateTime> nextOpen) {
|
||||
String day = nextOpen.map(dt -> dt.getDayOfWeek().getDisplayName(TextStyle.FULL, Messages.getJavaLocale())).orElse("?");
|
||||
String time = nextOpen.map(dt -> TimeRange.formatTime(dt.toLocalTime(), Messages.getJavaLocale())).orElse("?");
|
||||
return new FormattedSchedule(day, time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats the next closing time for display.
|
||||
*
|
||||
* @param nextClose the next closing time
|
||||
* @return formatted time string
|
||||
*/
|
||||
public static String formatNextClose(Optional<ZonedDateTime> nextClose) {
|
||||
return nextClose.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current schedule status and next times.
|
||||
*
|
||||
* @param scheduleService the schedule service
|
||||
* @param now the current time
|
||||
* @return formatted schedule information
|
||||
*/
|
||||
public static FormattedSchedule getScheduleInfo(ScheduleService scheduleService, ZonedDateTime now) {
|
||||
boolean open = scheduleService.isOpen(now);
|
||||
String nextClose = formatNextClose(scheduleService.nextClose(now));
|
||||
FormattedSchedule nextOpen = formatNextOpen(scheduleService.nextOpen(now));
|
||||
|
||||
return new FormattedSchedule(open, nextClose, nextOpen.day, nextOpen.time);
|
||||
}
|
||||
|
||||
/**
|
||||
* Container for formatted schedule information.
|
||||
*/
|
||||
public static final class FormattedSchedule {
|
||||
public final String day;
|
||||
public final String time;
|
||||
public final boolean isOpen;
|
||||
public final String nextClose;
|
||||
|
||||
public FormattedSchedule(String day, String time) {
|
||||
this.day = day;
|
||||
this.time = time;
|
||||
this.isOpen = false;
|
||||
this.nextClose = null;
|
||||
}
|
||||
|
||||
public FormattedSchedule(boolean isOpen, String nextClose, String day, String time) {
|
||||
this.isOpen = isOpen;
|
||||
this.nextClose = nextClose;
|
||||
this.day = day;
|
||||
this.time = time;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.config.*;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraftforge.event.TickEvent;
|
||||
import net.minecraftforge.eventbus.api.SubscribeEvent;
|
||||
import net.minecraftforge.fml.common.Mod;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* Handles periodic server tick processing for warnings and auto-kick at closing time.
|
||||
* Runs checks every second (20 ticks) to broadcast warnings and enforce closing times.
|
||||
*/
|
||||
@Mod.EventBusSubscriber
|
||||
public class TickScheduler {
|
||||
private static final int TICKS_PER_SECOND = 20;
|
||||
private static int tickCounter = 0;
|
||||
private static final Set<Integer> sentCountdowns = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Server tick event handler that checks for closing warnings and kicks players at closing time.
|
||||
*/
|
||||
@SubscribeEvent
|
||||
public static void onServerTick(TickEvent.ServerTickEvent event) {
|
||||
if (event.phase != TickEvent.Phase.END) return;
|
||||
tickCounter++;
|
||||
if (tickCounter % TICKS_PER_SECOND != 0) return; // once per second
|
||||
|
||||
var server = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer();
|
||||
if (server == null) return;
|
||||
|
||||
ScheduleService sched = ScheduleService.get();
|
||||
if (!ConfigEventHandler.isReady()) {
|
||||
// Config not ready yet; avoid accessing values
|
||||
com.mrkayjaydee.playhours.PlayHoursMod.LOGGER.debug("Tick skipped: config not ready");
|
||||
return;
|
||||
}
|
||||
|
||||
ZonedDateTime now = ZonedDateTime.now(sched.getZoneId());
|
||||
boolean open = sched.isOpen(now);
|
||||
|
||||
if (open) {
|
||||
Optional<ZonedDateTime> nextClose = sched.nextClose(now);
|
||||
if (nextClose.isPresent()) {
|
||||
// Broadcast warnings
|
||||
WarningBroadcaster.broadcastWarnings(server, sched, now, nextClose.get());
|
||||
|
||||
// Handle countdown messages
|
||||
handleCountdown(server, now, nextClose.get());
|
||||
|
||||
// Kick at close - use reliable time comparison
|
||||
if (!now.isBefore(nextClose.get())) {
|
||||
List<ServerPlayer> players = new ArrayList<>(server.getPlayerList().getPlayers());
|
||||
PlayerKickHandler.kickPlayersAtClose(players, sched, nextClose.get());
|
||||
WarningBroadcaster.clearWarnings();
|
||||
sentCountdowns.clear();
|
||||
}
|
||||
} else {
|
||||
WarningBroadcaster.clearWarnings();
|
||||
sentCountdowns.clear();
|
||||
}
|
||||
} else {
|
||||
WarningBroadcaster.clearWarnings();
|
||||
sentCountdowns.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles countdown messages before closing.
|
||||
* Sends messages every second for the configured number of seconds before closing.
|
||||
*/
|
||||
private static void handleCountdown(net.minecraft.server.MinecraftServer server, ZonedDateTime now, ZonedDateTime nextClose) {
|
||||
int countdownSeconds = GeneralConfig.COUNTDOWN_SECONDS.get();
|
||||
if (countdownSeconds <= 0) return;
|
||||
|
||||
long secondsUntilClose = java.time.Duration.between(now, nextClose).getSeconds();
|
||||
|
||||
if (secondsUntilClose <= countdownSeconds && secondsUntilClose > 0) {
|
||||
int seconds = (int) secondsUntilClose;
|
||||
|
||||
// Only send if we haven't sent this countdown yet
|
||||
if (!sentCountdowns.contains(seconds)) {
|
||||
sentCountdowns.add(seconds);
|
||||
server.getPlayerList().broadcastSystemMessage(Messages.countdown(seconds), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.mrkayjaydee.playhours.events;
|
||||
|
||||
import com.mrkayjaydee.playhours.core.ScheduleService;
|
||||
import com.mrkayjaydee.playhours.text.Messages;
|
||||
import net.minecraft.server.MinecraftServer;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
|
||||
/**
|
||||
* Handles warning broadcasts for approaching closing times.
|
||||
* Separates warning logic from tick scheduling.
|
||||
*/
|
||||
public final class WarningBroadcaster {
|
||||
private WarningBroadcaster() {}
|
||||
|
||||
private static final Set<Integer> sentForMinute = new HashSet<>();
|
||||
|
||||
/**
|
||||
* Broadcasts warnings for approaching closing time.
|
||||
*
|
||||
* @param server the Minecraft server
|
||||
* @param scheduleService the schedule service
|
||||
* @param now the current time
|
||||
* @param nextClose the next closing time
|
||||
*/
|
||||
public static void broadcastWarnings(MinecraftServer server, ScheduleService scheduleService,
|
||||
ZonedDateTime now, ZonedDateTime nextClose) {
|
||||
long minutes = Math.max(0, java.time.Duration.between(now, nextClose).toMinutes());
|
||||
int minInt = (int) minutes;
|
||||
|
||||
for (int mark : scheduleService.getWarningMinutes()) {
|
||||
if (minInt == mark && sentForMinute.add(mark)) {
|
||||
String closeTime = ScheduleFormatter.formatNextClose(java.util.Optional.of(nextClose));
|
||||
server.getPlayerList().broadcastSystemMessage(Messages.warn(mark, closeTime), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clears the warning tracking set.
|
||||
*/
|
||||
public static void clearWarnings() {
|
||||
sentForMinute.clear();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
package com.mrkayjaydee.playhours.permissions;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
import net.minecraftforge.server.ServerLifecycleHooks;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* Handles LuckPerms integration for permission checking.
|
||||
* Separates LuckPerms-specific logic from general permission checking.
|
||||
*/
|
||||
public final class LuckPermsIntegration {
|
||||
private LuckPermsIntegration() {}
|
||||
|
||||
// LuckPerms soft integration - detected at startup
|
||||
private static net.luckperms.api.LuckPerms luckPerms;
|
||||
static {
|
||||
try {
|
||||
luckPerms = net.luckperms.api.LuckPermsProvider.get();
|
||||
} catch (Throwable ignored) {
|
||||
// LuckPerms not present, will use vanilla fallback
|
||||
luckPerms = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if LuckPerms is available.
|
||||
*
|
||||
* @return true if LuckPerms is loaded and available
|
||||
*/
|
||||
public static boolean isAvailable() {
|
||||
return luckPerms != null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a permission for an online player using LuckPerms.
|
||||
*
|
||||
* @param player the player to check
|
||||
* @param permission the permission node
|
||||
* @return true if the player has the permission
|
||||
*/
|
||||
public static boolean hasPermission(ServerPlayer player, String permission) {
|
||||
if (!isAvailable()) return false;
|
||||
|
||||
var user = luckPerms.getUserManager().getUser(player.getUUID());
|
||||
if (user == null) return false;
|
||||
|
||||
var data = user.getCachedData().getPermissionData();
|
||||
var result = data.checkPermission(permission);
|
||||
return result.asBoolean();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a permission for an offline player by UUID using LuckPerms.
|
||||
* Uses a timeout to prevent blocking indefinitely.
|
||||
*
|
||||
* @param uuid the player UUID
|
||||
* @param permission the permission node
|
||||
* @return true if the player has the permission, false otherwise or on timeout
|
||||
*/
|
||||
public static boolean hasPermissionOffline(UUID uuid, String permission) {
|
||||
if (!isAvailable()) return false;
|
||||
|
||||
// Check if player is online first
|
||||
var server = ServerLifecycleHooks.getCurrentServer();
|
||||
ServerPlayer online = server.getPlayerList().getPlayer(uuid);
|
||||
if (online != null) {
|
||||
return hasPermission(online, permission);
|
||||
}
|
||||
|
||||
// Offline LP check (best-effort with timeout to avoid blocking)
|
||||
try {
|
||||
var future = luckPerms.getUserManager().loadUser(uuid);
|
||||
var user = future.get(PermissionConstants.LUCKPERMS_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (user != null) {
|
||||
var result = user.getCachedData().getPermissionData().checkPermission(permission);
|
||||
return result.asBoolean();
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
// Timeout, interrupted, or other error - log and continue
|
||||
// This is acceptable as it's best-effort for offline checks
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.mrkayjaydee.playhours.permissions;
|
||||
|
||||
import net.minecraft.server.level.ServerPlayer;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Main permission checking utility with LuckPerms soft integration.
|
||||
* Falls back to vanilla operator levels when LuckPerms is not present.
|
||||
*/
|
||||
public final class PermissionChecker {
|
||||
private PermissionChecker() {}
|
||||
|
||||
/**
|
||||
* Checks if a player has view permission.
|
||||
*
|
||||
* @param player the player to check
|
||||
* @return true if the player has view permission
|
||||
*/
|
||||
public static boolean hasView(ServerPlayer player) {
|
||||
return hasPermission(player, PermissionConstants.VIEW, PermissionConstants.VIEW_FALLBACK_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a player has admin permission.
|
||||
*
|
||||
* @param player the player to check
|
||||
* @return true if the player has admin permission
|
||||
*/
|
||||
public static boolean hasAdmin(ServerPlayer player) {
|
||||
return hasPermission(player, PermissionConstants.ADMIN, PermissionConstants.ADMIN_FALLBACK_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a player has exempt permission.
|
||||
*
|
||||
* @param player the player to check
|
||||
* @return true if the player has exempt permission
|
||||
*/
|
||||
public static boolean isExempt(ServerPlayer player) {
|
||||
return hasPermission(player, PermissionConstants.EXEMPT, PermissionConstants.ADMIN_FALLBACK_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if an offline player has exempt permission by UUID.
|
||||
* Uses LuckPerms if available, with a timeout to prevent blocking indefinitely.
|
||||
*
|
||||
* @param uuid the player UUID
|
||||
* @return true if the player has exempt permission, false otherwise or on timeout
|
||||
*/
|
||||
public static boolean isExempt(UUID uuid) {
|
||||
if (LuckPermsIntegration.isAvailable()) {
|
||||
return LuckPermsIntegration.hasPermissionOffline(uuid, PermissionConstants.EXEMPT);
|
||||
}
|
||||
// Fallback: assume not exempt when offline or unavailable
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks a permission for a player with fallback to vanilla operator levels.
|
||||
*
|
||||
* @param player the player to check
|
||||
* @param permission the permission node
|
||||
* @param fallbackLevel the vanilla operator level to fall back to
|
||||
* @return true if the player has the permission
|
||||
*/
|
||||
public static boolean hasPermission(ServerPlayer player, String permission, int fallbackLevel) {
|
||||
if (LuckPermsIntegration.isAvailable()) {
|
||||
if (LuckPermsIntegration.hasPermission(player, permission)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return player.hasPermissions(fallbackLevel);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.mrkayjaydee.playhours.permissions;
|
||||
|
||||
/**
|
||||
* Constants for permission nodes and timeouts.
|
||||
* Centralizes permission-related constants to avoid magic values.
|
||||
*/
|
||||
public final class PermissionConstants {
|
||||
private PermissionConstants() {}
|
||||
|
||||
// Permission nodes
|
||||
public static final String ADMIN = "playhours.admin";
|
||||
public static final String EXEMPT = "playhours.exempt";
|
||||
public static final String VIEW = "playhours.view";
|
||||
|
||||
// Timeouts and fallbacks
|
||||
public static final int LUCKPERMS_TIMEOUT_SECONDS = 2;
|
||||
public static final int ADMIN_FALLBACK_LEVEL = 2;
|
||||
public static final int VIEW_FALLBACK_LEVEL = 1;
|
||||
}
|
||||
69
src/main/java/com/mrkayjaydee/playhours/text/JsonParser.java
Normal file
69
src/main/java/com/mrkayjaydee/playhours/text/JsonParser.java
Normal file
@@ -0,0 +1,69 @@
|
||||
package com.mrkayjaydee.playhours.text;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Simple JSON parser for flat string maps.
|
||||
* Handles basic JSON parsing for message bundles.
|
||||
*/
|
||||
public final class JsonParser {
|
||||
private JsonParser() {}
|
||||
|
||||
/**
|
||||
* Parses a simple JSON object containing string key-value pairs.
|
||||
*
|
||||
* @param json the JSON string to parse
|
||||
* @return a map of key-value pairs
|
||||
*/
|
||||
public static Map<String, String> parse(String json) {
|
||||
Map<String, String> result = new HashMap<>();
|
||||
String trimmed = json.trim();
|
||||
|
||||
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) {
|
||||
return result;
|
||||
}
|
||||
|
||||
trimmed = trimmed.substring(1, trimmed.length() - 1).trim();
|
||||
if (trimmed.isEmpty()) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// Split on commas not within quotes - simple approach assumes no escaped quotes or commas in values
|
||||
int i = 0;
|
||||
boolean inQuotes = false;
|
||||
StringBuilder sb = new StringBuilder();
|
||||
java.util.List<String> parts = new java.util.ArrayList<>();
|
||||
|
||||
while (i < trimmed.length()) {
|
||||
char c = trimmed.charAt(i);
|
||||
if (c == '"') inQuotes = !inQuotes;
|
||||
if (c == ',' && !inQuotes) {
|
||||
parts.add(sb.toString());
|
||||
sb.setLength(0);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
sb.append(c);
|
||||
i++;
|
||||
}
|
||||
parts.add(sb.toString());
|
||||
|
||||
for (String part : parts) {
|
||||
String[] kv = part.split(":", 2);
|
||||
if (kv.length != 2) continue;
|
||||
String key = unquote(kv[0].trim());
|
||||
String value = unquote(kv[1].trim());
|
||||
result.put(key, value);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static String unquote(String s) {
|
||||
if (s.startsWith("\"") && s.endsWith("\"")) {
|
||||
return s.substring(1, s.length() - 1);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.mrkayjaydee.playhours.text;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* Handles loading of language files and locale management.
|
||||
* Separates locale loading logic from message formatting.
|
||||
*/
|
||||
public final class LocaleLoader {
|
||||
private LocaleLoader() {}
|
||||
|
||||
/**
|
||||
* Loads a language bundle from the classpath.
|
||||
*
|
||||
* @param locale the locale to load (e.g., "en_us", "fr_fr")
|
||||
* @return a map of message keys to translated strings
|
||||
*/
|
||||
public static Map<String, String> loadBundle(String locale) {
|
||||
String path = "assets/playhours/lang/" + locale + ".json";
|
||||
try (InputStream in = LocaleLoader.class.getClassLoader().getResourceAsStream(path)) {
|
||||
if (in == null) return getDefaultBundle();
|
||||
|
||||
String json = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8))
|
||||
.lines().collect(Collectors.joining("\n"));
|
||||
return JsonParser.parse(json);
|
||||
} catch (Exception e) {
|
||||
return getDefaultBundle();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the default message bundle with fallback messages.
|
||||
*
|
||||
* @return a map containing default English messages
|
||||
*/
|
||||
public static Map<String, String> getDefaultBundle() {
|
||||
Map<String, String> messages = new HashMap<>();
|
||||
messages.put("msg.access_denied", "Server closed. Next open: %openday% at %opentime%.");
|
||||
messages.put("msg.threshold_denied", "Server closing soon. Next open: %openday% at %opentime%.");
|
||||
messages.put("msg.warn", "Server closing in %minutes% minute%s% at %closetime%.");
|
||||
messages.put("msg.kick", "Server closed. Next open: %openday% at %opentime%.");
|
||||
messages.put("msg.force_open", "Hours overridden: FORCE_OPEN.");
|
||||
messages.put("msg.force_closed", "Hours overridden: FORCE_CLOSED.");
|
||||
messages.put("msg.status_line", "Mode: %mode%. %isopen%. Next close: %closetime%. Next open: %openday% at %opentime%.");
|
||||
messages.put("msg.status_open", "Server open");
|
||||
messages.put("msg.status_closed", "Server closed");
|
||||
messages.put("msg.countdown", "Closing in %seconds%s");
|
||||
|
||||
// Admin/command feedback messages
|
||||
messages.put("msg.config_not_ready", "PlayHours config not ready yet. Try again in a moment.");
|
||||
messages.put("msg.unexpected_error", "An unexpected error occurred. See server log.");
|
||||
messages.put("msg.config_reloaded", "PlayHours config reloaded.");
|
||||
messages.put("msg.invalid_time_range", "Invalid time range. Use: hh:mm AM-hh:mm PM");
|
||||
messages.put("msg.failed_clear_default_periods", "Failed to clear default periods.");
|
||||
messages.put("msg.settings_updated", "PlayHours settings updated.");
|
||||
|
||||
// Force mode display messages
|
||||
messages.put("msg.mode_normal", "Normal");
|
||||
messages.put("msg.mode_force_open", "Always Open");
|
||||
messages.put("msg.mode_force_closed", "Maintenance");
|
||||
|
||||
// Formatting and placeholder strings
|
||||
messages.put("msg.yes", "yes");
|
||||
messages.put("msg.no", "no");
|
||||
messages.put("msg.day_time_separator", " at ");
|
||||
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.mrkayjaydee.playhours.text;
|
||||
|
||||
import net.minecraft.ChatFormatting;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Handles message formatting and placeholder replacement.
|
||||
* Separates formatting logic from message loading and locale management.
|
||||
*/
|
||||
public final class MessageFormatter {
|
||||
private MessageFormatter() {}
|
||||
|
||||
/**
|
||||
* Creates a formatted message component with placeholder replacement.
|
||||
*
|
||||
* @param template the message template with placeholders
|
||||
* @param tokens the token replacements
|
||||
* @return a formatted component
|
||||
*/
|
||||
public static Component formatMessage(String template, Map<String, String> tokens) {
|
||||
String formatted = applyTokens(template, tokens);
|
||||
return Component.literal(formatted).withStyle(ChatFormatting.YELLOW);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies token replacements to a message template.
|
||||
*
|
||||
* @param template the message template
|
||||
* @param tokens the token replacements
|
||||
* @return the formatted message string
|
||||
*/
|
||||
public static String applyTokens(String template, Map<String, String> tokens) {
|
||||
String result = template;
|
||||
for (Map.Entry<String, String> entry : tokens.entrySet()) {
|
||||
result = result.replace(entry.getKey(), entry.getValue());
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a value is not blank and adds it to the map.
|
||||
*
|
||||
* @param map the map to add to
|
||||
* @param key the key
|
||||
* @param value the value to check
|
||||
*/
|
||||
public static void putIfNotBlank(Map<String, String> map, String key, String value) {
|
||||
if (value != null && !value.isBlank()) {
|
||||
map.put(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.mrkayjaydee.playhours.text;
|
||||
|
||||
/**
|
||||
* Constants for message keys used throughout the mod.
|
||||
* Centralizes message key definitions to avoid magic strings.
|
||||
*/
|
||||
public final class MessageKeys {
|
||||
private MessageKeys() {}
|
||||
|
||||
// Player messages
|
||||
public static final String ACCESS_DENIED = "msg.access_denied";
|
||||
public static final String THRESHOLD_DENIED = "msg.threshold_denied";
|
||||
public static final String WARN = "msg.warn";
|
||||
public static final String KICK = "msg.kick";
|
||||
public static final String FORCE_OPEN = "msg.force_open";
|
||||
public static final String FORCE_CLOSED = "msg.force_closed";
|
||||
public static final String STATUS_LINE = "msg.status_line";
|
||||
public static final String STATUS_OPEN = "msg.status_open";
|
||||
public static final String STATUS_CLOSED = "msg.status_closed";
|
||||
public static final String COUNTDOWN = "msg.countdown";
|
||||
|
||||
// Admin/command feedback messages
|
||||
public static final String CONFIG_NOT_READY = "msg.config_not_ready";
|
||||
public static final String UNEXPECTED_ERROR = "msg.unexpected_error";
|
||||
public static final String CONFIG_RELOADED = "msg.config_reloaded";
|
||||
public static final String INVALID_TIME_RANGE = "msg.invalid_time_range";
|
||||
public static final String FAILED_CLEAR_DEFAULT_PERIODS = "msg.failed_clear_default_periods";
|
||||
public static final String SETTINGS_UPDATED = "msg.settings_updated";
|
||||
|
||||
// MOTD messages
|
||||
public static final String MOTD_STATUS_OPEN = "msg.motd_status_open";
|
||||
public static final String MOTD_STATUS_CLOSED = "msg.motd_status_closed";
|
||||
public static final String MOTD_NEXT_OPEN = "msg.motd_next_open";
|
||||
public static final String MOTD_NEXT_CLOSE = "msg.motd_next_close";
|
||||
public static final String MOTD_COUNTDOWN = "msg.motd_countdown";
|
||||
public static final String MOTD_FORCE_OPEN = "msg.motd_force_open";
|
||||
public static final String MOTD_FORCE_CLOSED = "msg.motd_force_closed";
|
||||
|
||||
// Force mode display messages
|
||||
public static final String MODE_NORMAL = "msg.mode_normal";
|
||||
public static final String MODE_FORCE_OPEN = "msg.mode_force_open";
|
||||
public static final String MODE_FORCE_CLOSED = "msg.mode_force_closed";
|
||||
|
||||
// Formatting and placeholder strings
|
||||
public static final String YES = "msg.yes";
|
||||
public static final String NO = "msg.no";
|
||||
public static final String DAY_TIME_SEPARATOR = "msg.day_time_separator";
|
||||
}
|
||||
147
src/main/java/com/mrkayjaydee/playhours/text/Messages.java
Normal file
147
src/main/java/com/mrkayjaydee/playhours/text/Messages.java
Normal file
@@ -0,0 +1,147 @@
|
||||
package com.mrkayjaydee.playhours.text;
|
||||
|
||||
import com.mrkayjaydee.playhours.config.GeneralConfig;
|
||||
import com.mrkayjaydee.playhours.config.MessagesConfig;
|
||||
import net.minecraft.network.chat.Component;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Message system with locale support and config overrides.
|
||||
* Loads messages from lang files and allows per-message config overrides.
|
||||
*/
|
||||
public final class Messages {
|
||||
private Messages() {}
|
||||
|
||||
private static volatile String currentLocale = "en_us";
|
||||
private static volatile Map<String, String> bundle = new HashMap<>();
|
||||
// Cache of config overrides to avoid reading config at call sites before spec is ready
|
||||
private static volatile Map<String, String> overrides = new HashMap<>();
|
||||
|
||||
/**
|
||||
* Reloads messages from config, setting the locale from ServerConfig.
|
||||
*/
|
||||
public static void reloadFromConfig() {
|
||||
String loc = GeneralConfig.MESSAGE_LOCALE.get();
|
||||
setLocale(loc == null || loc.isBlank() ? "en_us" : loc);
|
||||
// Snapshot overrides once per config reload
|
||||
Map<String, String> o = new HashMap<>();
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.ACCESS_DENIED, MessagesConfig.ACCESS_DENIED.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.THRESHOLD_DENIED, MessagesConfig.THRESHOLD_DENIED.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.WARN, MessagesConfig.WARN.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.KICK, MessagesConfig.KICK.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.FORCE_OPEN, MessagesConfig.FORCE_OPEN.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.FORCE_CLOSED, MessagesConfig.FORCE_CLOSED.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.STATUS_LINE, MessagesConfig.STATUS_LINE.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.STATUS_OPEN, MessagesConfig.STATUS_OPEN.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.STATUS_CLOSED, MessagesConfig.STATUS_CLOSED.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.COUNTDOWN, MessagesConfig.COUNTDOWN.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.CONFIG_NOT_READY, MessagesConfig.CONFIG_NOT_READY.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.UNEXPECTED_ERROR, MessagesConfig.UNEXPECTED_ERROR.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.CONFIG_RELOADED, MessagesConfig.CONFIG_RELOADED.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.INVALID_TIME_RANGE, MessagesConfig.INVALID_TIME_RANGE.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.FAILED_CLEAR_DEFAULT_PERIODS, MessagesConfig.FAILED_CLEAR_DEFAULT_PERIODS.get());
|
||||
MessageFormatter.putIfNotBlank(o, MessageKeys.SETTINGS_UPDATED, MessagesConfig.SETTINGS_UPDATED.get());
|
||||
overrides = o;
|
||||
}
|
||||
|
||||
public static void setLocale(String locale) {
|
||||
currentLocale = locale;
|
||||
bundle = LocaleLoader.loadBundle(locale);
|
||||
}
|
||||
|
||||
public static java.util.Locale getJavaLocale() {
|
||||
try {
|
||||
String[] parts = currentLocale.split("[_-]");
|
||||
if (parts.length == 1) return new java.util.Locale.Builder().setLanguage(parts[0]).build();
|
||||
if (parts.length >= 2) return new java.util.Locale.Builder().setLanguage(parts[0]).setRegion(parts[1].toUpperCase()).build();
|
||||
} catch (Throwable ignored) {}
|
||||
return java.util.Locale.ENGLISH;
|
||||
}
|
||||
|
||||
|
||||
public static Component accessDenied(String day, String time) {
|
||||
return fromKeyOrConfig(MessageKeys.ACCESS_DENIED, Map.of(
|
||||
"%openday%", day,
|
||||
"%opentime%", time
|
||||
));
|
||||
}
|
||||
|
||||
public static Component thresholdDenied(String day, String time) {
|
||||
return fromKeyOrConfig(MessageKeys.THRESHOLD_DENIED, Map.of(
|
||||
"%openday%", day,
|
||||
"%opentime%", time
|
||||
));
|
||||
}
|
||||
|
||||
public static Component warn(int minutes, String closeTime) {
|
||||
String s = minutes == 1 ? "" : "s";
|
||||
return fromKeyOrConfig(MessageKeys.WARN, Map.of(
|
||||
"%minutes%", String.valueOf(minutes),
|
||||
"%s%", s,
|
||||
"%closetime%", closeTime
|
||||
));
|
||||
}
|
||||
|
||||
public static Component kick(String day, String time) {
|
||||
return fromKeyOrConfig(MessageKeys.KICK, Map.of(
|
||||
"%openday%", day,
|
||||
"%opentime%", time
|
||||
));
|
||||
}
|
||||
|
||||
public static Component forceOpen() { return fromKeyOrConfig(MessageKeys.FORCE_OPEN, Map.of()); }
|
||||
public static Component forceClosed() { return fromKeyOrConfig(MessageKeys.FORCE_CLOSED, Map.of()); }
|
||||
|
||||
public static Component statusLine(String mode, boolean isOpen, String nextClose, String nextOpenDay, String nextOpenTime) {
|
||||
String statusText = isOpen ? translate(MessageKeys.STATUS_OPEN) : translate(MessageKeys.STATUS_CLOSED);
|
||||
return fromKeyOrConfig(MessageKeys.STATUS_LINE, Map.of(
|
||||
"%mode%", mode,
|
||||
"%isopen%", statusText,
|
||||
"%closetime%", nextClose,
|
||||
"%openday%", nextOpenDay,
|
||||
"%opentime%", nextOpenTime
|
||||
));
|
||||
}
|
||||
|
||||
public static Component countdown(int seconds) {
|
||||
return fromKeyOrConfig(MessageKeys.COUNTDOWN, Map.of(
|
||||
"%seconds%", String.valueOf(seconds)
|
||||
));
|
||||
}
|
||||
|
||||
// Admin/command feedback helpers
|
||||
public static Component configNotReady() { return fromKeyOrConfig(MessageKeys.CONFIG_NOT_READY, Map.of()); }
|
||||
public static Component unexpectedError() { return fromKeyOrConfig(MessageKeys.UNEXPECTED_ERROR, Map.of()); }
|
||||
public static Component configReloaded() { return fromKeyOrConfig(MessageKeys.CONFIG_RELOADED, Map.of()); }
|
||||
public static Component invalidTimeRange() { return fromKeyOrConfig(MessageKeys.INVALID_TIME_RANGE, Map.of()); }
|
||||
public static Component failedClearDefaultPeriods() { return fromKeyOrConfig(MessageKeys.FAILED_CLEAR_DEFAULT_PERIODS, Map.of()); }
|
||||
public static Component settingsUpdated() { return fromKeyOrConfig(MessageKeys.SETTINGS_UPDATED, Map.of()); }
|
||||
|
||||
private static Component fromKeyOrConfig(String key, Map<String, String> tokens) {
|
||||
String override = overrides.get(key);
|
||||
String base = override != null && !override.isBlank() ? override : translate(key);
|
||||
return MessageFormatter.formatMessage(base, tokens);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public method to get a translated message string by key.
|
||||
* This is used for custom formatting where Component objects are not needed.
|
||||
*
|
||||
* @param key the message key (e.g., "msg.motd_status_open")
|
||||
* @return the translated string, or the key itself if not found
|
||||
*/
|
||||
public static String get(String key) {
|
||||
String override = overrides.get(key);
|
||||
if (override != null && !override.isBlank()) {
|
||||
return override;
|
||||
}
|
||||
return translate(key);
|
||||
}
|
||||
|
||||
private static String translate(String key) {
|
||||
return bundle.getOrDefault(key, LocaleLoader.getDefaultBundle().getOrDefault(key, key));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user