feat(docs): complete PlayHours mod implementation with comprehensive documentation

- Add complete PlayHours mod source code with all features:
  * Schedule enforcement with per-day schedules and midnight-spanning support
  * Login control with configurable thresholds and exemptions
  * Warnings and auto-kick system with countdown functionality
  * Force modes (NORMAL/FORCE_OPEN/FORCE_CLOSED) for maintenance
  * Whitelist/blacklist system for player access control
  * Date exceptions for holidays and special events
  * Multi-language support (English/French) with smart time formatting
  * LuckPerms integration with vanilla ops fallback
  * Dynamic MOTD system with real-time schedule display
  * Comprehensive command system with permission integration
  * TOML configuration with hot-reload support

- Add comprehensive documentation suite:
  * Installation guide with step-by-step setup instructions
  * Complete configuration reference with all options
  * Commands reference with usage examples
  * Features overview with detailed explanations
  * MOTD system configuration and customization guide
  * Permissions system documentation with LuckPerms integration
  * Technical details covering architecture and limitations
  * Usage examples with real-world scenarios
  * Changelog with version history

- Add resource files:
  * Language files (en_us.json, fr_fr.json) with localized messages
  * Mod metadata (mods.toml) with proper Forge configuration
  * Resource pack metadata (pack.mcmeta)

- Update build configuration:
  * Gradle build system with proper dependencies
  * Project properties and version management
  * Development environment setup

- Restructure documentation:
  * Replace old README.txt with new comprehensive README.md
  * Create modular documentation structure in docs/ directory
  * Add cross-references and navigation between documents
  * Include quick start guide and common use cases

This commit represents the complete v1.0.0 release of PlayHours, a production-ready server operation hours enforcement mod for Minecraft Forge 1.20.1.
This commit is contained in:
Mr¤KayJayDee
2025-10-23 23:28:20 +02:00
parent 58919ced40
commit c0fd2a2787
63 changed files with 6974 additions and 265 deletions

View File

@@ -0,0 +1,64 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.permissions.PermissionChecker;
import com.mrkayjaydee.playhours.text.Messages;
import com.mrkayjaydee.playhours.config.ConfigEventHandler;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.event.entity.player.PlayerEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.util.Optional;
/**
* Handles login enforcement based on server operating hours and thresholds.
* Disconnects players immediately after login if they are not permitted during current schedule.
*/
@Mod.EventBusSubscriber
public class LoginGuard {
// Note: Using PlayerLoggedInEvent as per PLAN.md fallback strategy.
// Early denial could be implemented via ServerLoginNetworkEvent.CheckLogin if needed.
/**
* Checks if player login is permitted based on current schedule, threshold, and exemptions.
* Disconnects the player immediately if access is denied.
*/
@SubscribeEvent
public static void onLoggedIn(PlayerEvent.PlayerLoggedInEvent event) {
if (!(event.getEntity() instanceof ServerPlayer player)) return;
if (!ConfigEventHandler.isReady()) {
// Defer checks until config ready to avoid early access
com.mrkayjaydee.playhours.PlayHoursMod.LOGGER.debug("LoginGuard deferred: config not ready for {}", player.getGameProfile().getName());
return;
}
ScheduleService sched = ScheduleService.get();
boolean exempt = PermissionChecker.isExempt(player);
ZonedDateTime now = ZonedDateTime.now(sched.getZoneId());
// First check: is the player allowed by schedule/lists/force mode?
boolean bypassSchedule = exempt && sched.isExemptBypassSchedule();
if (!sched.isOpenForName(player.getGameProfile().getName(), bypassSchedule)) {
ScheduleFormatter.FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(sched.nextOpen(now));
player.connection.disconnect(Messages.accessDenied(nextOpen.day, nextOpen.time));
return;
}
// Second check: threshold - deny login if within closing threshold
boolean bypassThreshold = exempt && sched.isExemptBypassThreshold();
if (!bypassThreshold && sched.isOpen(now) && sched.isDenyLoginDuringThreshold()) {
Optional<ZonedDateTime> nextClose = sched.nextClose(now);
if (nextClose.isPresent()) {
long minutesUntilClose = Duration.between(now, nextClose.get()).toMinutes();
if (minutesUntilClose >= 0 && minutesUntilClose <= sched.getClosingThresholdMinutes()) {
ScheduleFormatter.FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(sched.nextOpen(nextClose.get().plusMinutes(1)));
player.connection.disconnect(Messages.thresholdDenied(nextOpen.day, nextOpen.time));
}
}
}
}
}

View File

@@ -0,0 +1,218 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.config.MOTDConfig;
import com.mrkayjaydee.playhours.core.ForceMode;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.events.ScheduleFormatter.FormattedSchedule;
import com.mrkayjaydee.playhours.text.Messages;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import net.minecraft.network.chat.MutableComponent;
import java.time.ZonedDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* Builds MOTD components based on configuration.
* Supports multiple MOTD strategies: rotating, custom lines, custom format, and automatic.
*/
public final class MOTDBuilder {
private MOTDBuilder() {}
private static int currentRotationIndex = 0;
private static long lastRotationTime = 0;
private static List<? extends String> lastRotationTemplates = null;
/**
* Builds the MOTD component based on configuration and current schedule state.
* Priority: rotation → custom lines → custom format → automatic
*
* @param scheduleService the schedule service
* @param now the current time
* @return the MOTD component
*/
public static Component build(ScheduleService scheduleService, ZonedDateTime now) {
// Check for rotation first
if (MOTDConfig.ROTATION_ENABLED.get()) {
List<? extends String> templates = MOTDConfig.ROTATION_TEMPLATES.get();
if (templates != null && !templates.isEmpty()) {
return buildRotating(scheduleService, now, templates);
}
}
// Check for custom lines
List<? extends String> customLines = MOTDConfig.CUSTOM_LINES.get();
if (customLines != null && !customLines.isEmpty()) {
return buildCustomLines(scheduleService, now, customLines);
}
// Check for custom format
String customFormat = MOTDConfig.CUSTOM_FORMAT.get();
if (customFormat != null && !customFormat.trim().isEmpty()) {
return buildCustomFormat(scheduleService, now, customFormat);
}
// Build automatic MOTD
return buildAutomatic(scheduleService, now);
}
/**
* Builds a rotating MOTD that cycles through configured templates.
*/
private static Component buildRotating(ScheduleService scheduleService, ZonedDateTime now, List<? extends String> templates) {
if (templates.isEmpty()) {
return buildCustomLines(scheduleService, now, MOTDConfig.CUSTOM_LINES.get());
}
// Check if template list changed
if (lastRotationTemplates != templates) {
lastRotationTemplates = templates;
currentRotationIndex = 0;
lastRotationTime = System.currentTimeMillis();
}
// Calculate time elapsed since last rotation
long currentTime = System.currentTimeMillis();
long rotationIntervalMillis = MOTDConfig.ROTATION_INTERVAL_SECONDS.get() * 1000L;
long timeSinceLastRotation = currentTime - lastRotationTime;
// Check if we should advance to next template
if (timeSinceLastRotation >= rotationIntervalMillis) {
// Move to next template
currentRotationIndex = (currentRotationIndex + 1) % templates.size();
lastRotationTime = currentTime;
}
String template = templates.get(currentRotationIndex);
return MOTDFormatter.formatLine(scheduleService, now, template);
}
/**
* Builds MOTD from custom line configuration.
*/
private static Component buildCustomLines(ScheduleService scheduleService, ZonedDateTime now, List<? extends String> customLines) {
MutableComponent result = Component.empty();
for (int i = 0; i < customLines.size(); i++) {
String line = customLines.get(i);
Component formatted = MOTDFormatter.formatLine(scheduleService, now, line);
result.append(formatted);
if (i < customLines.size() - 1) {
result.append(Component.literal("\n"));
}
}
return result;
}
/**
* Builds MOTD from custom format string.
*/
private static Component buildCustomFormat(ScheduleService scheduleService, ZonedDateTime now, String format) {
return MOTDFormatter.formatLine(scheduleService, now, format);
}
/**
* Builds automatic MOTD based on configuration flags.
*/
private static Component buildAutomatic(ScheduleService scheduleService, ZonedDateTime now) {
List<Component> parts = new ArrayList<>();
// Get schedule information
boolean isOpen = scheduleService.isOpen(now);
ForceMode forceMode = scheduleService.getForceMode();
Optional<ZonedDateTime> nextClose = scheduleService.nextClose(now);
FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(scheduleService.nextOpen(now));
// Show force mode if enabled
if (MOTDConfig.SHOW_FORCE_MODE.get() && forceMode != ForceMode.NORMAL) {
String forceModeText = forceMode == ForceMode.FORCE_OPEN
? Messages.get("msg.motd_force_open")
: Messages.get("msg.motd_force_closed");
parts.add(MOTDFormatter.colorize(forceModeText, ChatFormatting.GOLD));
}
// Show status if enabled
if (MOTDConfig.SHOW_STATUS.get()) {
String statusKey = isOpen ? "msg.motd_status_open" : "msg.motd_status_closed";
String statusText = Messages.get(statusKey);
ChatFormatting statusColor = isOpen
? MOTDColorParser.parseColor(MOTDConfig.OPEN_COLOR.get())
: MOTDColorParser.parseColor(MOTDConfig.CLOSED_COLOR.get());
parts.add(MOTDFormatter.colorize(statusText, statusColor));
}
// Show countdown if enabled and applicable
if (MOTDConfig.SHOW_COUNTDOWN.get() && isOpen && nextClose.isPresent()) {
addCountdownIfApplicable(parts, nextClose.get(), now);
}
// Show next close if enabled and open
if (MOTDConfig.SHOW_NEXT_CLOSE.get() && isOpen && nextClose.isPresent()) {
addNextClose(parts, nextClose.get());
}
// Show next open if enabled and closed
if (MOTDConfig.SHOW_NEXT_OPEN.get() && !isOpen) {
addNextOpen(parts, nextOpen);
}
// Combine parts
if (parts.isEmpty()) {
return Component.literal("");
}
return combineParts(parts);
}
private static void addCountdownIfApplicable(List<Component> parts, ZonedDateTime nextClose, ZonedDateTime now) {
long minutesUntilClose = java.time.temporal.ChronoUnit.MINUTES.between(now, nextClose);
int countdownThreshold = MOTDConfig.COUNTDOWN_THRESHOLD_MINUTES.get();
if (countdownThreshold > 0 && minutesUntilClose <= countdownThreshold && minutesUntilClose > 0) {
String countdownText = Messages.get("msg.motd_countdown")
.replace("%minutes%", String.valueOf(minutesUntilClose));
parts.add(MOTDFormatter.colorize(countdownText, ChatFormatting.YELLOW));
}
}
private static void addNextClose(List<Component> parts, ZonedDateTime nextClose) {
String closeTime = com.mrkayjaydee.playhours.core.TimeRange.formatTime(nextClose.toLocalTime(), Messages.getJavaLocale());
String closeText = Messages.get("msg.motd_next_close")
.replace("%closetime%", closeTime);
ChatFormatting infoColor = MOTDColorParser.parseColor(MOTDConfig.INFO_COLOR.get());
parts.add(MOTDFormatter.colorize(closeText, infoColor));
}
private static void addNextOpen(List<Component> parts, FormattedSchedule nextOpen) {
String openText = Messages.get("msg.motd_next_open")
.replace("%openday%", nextOpen.day)
.replace("%opentime%", nextOpen.time);
ChatFormatting infoColor = MOTDColorParser.parseColor(MOTDConfig.INFO_COLOR.get());
parts.add(MOTDFormatter.colorize(openText, infoColor));
}
private static Component combineParts(List<Component> parts) {
String separator = MOTDConfig.SEPARATOR.get();
boolean useSecondLine = MOTDConfig.SHOW_ON_SECOND_LINE.get();
MutableComponent result = Component.empty();
if (useSecondLine) {
// Put on second line
result.append(Component.literal("\n"));
}
for (int i = 0; i < parts.size(); i++) {
result.append(parts.get(i));
if (i < parts.size() - 1) {
result.append(Component.literal(separator));
}
}
return result;
}
}

View File

@@ -0,0 +1,41 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.PlayHoursMod;
import net.minecraft.ChatFormatting;
/**
* Utility for parsing color names and codes to Minecraft ChatFormatting.
* Handles both named colors (e.g., "green") and section sign codes (e.g., "§a").
*/
public final class MOTDColorParser {
private MOTDColorParser() {}
/**
* Parses a color name or code to ChatFormatting.
*
* @param colorStr color name (e.g., "green", "red") or § code (e.g., "§a")
* @return the ChatFormatting color, or GRAY as default
*/
public static ChatFormatting parseColor(String colorStr) {
if (colorStr == null || colorStr.trim().isEmpty()) {
return ChatFormatting.GRAY;
}
String normalized = colorStr.trim().toUpperCase().replace(" ", "_");
try {
return ChatFormatting.valueOf(normalized);
} catch (IllegalArgumentException e) {
// Try parsing § codes
if (colorStr.startsWith("§") && colorStr.length() == 2) {
ChatFormatting byCode = ChatFormatting.getByCode(colorStr.charAt(1));
if (byCode != null) {
return byCode;
}
}
PlayHoursMod.LOGGER.warn("Invalid color code '{}', using GRAY", colorStr);
return ChatFormatting.GRAY;
}
}
}

View File

@@ -0,0 +1,74 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.config.MOTDConfig;
import com.mrkayjaydee.playhours.core.ForceModeFormatter;
import com.mrkayjaydee.playhours.core.ForceMode;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.core.TimeRange;
import com.mrkayjaydee.playhours.events.ScheduleFormatter.FormattedSchedule;
import com.mrkayjaydee.playhours.text.Messages;
import com.mrkayjaydee.playhours.text.MessageKeys;
import net.minecraft.ChatFormatting;
import net.minecraft.network.chat.Component;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
/**
* Formats MOTD lines with placeholder replacement and color application.
* Replaces dynamic placeholders like %status%, %mode%, %isopen%, etc. with actual values.
*/
public final class MOTDFormatter {
private MOTDFormatter() {}
/**
* Formats a line with placeholder replacement and coloring.
*/
public static Component formatLine(ScheduleService scheduleService, ZonedDateTime now, String format) {
boolean isOpen = scheduleService.isOpen(now);
Optional<ZonedDateTime> nextClose = scheduleService.nextClose(now);
FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(scheduleService.nextOpen(now));
ForceMode forceMode = scheduleService.getForceMode();
long minutesUntilClose = nextClose.isPresent() ? ChronoUnit.MINUTES.between(now, nextClose.get()) : 0;
// Replace placeholders
String formatted = format
.replace("%status%", isOpen
? Messages.get("msg.motd_status_open")
: Messages.get("msg.motd_status_closed"))
.replace("%mode%", ForceModeFormatter.format(forceMode))
.replace("%isopen%", isOpen ? Messages.get(MessageKeys.YES) : Messages.get(MessageKeys.NO))
.replace("%nextopen%", nextOpen.day + Messages.get(MessageKeys.DAY_TIME_SEPARATOR) + nextOpen.time)
.replace("%openday%", nextOpen.day)
.replace("%opentime%", nextOpen.time)
.replace("%closetime%", nextClose.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-"))
.replace("%nextclose%", nextClose.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-"))
.replace("%minutes%", String.valueOf(minutesUntilClose));
// Apply coloring if enabled
if (MOTDConfig.USE_COLORS.get()) {
return parseFormattedText(formatted);
}
return Component.literal(formatted);
}
/**
* Parses text with color codes and returns a Component.
*/
private static Component parseFormattedText(String text) {
// Simple implementation - can be enhanced to support inline color codes
return Component.literal(text);
}
/**
* Colorizes a component with the specified color.
*/
public static Component colorize(String text, ChatFormatting color) {
if (MOTDConfig.USE_COLORS.get()) {
return Component.literal(text).withStyle(color);
}
return Component.literal(text);
}
}

View File

@@ -0,0 +1,70 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.PlayHoursMod;
import com.mrkayjaydee.playhours.config.GeneralConfig;
import com.mrkayjaydee.playhours.config.MOTDConfig;
import com.mrkayjaydee.playhours.core.ScheduleService;
import net.minecraft.network.chat.Component;
import net.minecraft.server.MinecraftServer;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import java.time.ZonedDateTime;
/**
* Handles server MOTD (Message of the Day) customization.
* Orchestrates periodic MOTD updates based on current schedule state.
*/
@Mod.EventBusSubscriber(modid = PlayHoursMod.MODID)
public final class MOTDHandler {
private static long lastMOTDUpdate = 0;
private MOTDHandler() {}
/**
* Handles server tick to periodically update MOTD with schedule information.
*
* @param event the server tick event
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
if (event.getServer() == null) return;
try {
// Check if MOTD feature is enabled
if (!GeneralConfig.MOTD_ENABLED.get()) {
return;
}
// Only update periodically based on configured delay
long currentTime = System.currentTimeMillis();
long updateIntervalMillis = MOTDConfig.UPDATE_DELAY_SECONDS.get() * 1000L;
if (currentTime - lastMOTDUpdate < updateIntervalMillis) {
return;
}
lastMOTDUpdate = currentTime;
MinecraftServer server = event.getServer();
// Get current schedule information
ScheduleService scheduleService = ScheduleService.get();
ZonedDateTime now = ZonedDateTime.now(scheduleService.getZoneId());
// Build MOTD component
Component motd = MOTDBuilder.build(scheduleService, now);
// Validate and truncate to Minecraft limits (2 lines, 59 chars per line)
Component validatedMotd = MOTDValidator.validateAndTruncate(motd);
// Apply MOTD to server
server.setMotd(validatedMotd.getString());
} catch (Exception e) {
PlayHoursMod.LOGGER.error("Error updating MOTD", e);
}
}
}

View File

@@ -0,0 +1,50 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.PlayHoursMod;
import net.minecraft.network.chat.Component;
/**
* Validates and truncates MOTD to comply with Minecraft protocol limits.
* Enforces: 2 lines maximum, ~59 characters per line.
*/
public final class MOTDValidator {
private MOTDValidator() {}
private static final int MINECRAFT_LINE_LIMIT = 59;
private static final int MINECRAFT_MAX_LINES = 2;
/**
* Validates and truncates MOTD to comply with Minecraft protocol limits.
* Minecraft MOTD: 2 lines max, ~59 characters per line
*
* @param motd the MOTD component to validate
* @return the validated/truncated MOTD component
*/
public static Component validateAndTruncate(Component motd) {
String text = motd.getString();
String[] lines = text.split("\n", -1);
// Limit to 2 lines maximum (Minecraft displays 2 lines in server list)
if (lines.length > MINECRAFT_MAX_LINES) {
PlayHoursMod.LOGGER.warn("MOTD has {} lines but Minecraft only displays {}. Truncating.", lines.length, MINECRAFT_MAX_LINES);
lines = new String[]{lines[0], lines[1]};
}
// Truncate each line to ~59 characters (Minecraft MOTD line width limit)
StringBuilder validatedMotd = new StringBuilder();
for (int i = 0; i < lines.length; i++) {
String line = lines[i];
if (line.length() > MINECRAFT_LINE_LIMIT) {
PlayHoursMod.LOGGER.warn("MOTD line {} is {} chars but Minecraft limit is ~{}. Truncating.", i + 1, line.length(), MINECRAFT_LINE_LIMIT);
line = line.substring(0, Math.min(line.length(), MINECRAFT_LINE_LIMIT));
}
validatedMotd.append(line);
if (i < lines.length - 1) {
validatedMotd.append("\n");
}
}
return Component.literal(validatedMotd.toString());
}
}

View File

@@ -0,0 +1,35 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.permissions.PermissionChecker;
import com.mrkayjaydee.playhours.text.Messages;
import net.minecraft.server.level.ServerPlayer;
import java.time.ZonedDateTime;
import java.util.List;
/**
* Handles player kicking logic for schedule enforcement.
* Separates kick logic from tick scheduling.
*/
public final class PlayerKickHandler {
private PlayerKickHandler() {}
/**
* Kicks players at closing time based on schedule and exemptions.
*
* @param players the list of players to check
* @param scheduleService the schedule service
* @param nextClose the next closing time
*/
public static void kickPlayersAtClose(List<ServerPlayer> players, ScheduleService scheduleService, ZonedDateTime nextClose) {
ScheduleFormatter.FormattedSchedule nextOpen = ScheduleFormatter.formatNextOpen(scheduleService.nextOpen(nextClose.plusMinutes(1)));
for (ServerPlayer player : players) {
boolean exempt = PermissionChecker.isExempt(player);
if (!exempt || scheduleService.isKickExempt()) {
player.connection.disconnect(Messages.kick(nextOpen.day, nextOpen.time));
}
}
}
}

View File

@@ -0,0 +1,78 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.core.TimeRange;
import com.mrkayjaydee.playhours.text.Messages;
import java.time.ZonedDateTime;
import java.time.format.TextStyle;
import java.util.Optional;
/**
* Handles formatting of schedule information for display.
* Separates formatting logic from event handling.
*/
public final class ScheduleFormatter {
private ScheduleFormatter() {}
/**
* Formats the next opening time for display.
*
* @param nextOpen the next opening time
* @return formatted day and time strings
*/
public static FormattedSchedule formatNextOpen(Optional<ZonedDateTime> nextOpen) {
String day = nextOpen.map(dt -> dt.getDayOfWeek().getDisplayName(TextStyle.FULL, Messages.getJavaLocale())).orElse("?");
String time = nextOpen.map(dt -> TimeRange.formatTime(dt.toLocalTime(), Messages.getJavaLocale())).orElse("?");
return new FormattedSchedule(day, time);
}
/**
* Formats the next closing time for display.
*
* @param nextClose the next closing time
* @return formatted time string
*/
public static String formatNextClose(Optional<ZonedDateTime> nextClose) {
return nextClose.map(z -> TimeRange.formatTime(z.toLocalTime(), Messages.getJavaLocale())).orElse("-");
}
/**
* Gets the current schedule status and next times.
*
* @param scheduleService the schedule service
* @param now the current time
* @return formatted schedule information
*/
public static FormattedSchedule getScheduleInfo(ScheduleService scheduleService, ZonedDateTime now) {
boolean open = scheduleService.isOpen(now);
String nextClose = formatNextClose(scheduleService.nextClose(now));
FormattedSchedule nextOpen = formatNextOpen(scheduleService.nextOpen(now));
return new FormattedSchedule(open, nextClose, nextOpen.day, nextOpen.time);
}
/**
* Container for formatted schedule information.
*/
public static final class FormattedSchedule {
public final String day;
public final String time;
public final boolean isOpen;
public final String nextClose;
public FormattedSchedule(String day, String time) {
this.day = day;
this.time = time;
this.isOpen = false;
this.nextClose = null;
}
public FormattedSchedule(boolean isOpen, String nextClose, String day, String time) {
this.isOpen = isOpen;
this.nextClose = nextClose;
this.day = day;
this.time = time;
}
}
}

View File

@@ -0,0 +1,93 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.config.*;
import com.mrkayjaydee.playhours.text.Messages;
import net.minecraft.server.level.ServerPlayer;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
import net.minecraftforge.fml.common.Mod;
import java.time.ZonedDateTime;
import java.util.*;
/**
* Handles periodic server tick processing for warnings and auto-kick at closing time.
* Runs checks every second (20 ticks) to broadcast warnings and enforce closing times.
*/
@Mod.EventBusSubscriber
public class TickScheduler {
private static final int TICKS_PER_SECOND = 20;
private static int tickCounter = 0;
private static final Set<Integer> sentCountdowns = new HashSet<>();
/**
* Server tick event handler that checks for closing warnings and kicks players at closing time.
*/
@SubscribeEvent
public static void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase != TickEvent.Phase.END) return;
tickCounter++;
if (tickCounter % TICKS_PER_SECOND != 0) return; // once per second
var server = net.minecraftforge.server.ServerLifecycleHooks.getCurrentServer();
if (server == null) return;
ScheduleService sched = ScheduleService.get();
if (!ConfigEventHandler.isReady()) {
// Config not ready yet; avoid accessing values
com.mrkayjaydee.playhours.PlayHoursMod.LOGGER.debug("Tick skipped: config not ready");
return;
}
ZonedDateTime now = ZonedDateTime.now(sched.getZoneId());
boolean open = sched.isOpen(now);
if (open) {
Optional<ZonedDateTime> nextClose = sched.nextClose(now);
if (nextClose.isPresent()) {
// Broadcast warnings
WarningBroadcaster.broadcastWarnings(server, sched, now, nextClose.get());
// Handle countdown messages
handleCountdown(server, now, nextClose.get());
// Kick at close - use reliable time comparison
if (!now.isBefore(nextClose.get())) {
List<ServerPlayer> players = new ArrayList<>(server.getPlayerList().getPlayers());
PlayerKickHandler.kickPlayersAtClose(players, sched, nextClose.get());
WarningBroadcaster.clearWarnings();
sentCountdowns.clear();
}
} else {
WarningBroadcaster.clearWarnings();
sentCountdowns.clear();
}
} else {
WarningBroadcaster.clearWarnings();
sentCountdowns.clear();
}
}
/**
* Handles countdown messages before closing.
* Sends messages every second for the configured number of seconds before closing.
*/
private static void handleCountdown(net.minecraft.server.MinecraftServer server, ZonedDateTime now, ZonedDateTime nextClose) {
int countdownSeconds = GeneralConfig.COUNTDOWN_SECONDS.get();
if (countdownSeconds <= 0) return;
long secondsUntilClose = java.time.Duration.between(now, nextClose).getSeconds();
if (secondsUntilClose <= countdownSeconds && secondsUntilClose > 0) {
int seconds = (int) secondsUntilClose;
// Only send if we haven't sent this countdown yet
if (!sentCountdowns.contains(seconds)) {
sentCountdowns.add(seconds);
server.getPlayerList().broadcastSystemMessage(Messages.countdown(seconds), false);
}
}
}
}

View File

@@ -0,0 +1,47 @@
package com.mrkayjaydee.playhours.events;
import com.mrkayjaydee.playhours.core.ScheduleService;
import com.mrkayjaydee.playhours.text.Messages;
import net.minecraft.server.MinecraftServer;
import java.time.ZonedDateTime;
import java.util.HashSet;
import java.util.Set;
/**
* Handles warning broadcasts for approaching closing times.
* Separates warning logic from tick scheduling.
*/
public final class WarningBroadcaster {
private WarningBroadcaster() {}
private static final Set<Integer> sentForMinute = new HashSet<>();
/**
* Broadcasts warnings for approaching closing time.
*
* @param server the Minecraft server
* @param scheduleService the schedule service
* @param now the current time
* @param nextClose the next closing time
*/
public static void broadcastWarnings(MinecraftServer server, ScheduleService scheduleService,
ZonedDateTime now, ZonedDateTime nextClose) {
long minutes = Math.max(0, java.time.Duration.between(now, nextClose).toMinutes());
int minInt = (int) minutes;
for (int mark : scheduleService.getWarningMinutes()) {
if (minInt == mark && sentForMinute.add(mark)) {
String closeTime = ScheduleFormatter.formatNextClose(java.util.Optional.of(nextClose));
server.getPlayerList().broadcastSystemMessage(Messages.warn(mark, closeTime), false);
}
}
}
/**
* Clears the warning tracking set.
*/
public static void clearWarnings() {
sentForMinute.clear();
}
}