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

@@ -1,64 +0,0 @@
package com.example.examplemod;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.world.item.Item;
import net.minecraftforge.common.ForgeConfigSpec;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.event.config.ModConfigEvent;
import net.minecraftforge.registries.ForgeRegistries;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
// An example config class. This is not required, but it's a good idea to have one to keep your config organized.
// Demonstrates how to use Forge's config APIs
@Mod.EventBusSubscriber(modid = ExampleMod.MODID, bus = Mod.EventBusSubscriber.Bus.MOD)
public class Config
{
private static final ForgeConfigSpec.Builder BUILDER = new ForgeConfigSpec.Builder();
private static final ForgeConfigSpec.BooleanValue LOG_DIRT_BLOCK = BUILDER
.comment("Whether to log the dirt block on common setup")
.define("logDirtBlock", true);
private static final ForgeConfigSpec.IntValue MAGIC_NUMBER = BUILDER
.comment("A magic number")
.defineInRange("magicNumber", 42, 0, Integer.MAX_VALUE);
public static final ForgeConfigSpec.ConfigValue<String> MAGIC_NUMBER_INTRODUCTION = BUILDER
.comment("What you want the introduction message to be for the magic number")
.define("magicNumberIntroduction", "The magic number is... ");
// a list of strings that are treated as resource locations for items
private static final ForgeConfigSpec.ConfigValue<List<? extends String>> ITEM_STRINGS = BUILDER
.comment("A list of items to log on common setup.")
.defineListAllowEmpty("items", List.of("minecraft:iron_ingot"), Config::validateItemName);
static final ForgeConfigSpec SPEC = BUILDER.build();
public static boolean logDirtBlock;
public static int magicNumber;
public static String magicNumberIntroduction;
public static Set<Item> items;
private static boolean validateItemName(final Object obj)
{
return obj instanceof final String itemName && ForgeRegistries.ITEMS.containsKey(new ResourceLocation(itemName));
}
@SubscribeEvent
static void onLoad(final ModConfigEvent event)
{
logDirtBlock = LOG_DIRT_BLOCK.get();
magicNumber = MAGIC_NUMBER.get();
magicNumberIntroduction = MAGIC_NUMBER_INTRODUCTION.get();
// convert the list of strings into a set of items
items = ITEM_STRINGS.get().stream()
.map(itemName -> ForgeRegistries.ITEMS.getValue(new ResourceLocation(itemName)))
.collect(Collectors.toSet());
}
}

View File

@@ -1,127 +0,0 @@
package com.example.examplemod;
import com.mojang.logging.LogUtils;
import net.minecraft.client.Minecraft;
import net.minecraft.core.registries.Registries;
import net.minecraft.world.food.FoodProperties;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraft.world.item.CreativeModeTabs;
import net.minecraft.world.item.Item;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
import net.minecraftforge.api.distmarker.Dist;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.event.BuildCreativeModeTabContentsEvent;
import net.minecraftforge.event.server.ServerStartingEvent;
import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.config.ModConfig;
import net.minecraftforge.fml.event.lifecycle.FMLClientSetupEvent;
import net.minecraftforge.fml.event.lifecycle.FMLCommonSetupEvent;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
import org.slf4j.Logger;
// The value here should match an entry in the META-INF/mods.toml file
@Mod(ExampleMod.MODID)
public class ExampleMod
{
// Define mod id in a common place for everything to reference
public static final String MODID = "examplemod";
// Directly reference a slf4j logger
private static final Logger LOGGER = LogUtils.getLogger();
// Create a Deferred Register to hold Blocks which will all be registered under the "examplemod" namespace
public static final DeferredRegister<Block> BLOCKS = DeferredRegister.create(ForgeRegistries.BLOCKS, MODID);
// Create a Deferred Register to hold Items which will all be registered under the "examplemod" namespace
public static final DeferredRegister<Item> ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, MODID);
// Create a Deferred Register to hold CreativeModeTabs which will all be registered under the "examplemod" namespace
public static final DeferredRegister<CreativeModeTab> CREATIVE_MODE_TABS = DeferredRegister.create(Registries.CREATIVE_MODE_TAB, MODID);
// Creates a new Block with the id "examplemod:example_block", combining the namespace and path
public static final RegistryObject<Block> EXAMPLE_BLOCK = BLOCKS.register("example_block", () -> new Block(BlockBehaviour.Properties.of().mapColor(MapColor.STONE)));
// Creates a new BlockItem with the id "examplemod:example_block", combining the namespace and path
public static final RegistryObject<Item> EXAMPLE_BLOCK_ITEM = ITEMS.register("example_block", () -> new BlockItem(EXAMPLE_BLOCK.get(), new Item.Properties()));
// Creates a new food item with the id "examplemod:example_id", nutrition 1 and saturation 2
public static final RegistryObject<Item> EXAMPLE_ITEM = ITEMS.register("example_item", () -> new Item(new Item.Properties().food(new FoodProperties.Builder()
.alwaysEat().nutrition(1).saturationMod(2f).build())));
// Creates a creative tab with the id "examplemod:example_tab" for the example item, that is placed after the combat tab
public static final RegistryObject<CreativeModeTab> EXAMPLE_TAB = CREATIVE_MODE_TABS.register("example_tab", () -> CreativeModeTab.builder()
.withTabsBefore(CreativeModeTabs.COMBAT)
.icon(() -> EXAMPLE_ITEM.get().getDefaultInstance())
.displayItems((parameters, output) -> {
output.accept(EXAMPLE_ITEM.get()); // Add the example item to the tab. For your own tabs, this method is preferred over the event
}).build());
public ExampleMod(FMLJavaModLoadingContext context)
{
IEventBus modEventBus = context.getModEventBus();
// Register the commonSetup method for modloading
modEventBus.addListener(this::commonSetup);
// Register the Deferred Register to the mod event bus so blocks get registered
BLOCKS.register(modEventBus);
// Register the Deferred Register to the mod event bus so items get registered
ITEMS.register(modEventBus);
// Register the Deferred Register to the mod event bus so tabs get registered
CREATIVE_MODE_TABS.register(modEventBus);
// Register ourselves for server and other game events we are interested in
MinecraftForge.EVENT_BUS.register(this);
// Register the item to a creative tab
modEventBus.addListener(this::addCreative);
// Register our mod's ForgeConfigSpec so that Forge can create and load the config file for us
context.registerConfig(ModConfig.Type.COMMON, Config.SPEC);
}
private void commonSetup(final FMLCommonSetupEvent event)
{
// Some common setup code
LOGGER.info("HELLO FROM COMMON SETUP");
if (Config.logDirtBlock)
LOGGER.info("DIRT BLOCK >> {}", ForgeRegistries.BLOCKS.getKey(Blocks.DIRT));
LOGGER.info(Config.magicNumberIntroduction + Config.magicNumber);
Config.items.forEach((item) -> LOGGER.info("ITEM >> {}", item.toString()));
}
// Add the example block item to the building blocks tab
private void addCreative(BuildCreativeModeTabContentsEvent event)
{
if (event.getTabKey() == CreativeModeTabs.BUILDING_BLOCKS)
event.accept(EXAMPLE_BLOCK_ITEM);
}
// You can use SubscribeEvent and let the Event Bus discover methods to call
@SubscribeEvent
public void onServerStarting(ServerStartingEvent event)
{
// Do something when the server starts
LOGGER.info("HELLO from server starting");
}
// You can use EventBusSubscriber to automatically register all static methods in the class annotated with @SubscribeEvent
@Mod.EventBusSubscriber(modid = MODID, bus = Mod.EventBusSubscriber.Bus.MOD, value = Dist.CLIENT)
public static class ClientModEvents
{
@SubscribeEvent
public static void onClientSetup(FMLClientSetupEvent event)
{
// Some client setup code
LOGGER.info("HELLO FROM CLIENT SETUP");
LOGGER.info("MINECRAFT NAME >> {}", Minecraft.getInstance().getUser().getName());
}
}
}

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

View File

@@ -8,7 +8,6 @@ modLoader="javafml" #mandatory
# A version range to match for said mod loader - for regular FML @Mod it will be the forge version
loaderVersion="${loader_version_range}" #mandatory This is typically bumped every Minecraft version by Forge. See our download page for lists of versions.
# The license for you mod. This is mandatory metadata and allows for easier comprehension of your redistributive properties.
# Review your options at https://choosealicense.com/. All rights reserved is the default copyright stance, and is thus the default here.
license="${mod_license}"
# A URL to refer people to when problems occur with this mod
#issueTrackerURL="https://change.me.to.your.issue.tracker.example.invalid/" #optional
@@ -26,20 +25,16 @@ displayName="${mod_name}" #mandatory
# A URL to query for updates for this mod. See the JSON update specification https://docs.minecraftforge.net/en/latest/misc/updatechecker/
#updateJSONURL="https://change.me.example.invalid/updates.json" #optional
# A URL for the "homepage" for this mod, displayed in the mod UI
#displayURL="https://change.me.to.your.mods.homepage.example.invalid/" #optional
#displayURL="https://example.invalid/" #optional
# A file name (in the root of the mod JAR) containing a logo for display
#logoFile="examplemod.png" #optional
#logoFile="playhours.png" #optional
# A text field displayed in the mod UI
#credits="" #optional
# A text field displayed in the mod UI
authors="${mod_authors}" #optional
# Display Test controls the display for your mod in the server connection screen
# MATCH_VERSION means that your mod will cause a red X if the versions on client and server differ. This is the default behaviour and should be what you choose if you have server and client elements to your mod.
# IGNORE_SERVER_VERSION means that your mod will not cause a red X if it's present on the server but not on the client. This is what you should use if you're a server only mod.
# IGNORE_ALL_VERSION means that your mod will not cause a red X if it's present on the client or the server. This is a special case and should only be used if your mod has no server component.
# NONE means that no display test is set on your mod. You need to do this yourself, see IExtensionPoint.DisplayTest for more information. You can define any scheme you wish with this value.
# IMPORTANT NOTE: this is NOT an instruction as to which environments (CLIENT or DEDICATED SERVER) your mod loads on. Your mod should load (and maybe do nothing!) whereever it finds itself.
#displayTest="MATCH_VERSION" # if nothing is specified, MATCH_VERSION is the default when clientSideOnly=false, otherwise IGNORE_ALL_VERSION when clientSideOnly=true (#optional)
# For server-only mods, IGNORE_SERVER_VERSION avoids red X when client lacks the mod
displayTest="IGNORE_SERVER_VERSION"
# The description text for the mod (multi line!) (#mandatory)
description='''${mod_description}'''
@@ -56,7 +51,7 @@ description='''${mod_description}'''
# AFTER - This mod is loaded AFTER the dependency
ordering="NONE"
# Side this dependency is applied on - BOTH, CLIENT, or SERVER
side="BOTH"
side="BOTH"
# Here's another dependency
[[dependencies.${mod_id}]]
modId="minecraft"
@@ -64,7 +59,7 @@ description='''${mod_description}'''
# This version range declares a minimum of the current minecraft version up to but not including the next major version
versionRange="${minecraft_version_range}"
ordering="NONE"
side="BOTH"
side="BOTH"
# Features are specific properties of the game environment, that you may want to declare you require. This example declares
# that your mod requires GL version 3.2 or higher. Other features will be added. They are side aware so declaring this won't

View File

@@ -0,0 +1,32 @@
{
"msg.access_denied": "Server closed. Next open: %openday% at %opentime%.",
"msg.threshold_denied": "Server closing soon. Next open: %openday% at %opentime%.",
"msg.warn": "Server closing in %minutes% minute%s% at %closetime%.",
"msg.kick": "Server closed. Next open: %openday% at %opentime%.",
"msg.force_open": "Hours overridden: FORCE_OPEN.",
"msg.force_closed": "Hours overridden: FORCE_CLOSED.",
"msg.status_line": "Mode: %mode%. %isopen%. Next close: %closetime%. Next open: %openday% at %opentime%.",
"msg.status_open": "Server open",
"msg.status_closed": "Server closed",
"msg.countdown": "Closing in %seconds%s",
"msg.config_not_ready": "PlayHours config not ready yet. Try again in a moment.",
"msg.unexpected_error": "An unexpected error occurred. See server log.",
"msg.config_reloaded": "PlayHours config reloaded.",
"msg.invalid_time_range": "Invalid time range. Use: hh:mm AM-hh:mm PM",
"msg.failed_clear_default_periods": "Failed to clear default periods.",
"msg.settings_updated": "PlayHours settings updated.",
"msg.motd_status_open": "Open",
"msg.motd_status_closed": "Closed",
"msg.motd_next_open": "Opens %openday% at %opentime%",
"msg.motd_next_close": "Closes at %closetime%",
"msg.motd_countdown": "Closing in %minutes% min",
"msg.motd_force_open": "Always Open",
"msg.motd_force_closed": "Maintenance",
"msg.mode_normal": "Normal",
"msg.mode_force_open": "Always Open",
"msg.mode_force_closed": "Maintenance",
"msg.yes": "yes",
"msg.no": "no",
"msg.day_time_separator": " at "
}

View File

@@ -0,0 +1,32 @@
{
"msg.access_denied": "Serveur fermé. Prochaine ouverture : %openday% à %opentime%.",
"msg.threshold_denied": "Fermeture imminente. Prochaine ouverture : %openday% à %opentime%.",
"msg.warn": "Fermeture dans %minutes% minute%s% à %closetime%.",
"msg.kick": "Serveur fermé. Prochaine ouverture : %openday% à %opentime%.",
"msg.force_open": "Horaires forcés : FORCE_OPEN.",
"msg.force_closed": "Horaires forcés : FORCE_CLOSED.",
"msg.status_line": "Mode : %mode%. %isopen%. Prochaine fermeture : %closetime%. Prochaine ouverture : %openday% à %opentime%.",
"msg.status_open": "Serveur ouvert",
"msg.status_closed": "Serveur fermé",
"msg.countdown": "Fermeture dans %seconds%s",
"msg.config_not_ready": "La configuration PlayHours n'est pas encore prête. Réessayez dans un instant.",
"msg.unexpected_error": "Une erreur inattendue s'est produite. Voir le journal du serveur.",
"msg.config_reloaded": "Configuration PlayHours rechargée.",
"msg.invalid_time_range": "Plage horaire invalide. Utilisez : hh:mm AM-hh:mm PM",
"msg.failed_clear_default_periods": "Échec de l'effacement des périodes par défaut.",
"msg.settings_updated": "Paramètres PlayHours mis à jour.",
"msg.motd_status_open": "Ouvert",
"msg.motd_status_closed": "Fermé",
"msg.motd_next_open": "Ouverture %openday% à %opentime%",
"msg.motd_next_close": "Fermeture à %closetime%",
"msg.motd_countdown": "Fermeture dans %minutes% min",
"msg.motd_force_open": "Toujours Ouvert",
"msg.motd_force_closed": "Maintenance",
"msg.mode_normal": "Normal",
"msg.mode_force_open": "Toujours Ouvert",
"msg.mode_force_closed": "Maintenance",
"msg.yes": "oui",
"msg.no": "non",
"msg.day_time_separator": " à "
}

View File

@@ -1,7 +1,7 @@
{
"pack": {
"description": {
"text": "${mod_id} resources"
"text": "${mod_name} resources"
},
"pack_format": 15
}