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:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user