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:
Mr¤KayJayDee
2025-10-23 23:28:20 +02:00
parent 58919ced40
commit c0fd2a2787
63 changed files with 6974 additions and 265 deletions

View 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");
}
}

View File

@@ -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);
}
}

View File

@@ -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));
}
}

View File

@@ -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;
}
}

View File

@@ -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;
})));
}
}

View File

@@ -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;
})));
}
}

View File

@@ -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;
})));
}
}

View File

@@ -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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View 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
}

View File

@@ -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);
};
}
}

View File

@@ -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;
}
}

View File

@@ -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();
}
}

View File

@@ -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);
}
}

View 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
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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));
}
}
}
}
}

View 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;
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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());
}
}

View File

@@ -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));
}
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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);
}
}
}
}

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}

View 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;
}
}

View File

@@ -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;
}
}

View File

@@ -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);
}
}
}

View File

@@ -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";
}

View 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));
}
}