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