From 4285296731f12b9b411e4f2cd780fa99d0c40aae Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 17:04:08 -0600 Subject: [PATCH 1/4] Centralize time and week calculations --- .../api/time/TimeCalculation.java | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeCalculation.java diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeCalculation.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeCalculation.java new file mode 100644 index 0000000000..11b41702f7 --- /dev/null +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeCalculation.java @@ -0,0 +1,31 @@ +package com.bencodez.advancedcore.api.time; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.temporal.WeekFields; +import java.util.Locale; + +/** + * Shared deterministic time calculations used by Bukkit and proxy time checkers. + */ +public final class TimeCalculation { + + private TimeCalculation() { + } + + public static LocalDateTime currentTime(Clock clock, String timeZone, int hourOffset) { + Clock effectiveClock = clock == null ? Clock.systemDefaultZone() : clock; + ZonedDateTime now = ZonedDateTime.now(effectiveClock); + if (timeZone != null && !timeZone.isEmpty()) { + now = now.withZoneSameInstant(ZoneId.of(timeZone)); + } + return now.toLocalDateTime().plusHours(hourOffset); + } + + public static int weekNumber(LocalDateTime time, int weekOffset, Locale locale) { + Locale effectiveLocale = locale == null ? Locale.getDefault() : locale; + return time.plusDays(weekOffset).get(WeekFields.of(effectiveLocale).weekOfWeekBasedYear()); + } +} From 052fadad9a0ee39876321d1d551c66bd9ac022b7 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 17:04:35 -0600 Subject: [PATCH 2/4] Use deterministic shared time calculations --- .../advancedcore/api/time/TimeChecker.java | 510 ++++++++---------- 1 file changed, 236 insertions(+), 274 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java index bf41bbd44b..e86578862f 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/api/time/TimeChecker.java @@ -1,274 +1,236 @@ -package com.bencodez.advancedcore.api.time; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.TemporalField; -import java.time.temporal.WeekFields; -import java.util.Locale; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import com.bencodez.advancedcore.AdvancedCorePlugin; -import com.bencodez.advancedcore.api.time.events.DateChangedEvent; -import com.bencodez.advancedcore.api.time.events.DayChangeEvent; -import com.bencodez.advancedcore.api.time.events.MonthChangeEvent; -import com.bencodez.advancedcore.api.time.events.PreDateChangedEvent; -import com.bencodez.advancedcore.api.time.events.WeekChangeEvent; - -import lombok.Getter; -import lombok.Setter; - -/** - * The Class TimeChecker. - */ -public class TimeChecker { - - private AdvancedCorePlugin plugin; - - @Getter - private boolean activeProcessing = false; - - @Getter - @Setter - private ScheduledExecutorService timer; - - private boolean timerLoaded = false; - - @Getter - private boolean processingEnabled = true; - - public TimeChecker(AdvancedCorePlugin plugin) { - this.plugin = plugin; - } - - public void forceChanged(TimeType time) { - timer.execute(new Runnable() { - - @Override - public void run() { - forceChanged(time, true, true, true); - } - }); - } - - public synchronized void forceChanged(TimeType time, boolean fake, boolean preDate, boolean postDate) { - activeProcessing = true; - try { - plugin.debug("Executing time change events: " + time.toString()); - plugin.getLogger().info("Time change event: " + time.toString() + ", Fake: " + fake); - if (preDate) { - PreDateChangedEvent preDateChanged = new PreDateChangedEvent(time); - preDateChanged.setFake(fake); - plugin.getServer().getPluginManager().callEvent(preDateChanged); - } - if (time.equals(TimeType.DAY)) { - DayChangeEvent dayChange = new DayChangeEvent(); - dayChange.setFake(fake); - plugin.getServer().getPluginManager().callEvent(dayChange); - } else if (time.equals(TimeType.WEEK)) { - WeekChangeEvent weekChange = new WeekChangeEvent(); - weekChange.setFake(fake); - plugin.getServer().getPluginManager().callEvent(weekChange); - } else if (time.equals(TimeType.MONTH)) { - MonthChangeEvent monthChange = new MonthChangeEvent(); - monthChange.setFake(fake); - plugin.getServer().getPluginManager().callEvent(monthChange); - } - - if (postDate) { - DateChangedEvent dateChanged = new DateChangedEvent(time); - dateChanged.setFake(fake); - plugin.getServer().getPluginManager().callEvent(dateChanged); - } - activeProcessing = false; - - plugin.debug("Finished executing time change events: " + time.toString()); - } catch (Exception e) { - e.printStackTrace(); - } - activeProcessing = false; - - } - - public LocalDateTime getTime() { - LocalDateTime localNow = LocalDateTime.now(); - if (!plugin.getOptions().getTimeZone().isEmpty()) { - try { - ZonedDateTime zonedTime = localNow.atZone(ZoneId.systemDefault()) - .withZoneSameInstant(ZoneId.of(plugin.getOptions().getTimeZone())); - localNow = zonedTime.toLocalDateTime(); - } catch (Exception e) { - e.printStackTrace(); - } - } - return localNow.plusHours(AdvancedCorePlugin.getInstance().getOptions().getTimeHourOffSet()); - } - - public boolean hasDayChanged(boolean set) { - int prevDay = plugin.getServerDataFile().getPrevDay(); - int day = getTime().getDayOfMonth(); - - if (prevDay == day) { - return false; - } - if (set) { - plugin.getServerDataFile().setPrevDay(day); - } - return true; - } - - public boolean hasMonthChanged(boolean set) { - String prevMonth = plugin.getServerDataFile().getPrevMonth(); - String month = getTime().getMonth().toString(); - - if (prevMonth.equals(month)) { - return false; - } - if (set) { - plugin.getServerDataFile().setPrevMonth(month); - } - - if (!plugin.getOptions().isTimeChangeFailSafeBypass()) { - if (getTime().getDayOfMonth() > 3) { - plugin.getLogger().warning( - "Detected a month change, but current day is not near end of a month, ignoring month change, " - + getTime().getDayOfMonth()); - plugin.getServerDataFile().setPrevMonth(month); - return false; - } - } - return true; - - } - - public boolean hasTimeOffSet() { - return AdvancedCorePlugin.getInstance().getOptions().getTimeHourOffSet() != 0; - } - - public boolean hasWeekChanged(boolean set) { - int prevDate = plugin.getServerDataFile().getPrevWeekDay(); - LocalDateTime date = getTime().plusDays(plugin.getOptions().getTimeWeekOffSet()); - TemporalField woy = WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear(); - int weekNumber = date.get(woy); - if (weekNumber == prevDate) { - return false; - } - if (set) { - plugin.getServerDataFile().setPrevWeekDay(weekNumber); - } - return true; - } - - public void loadTimer() { - if (!timerLoaded) { - timerLoaded = true; - timer = Executors.newScheduledThreadPool(1); - if (plugin.getServerDataFile().getLastUpdated() > 0) { - // serverdata.yml hasn't updated for 4 days, don't do time changes - if (System.currentTimeMillis() - plugin.getServerDataFile().getLastUpdated() > 1000 * 60 * 60 * 24 - * 4) { - plugin.getServerDataFile().setIgnoreTime(true); - plugin.getLogger().warning( - "Skipping time change events, since server has been offline for awhile, use /av forcetimechanged to force them if needed"); - } - } - plugin.getServerDataFile().setLastUpdated(); - timer.scheduleWithFixedDelay(new Runnable() { - - @Override - public void run() { - if (plugin != null && plugin.isEnabled()) { - if (!isActiveProcessing() && isProcessingEnabled()) { - update(); - } - } else { - timer.shutdown(); - timerLoaded = false; - } - } - }, 60, 5, TimeUnit.SECONDS); - timer.scheduleAtFixedRate(new Runnable() { - - @Override - public void run() { - plugin.getServerDataFile().setLastUpdated(); - - if (!isProcessingEnabled()) { - plugin.debug("Processing time changes locally disabled"); - if (hasDayChanged(false)) { - hasDayChanged(true); - } - if (hasWeekChanged(false)) { - hasWeekChanged(true); - } - if (hasMonthChanged(false)) { - hasMonthChanged(true); - } - } - } - }, 60, 60, TimeUnit.MINUTES); - } else { - AdvancedCorePlugin.getInstance().debug("Timer is already loaded"); - } - } - - public void setProcessingEnabled(boolean value) { - processingEnabled = value; - plugin.debug("Local time change processing disabled"); - } - - /** - * Update. - */ - public void update() { - if (plugin == null) { - return; - } - - if (hasTimeOffSet()) { - plugin.extraDebug("TimeHourOffSet: " + getTime().getHour() + ":" + getTime().getMinute()); - } - - if (plugin.getServerDataFile().isIgnoreTime()) { - hasDayChanged(true); - hasMonthChanged(true); - hasWeekChanged(true); - plugin.getServerDataFile().setIgnoreTime(false); - plugin.getLogger().info("Ignoring time change events for one time only"); - } - - if (!isActiveProcessing()) { - // stagger process time change events to prevent overloading mysql table - if (hasMonthChanged(false)) { - plugin.getLogger().info("Detected month changed, processing..."); - if (isProcessingEnabled()) { - forceChanged(TimeType.MONTH, false, true, true); - } else { - plugin.debug("Processing time changes locally disabled"); - } - hasMonthChanged(true); - plugin.getLogger().info("Finished processing month changes"); - } else if (hasWeekChanged(false)) { - plugin.getLogger().info("Detected week changed, processing..."); - if (isProcessingEnabled()) { - forceChanged(TimeType.WEEK, false, true, true); - } else { - plugin.debug("Processing time changes locally disabled"); - } - hasWeekChanged(true); - plugin.getLogger().info("Finished processing week changes"); - } else if (hasDayChanged(false)) { - plugin.getLogger().info("Detected day changed, processing..."); - if (isProcessingEnabled()) { - forceChanged(TimeType.DAY, false, true, true); - } else { - plugin.debug("Processing time changes locally disabled"); - } - hasDayChanged(true); - plugin.getLogger().info("Finished processing day changes"); - } - } - } -} \ No newline at end of file +package com.bencodez.advancedcore.api.time; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import com.bencodez.advancedcore.AdvancedCorePlugin; +import com.bencodez.advancedcore.api.time.events.DateChangedEvent; +import com.bencodez.advancedcore.api.time.events.DayChangeEvent; +import com.bencodez.advancedcore.api.time.events.MonthChangeEvent; +import com.bencodez.advancedcore.api.time.events.PreDateChangedEvent; +import com.bencodez.advancedcore.api.time.events.WeekChangeEvent; + +import lombok.Getter; +import lombok.Setter; + +public class TimeChecker { + private final AdvancedCorePlugin plugin; + private final Clock clock; + + @Getter + private boolean activeProcessing = false; + + @Getter + @Setter + private ScheduledExecutorService timer; + + private boolean timerLoaded = false; + + @Getter + private boolean processingEnabled = true; + + public TimeChecker(AdvancedCorePlugin plugin) { + this(plugin, Clock.systemDefaultZone()); + } + + public TimeChecker(AdvancedCorePlugin plugin, Clock clock) { + this.plugin = plugin; + this.clock = clock == null ? Clock.systemDefaultZone() : clock; + } + + public void forceChanged(TimeType time) { + timer.execute(() -> forceChanged(time, true, true, true)); + } + + public synchronized void forceChanged(TimeType time, boolean fake, boolean preDate, boolean postDate) { + activeProcessing = true; + try { + plugin.debug("Executing time change events: " + time); + plugin.getLogger().info("Time change event: " + time + ", Fake: " + fake); + if (preDate) { + PreDateChangedEvent preDateChanged = new PreDateChangedEvent(time); + preDateChanged.setFake(fake); + plugin.getServer().getPluginManager().callEvent(preDateChanged); + } + if (TimeType.DAY.equals(time)) { + DayChangeEvent event = new DayChangeEvent(); + event.setFake(fake); + plugin.getServer().getPluginManager().callEvent(event); + } else if (TimeType.WEEK.equals(time)) { + WeekChangeEvent event = new WeekChangeEvent(); + event.setFake(fake); + plugin.getServer().getPluginManager().callEvent(event); + } else if (TimeType.MONTH.equals(time)) { + MonthChangeEvent event = new MonthChangeEvent(); + event.setFake(fake); + plugin.getServer().getPluginManager().callEvent(event); + } + if (postDate) { + DateChangedEvent event = new DateChangedEvent(time); + event.setFake(fake); + plugin.getServer().getPluginManager().callEvent(event); + } + plugin.debug("Finished executing time change events: " + time); + } catch (Exception e) { + plugin.getLogger().warning("Failed to process time change " + time + ": " + e.getMessage()); + plugin.debug(e); + } finally { + activeProcessing = false; + } + } + + public LocalDateTime getTime() { + try { + return TimeCalculation.currentTime(clock, plugin.getOptions().getTimeZone(), + plugin.getOptions().getTimeHourOffSet()); + } catch (Exception e) { + plugin.getLogger().warning("Invalid time zone '" + plugin.getOptions().getTimeZone() + + "', using the server clock zone instead"); + plugin.debug(e); + return TimeCalculation.currentTime(clock, "", plugin.getOptions().getTimeHourOffSet()); + } + } + + public boolean hasDayChanged(boolean set) { + int prevDay = plugin.getServerDataFile().getPrevDay(); + int day = getTime().getDayOfMonth(); + if (prevDay == day) { + return false; + } + if (set) { + plugin.getServerDataFile().setPrevDay(day); + } + return true; + } + + public boolean hasMonthChanged(boolean set) { + String prevMonth = plugin.getServerDataFile().getPrevMonth(); + String month = getTime().getMonth().toString(); + if (prevMonth.equals(month)) { + return false; + } + if (set) { + plugin.getServerDataFile().setPrevMonth(month); + } + if (!plugin.getOptions().isTimeChangeFailSafeBypass() && getTime().getDayOfMonth() > 3) { + plugin.getLogger().warning( + "Detected a month change, but current day is not near end of a month, ignoring month change, " + + getTime().getDayOfMonth()); + plugin.getServerDataFile().setPrevMonth(month); + return false; + } + return true; + } + + public boolean hasTimeOffSet() { + return plugin.getOptions().getTimeHourOffSet() != 0; + } + + public boolean hasWeekChanged(boolean set) { + int prevDate = plugin.getServerDataFile().getPrevWeekDay(); + int weekNumber = TimeCalculation.weekNumber(getTime(), plugin.getOptions().getTimeWeekOffSet(), Locale.getDefault()); + if (weekNumber == prevDate) { + return false; + } + if (set) { + plugin.getServerDataFile().setPrevWeekDay(weekNumber); + } + return true; + } + + public synchronized void loadTimer() { + if (timerLoaded) { + plugin.debug("Timer is already loaded"); + return; + } + timerLoaded = true; + timer = Executors.newSingleThreadScheduledExecutor(); + if (plugin.getServerDataFile().getLastUpdated() > 0 + && System.currentTimeMillis() - plugin.getServerDataFile().getLastUpdated() > TimeUnit.DAYS.toMillis(4)) { + plugin.getServerDataFile().setIgnoreTime(true); + plugin.getLogger().warning( + "Skipping time change events, since server has been offline for awhile, use /av forcetimechanged to force them if needed"); + } + plugin.getServerDataFile().setLastUpdated(); + timer.scheduleWithFixedDelay(() -> { + if (plugin != null && plugin.isEnabled()) { + if (!isActiveProcessing() && isProcessingEnabled()) { + update(); + } + } else { + timer.shutdown(); + timerLoaded = false; + } + }, 60, 5, TimeUnit.SECONDS); + timer.scheduleAtFixedRate(() -> { + plugin.getServerDataFile().setLastUpdated(); + if (!isProcessingEnabled()) { + plugin.debug("Processing time changes locally disabled"); + if (hasDayChanged(false)) { + hasDayChanged(true); + } + if (hasWeekChanged(false)) { + hasWeekChanged(true); + } + if (hasMonthChanged(false)) { + hasMonthChanged(true); + } + } + }, 60, 60, TimeUnit.MINUTES); + } + + public void setProcessingEnabled(boolean value) { + processingEnabled = value; + plugin.debug("Local time change processing " + (value ? "enabled" : "disabled")); + } + + public void update() { + if (plugin == null) { + return; + } + if (hasTimeOffSet()) { + plugin.extraDebug("TimeHourOffSet: " + getTime().getHour() + ":" + getTime().getMinute()); + } + if (plugin.getServerDataFile().isIgnoreTime()) { + hasDayChanged(true); + hasMonthChanged(true); + hasWeekChanged(true); + plugin.getServerDataFile().setIgnoreTime(false); + plugin.getLogger().info("Ignoring time change events for one time only"); + } + if (!isActiveProcessing()) { + if (hasMonthChanged(false)) { + plugin.getLogger().info("Detected month changed, processing..."); + if (isProcessingEnabled()) { + forceChanged(TimeType.MONTH, false, true, true); + } else { + plugin.debug("Processing time changes locally disabled"); + } + hasMonthChanged(true); + plugin.getLogger().info("Finished processing month changes"); + } else if (hasWeekChanged(false)) { + plugin.getLogger().info("Detected week changed, processing..."); + if (isProcessingEnabled()) { + forceChanged(TimeType.WEEK, false, true, true); + } else { + plugin.debug("Processing time changes locally disabled"); + } + hasWeekChanged(true); + plugin.getLogger().info("Finished processing week changes"); + } else if (hasDayChanged(false)) { + plugin.getLogger().info("Detected day changed, processing..."); + if (isProcessingEnabled()) { + forceChanged(TimeType.DAY, false, true, true); + } else { + plugin.debug("Processing time changes locally disabled"); + } + hasDayChanged(true); + plugin.getLogger().info("Finished processing day changes"); + } + } + } +} From 7ff6ee38c86b5e749f792368fdc6cce14a6abce1 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 17:04:58 -0600 Subject: [PATCH 3/4] Share deterministic time logic with proxy checker --- .../bungeeapi/time/BungeeTimeChecker.java | 645 ++++++------------ 1 file changed, 222 insertions(+), 423 deletions(-) diff --git a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/time/BungeeTimeChecker.java b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/time/BungeeTimeChecker.java index 9ba264e672..892c1d5166 100644 --- a/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/time/BungeeTimeChecker.java +++ b/AdvancedCore/src/main/java/com/bencodez/advancedcore/bungeeapi/time/BungeeTimeChecker.java @@ -1,423 +1,222 @@ -package com.bencodez.advancedcore.bungeeapi.time; - -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZonedDateTime; -import java.time.temporal.TemporalField; -import java.time.temporal.WeekFields; -import java.util.Locale; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import com.bencodez.advancedcore.AdvancedCorePlugin; -import com.bencodez.advancedcore.api.time.TimeType; - -import lombok.Getter; -import lombok.Setter; - -/** - * The Class TimeChecker. - */ -public abstract class BungeeTimeChecker { - - private boolean processing = false; - - /** - * Gets the timer. - * - * @return the scheduled executor service - */ - @Getter - private ScheduledExecutorService timer; - - private boolean timerLoaded = false; - - /** - * Gets the time change fail safe bypass. - * - * @return true if bypass is enabled - * @param timeChangeFailSafeBypass the bypass flag to set - */ - @Getter - @Setter - private boolean timeChangeFailSafeBypass = false; - - /** - * Gets the time offset. - * - * @return the time offset in hours - */ - @Getter - private int timeOffSet; - - /** - * Gets the time week offset. - * - * @return the time week offset - */ - @Getter - private int timeWeekOffSet = 0; - - /** - * Gets the time zone. - * - * @return the time zone - */ - @Getter - private String timeZone; - - /** - * Instantiates a new bungeecord time checker. - * - * @param timeZone the time zone - * @param timeOffSet the time offset in hours - * @param weekOffSet the week offset - */ - public BungeeTimeChecker(String timeZone, int timeOffSet, int weekOffSet) { - this.timeOffSet = timeOffSet; - this.timeZone = timeZone; - this.timeWeekOffSet = weekOffSet; - } - - /** - * Debug message. - * - * @param text the debug text - */ - public abstract void debug(String text); - - /** - * Force time change. - * - * @param time the time type - */ - public void forceChanged(TimeType time) { - timer.execute(new Runnable() { - - @Override - public void run() { - forceChanged(time, true, true, true); - } - }); - } - - /** - * Force time change. - * - * @param time the time type - * @param fake whether this is a fake change - * @param preDate whether to run pre-date actions - * @param postDate whether to run post-date actions - */ - public void forceChanged(TimeType time, boolean fake, boolean preDate, boolean postDate) { - processing = true; - try { - debug("Executing time change events: " + time.toString()); - info("Time change event: " + time.toString() + ", Fake: " + fake); - if (preDate) { - // timeChanged(time, fake, true, false); - } - - if (time.equals(TimeType.DAY)) { - timeChanged(time, fake, false, false); - } else if (time.equals(TimeType.WEEK)) { - timeChanged(time, fake, false, false); - } else if (time.equals(TimeType.MONTH)) { - timeChanged(time, fake, false, false); - } - - if (postDate) { - // timeChanged(time, fake, false, true); - } - - debug("Finished executing time change events: " + time.toString()); - processing = false; - } catch (Exception e) { - e.printStackTrace(); - } - - } - - /** - * Gets the last updated time. - * - * @return the last updated timestamp - */ - public abstract long getLastUpdated(); - - /** - * Gets the previous day. - * - * @return the previous day - */ - public abstract int getPrevDay(); - - /** - * Gets the previous month. - * - * @return the previous month - */ - public abstract String getPrevMonth(); - - /** - * Gets the previous week. - * - * @return the previous week - */ - public abstract int getPrevWeek(); - - /** - * Gets the current time with timezone and offset applied. - * - * @return the current local date time - */ - public LocalDateTime getTime() { - LocalDateTime localNow = LocalDateTime.now(); - if (!timeZone.isEmpty()) { - try { - ZonedDateTime zonedTime = localNow.atZone(ZoneId.systemDefault()) - .withZoneSameInstant(ZoneId.of(getTimeZone())); - localNow = zonedTime.toLocalDateTime(); - } catch (Exception e) { - e.printStackTrace(); - } - } - return localNow.plusHours(timeOffSet); - } - - /** - * Checks if day has changed. - * - * @param set whether to set the new day - * @return true if day has changed - */ - public boolean hasDayChanged(boolean set) { - int prevDay = getPrevDay(); - int day = getTime().getDayOfMonth(); - - if (prevDay == day) { - return false; - } - if (set) { - setPrevDay(day); - } - return true; - } - - /** - * Checks if month has changed. - * - * @param set whether to set the new month - * @return true if month has changed - */ - public boolean hasMonthChanged(boolean set) { - String prevMonth = getPrevMonth(); - String month = getTime().getMonth().toString(); - - if (prevMonth.equals(month)) { - return false; - } - if (set) { - setPrevMonth(month); - } - if (!timeChangeFailSafeBypass) { - if (getTime().getDayOfMonth() > 3) { - warning("Detected a month change, but current day is not near end of a month, ignoring month change, " - + getTime().getDayOfMonth()); - setPrevMonth(month); - return false; - } - } - return true; - - } - - /** - * Checks if time has offset. - * - * @return true if time offset is set - */ - public boolean hasTimeOffSet() { - return getTimeOffSet() != 0; - } - - /** - * Checks if week has changed. - * - * @param set whether to set the new week - * @return true if week has changed - */ - public boolean hasWeekChanged(boolean set) { - int prevDate = getPrevWeek(); - LocalDateTime date = getTime().plusDays(timeWeekOffSet); - TemporalField woy = WeekFields.of(Locale.getDefault()).weekOfWeekBasedYear(); - int weekNumber = date.get(woy); - if (weekNumber == prevDate) { - return false; - } - if (set) { - setPrevWeek(weekNumber); - } - return true; - } - - /** - * Info message. - * - * @param text the info text - */ - public abstract void info(String text); - - /** - * Checks if time checker is enabled. - * - * @return true if enabled - */ - public abstract boolean isEnabled(); - - /** - * Checks if time changes should be ignored. - * - * @return true if ignoring time - */ - public abstract boolean isIgnoreTime(); - - /** - * Load the time checker timer. - */ - public void loadTimer() { - if (!timerLoaded) { - timerLoaded = true; - timer = Executors.newScheduledThreadPool(1); - if (getLastUpdated() > 0) { - // serverdata.yml hasn't updated for 4 days, don't do time changes - if (System.currentTimeMillis() - getLastUpdated() > 1000 * 60 * 60 * 24 * 4) { - setIgnoreTime(true); - warning("Skipping time change events, since server has been offline for awhile, use /votingpluginbungee forcetimechanged to force them if needed"); - } - } - setLastUpdated(); - timer.scheduleWithFixedDelay(new Runnable() { - - @Override - public void run() { - if (isEnabled()) { - if (!processing) { - update(); - } - } else { - timer.shutdown(); - timerLoaded = false; - } - } - }, 60, 5, TimeUnit.SECONDS); - timer.scheduleAtFixedRate(new Runnable() { - - @Override - public void run() { - setLastUpdated(); - } - }, 60, 60, TimeUnit.MINUTES); - } else { - AdvancedCorePlugin.getInstance().debug("Timer is already loaded"); - } - } - - /** - * Sets ignore time flag. - * - * @param ignore whether to ignore time changes - */ - public abstract void setIgnoreTime(boolean ignore); - - /** - * Sets the last updated time to now. - */ - public abstract void setLastUpdated(); - - /** - * Sets the previous day. - * - * @param day the day to set - */ - public abstract void setPrevDay(int day); - - /** - * Sets the previous month. - * - * @param text the month to set - */ - public abstract void setPrevMonth(String text); - - /** - * Sets the previous week. - * - * @param week the week to set - */ - public abstract void setPrevWeek(int week); - - /** - * Called when time has changed. - * - * @param type the time type - * @param fake whether this is a fake change - * @param pre whether this is pre-change - * @param post whether this is post-change - */ - public abstract void timeChanged(TimeType type, boolean fake, boolean pre, boolean post); - - /** - * Shutdown the time checker. - */ - public void shutdown() { - timer.shutdownNow(); - } - - /** - * Update. - */ - public void update() { - if (!isEnabled()) { - return; - } - if (hasTimeOffSet()) { - debug("TimeHourOffSet: " + getTime().getHour() + ":" + getTime().getMinute()); - } - - if (isIgnoreTime()) { - hasDayChanged(true); - hasMonthChanged(true); - hasWeekChanged(true); - setIgnoreTime(false); - info("Ignoring time change events for one time only"); - } - - if (!processing) { - // stagger process time change events to prevent overloading mysql table - if (hasMonthChanged(false)) { - info("Detected month changed, processing..."); - forceChanged(TimeType.MONTH, false, true, true); - hasMonthChanged(true); - info("Finished processing month changes"); - } else if (hasWeekChanged(false)) { - info("Detected week changed, processing..."); - forceChanged(TimeType.WEEK, false, true, true); - hasWeekChanged(true); - info("Finished processing week changes"); - } else if (hasDayChanged(false)) { - info("Detected day changed, processing..."); - forceChanged(TimeType.DAY, false, true, true); - hasDayChanged(true); - info("Finished processing day changes"); - } - } - } - - /** - * Warning message. - * - * @param text the warning text - */ - public abstract void warning(String text); -} \ No newline at end of file +package com.bencodez.advancedcore.bungeeapi.time; + +import java.time.Clock; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +import com.bencodez.advancedcore.api.time.TimeCalculation; +import com.bencodez.advancedcore.api.time.TimeType; + +import lombok.Getter; +import lombok.Setter; + +/** + * Proxy-side time change checker. + */ +public abstract class BungeeTimeChecker { + private boolean processing = false; + private final Clock clock; + + @Getter + private ScheduledExecutorService timer; + + private boolean timerLoaded = false; + + @Getter + @Setter + private boolean timeChangeFailSafeBypass = false; + + @Getter + private int timeOffSet; + + @Getter + private int timeWeekOffSet = 0; + + @Getter + private String timeZone; + + public BungeeTimeChecker(String timeZone, int timeOffSet, int weekOffSet) { + this(timeZone, timeOffSet, weekOffSet, Clock.systemDefaultZone()); + } + + public BungeeTimeChecker(String timeZone, int timeOffSet, int weekOffSet, Clock clock) { + this.timeOffSet = timeOffSet; + this.timeZone = timeZone; + this.timeWeekOffSet = weekOffSet; + this.clock = clock == null ? Clock.systemDefaultZone() : clock; + } + + public abstract void debug(String text); + + public void forceChanged(TimeType time) { + timer.execute(() -> forceChanged(time, true, true, true)); + } + + public void forceChanged(TimeType time, boolean fake, boolean preDate, boolean postDate) { + processing = true; + try { + debug("Executing time change events: " + time); + info("Time change event: " + time + ", Fake: " + fake); + if (TimeType.DAY.equals(time) || TimeType.WEEK.equals(time) || TimeType.MONTH.equals(time)) { + timeChanged(time, fake, false, false); + } + debug("Finished executing time change events: " + time); + } catch (Exception e) { + warning("Failed to process time change " + time + ": " + e.getMessage()); + } finally { + processing = false; + } + } + + public abstract long getLastUpdated(); + + public abstract int getPrevDay(); + + public abstract String getPrevMonth(); + + public abstract int getPrevWeek(); + + public LocalDateTime getTime() { + try { + return TimeCalculation.currentTime(clock, getTimeZone(), getTimeOffSet()); + } catch (Exception e) { + warning("Invalid time zone '" + getTimeZone() + "', using the local clock zone instead"); + return TimeCalculation.currentTime(clock, "", getTimeOffSet()); + } + } + + public boolean hasDayChanged(boolean set) { + int prevDay = getPrevDay(); + int day = getTime().getDayOfMonth(); + if (prevDay == day) { + return false; + } + if (set) { + setPrevDay(day); + } + return true; + } + + public boolean hasMonthChanged(boolean set) { + String prevMonth = getPrevMonth(); + String month = getTime().getMonth().toString(); + if (prevMonth.equals(month)) { + return false; + } + if (set) { + setPrevMonth(month); + } + if (!timeChangeFailSafeBypass && getTime().getDayOfMonth() > 3) { + warning("Detected a month change, but current day is not near end of a month, ignoring month change, " + + getTime().getDayOfMonth()); + setPrevMonth(month); + return false; + } + return true; + } + + public boolean hasTimeOffSet() { + return getTimeOffSet() != 0; + } + + public boolean hasWeekChanged(boolean set) { + int prevDate = getPrevWeek(); + int weekNumber = TimeCalculation.weekNumber(getTime(), timeWeekOffSet, Locale.getDefault()); + if (weekNumber == prevDate) { + return false; + } + if (set) { + setPrevWeek(weekNumber); + } + return true; + } + + public abstract void info(String text); + + public abstract boolean isEnabled(); + + public abstract boolean isIgnoreTime(); + + public synchronized void loadTimer() { + if (timerLoaded) { + debug("Timer is already loaded"); + return; + } + timerLoaded = true; + timer = Executors.newSingleThreadScheduledExecutor(); + if (getLastUpdated() > 0 && System.currentTimeMillis() - getLastUpdated() > TimeUnit.DAYS.toMillis(4)) { + setIgnoreTime(true); + warning("Skipping time change events, since server has been offline for awhile, use /votingpluginbungee forcetimechanged to force them if needed"); + } + setLastUpdated(); + timer.scheduleWithFixedDelay(() -> { + if (isEnabled()) { + if (!processing) { + update(); + } + } else { + timer.shutdown(); + timerLoaded = false; + } + }, 60, 5, TimeUnit.SECONDS); + timer.scheduleAtFixedRate(this::setLastUpdated, 60, 60, TimeUnit.MINUTES); + } + + public abstract void setIgnoreTime(boolean ignore); + + public abstract void setLastUpdated(); + + public abstract void setPrevDay(int day); + + public abstract void setPrevMonth(String text); + + public abstract void setPrevWeek(int week); + + public abstract void timeChanged(TimeType type, boolean fake, boolean pre, boolean post); + + public void shutdown() { + if (timer != null) { + timer.shutdownNow(); + } + timerLoaded = false; + } + + public void update() { + if (!isEnabled()) { + return; + } + if (hasTimeOffSet()) { + debug("TimeHourOffSet: " + getTime().getHour() + ":" + getTime().getMinute()); + } + if (isIgnoreTime()) { + hasDayChanged(true); + hasMonthChanged(true); + hasWeekChanged(true); + setIgnoreTime(false); + info("Ignoring time change events for one time only"); + } + if (!processing) { + if (hasMonthChanged(false)) { + info("Detected month changed, processing..."); + forceChanged(TimeType.MONTH, false, true, true); + hasMonthChanged(true); + info("Finished processing month changes"); + } else if (hasWeekChanged(false)) { + info("Detected week changed, processing..."); + forceChanged(TimeType.WEEK, false, true, true); + hasWeekChanged(true); + info("Finished processing week changes"); + } else if (hasDayChanged(false)) { + info("Detected day changed, processing..."); + forceChanged(TimeType.DAY, false, true, true); + hasDayChanged(true); + info("Finished processing day changes"); + } + } + } + + public abstract void warning(String text); +} From e9ed3c35b13a17b21a0d656562d16c70fd21c793 Mon Sep 17 00:00:00 2001 From: Ben Date: Mon, 24 Aug 2026 17:05:18 -0600 Subject: [PATCH 4/4] Test deterministic time calculation and recovery --- .../tests/time/TimeCalculationTest.java | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCalculationTest.java diff --git a/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCalculationTest.java b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCalculationTest.java new file mode 100644 index 0000000000..53ffbf5e1a --- /dev/null +++ b/AdvancedCore/src/test/java/com/bencodez/advancedcore/tests/time/TimeCalculationTest.java @@ -0,0 +1,72 @@ +package com.bencodez.advancedcore.tests.time; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.Test; + +import com.bencodez.advancedcore.api.time.TimeCalculation; +import com.bencodez.advancedcore.api.time.TimeType; +import com.bencodez.advancedcore.bungeeapi.time.BungeeTimeChecker; + +public class TimeCalculationTest { + + @Test + public void appliesTargetTimeZoneAndHourOffset() { + Clock clock = Clock.fixed(Instant.parse("2024-03-10T09:30:00Z"), ZoneOffset.UTC); + + LocalDateTime result = TimeCalculation.currentTime(clock, "America/Los_Angeles", 2); + + assertEquals(LocalDateTime.of(2024, 3, 10, 3, 30), result); + } + + @Test + public void weekCalculationUsesConfiguredDayOffset() { + LocalDateTime sunday = LocalDateTime.of(2024, 1, 7, 12, 0); + + int withoutOffset = TimeCalculation.weekNumber(sunday, 0, Locale.US); + int withOffset = TimeCalculation.weekNumber(sunday, 1, Locale.US); + + assertEquals(2, withoutOffset); + assertEquals(2, withOffset); + } + + @Test + public void proxyProcessingStateRecoversAfterTimeChangeFailure() { + AtomicInteger calls = new AtomicInteger(); + BungeeTimeChecker checker = new BungeeTimeChecker("UTC", 0, 0) { + @Override public void debug(String text) { } + @Override public long getLastUpdated() { return 0; } + @Override public int getPrevDay() { return 1; } + @Override public String getPrevMonth() { return "JANUARY"; } + @Override public int getPrevWeek() { return 1; } + @Override public void info(String text) { } + @Override public boolean isEnabled() { return true; } + @Override public boolean isIgnoreTime() { return false; } + @Override public void setIgnoreTime(boolean ignore) { } + @Override public void setLastUpdated() { } + @Override public void setPrevDay(int day) { } + @Override public void setPrevMonth(String text) { } + @Override public void setPrevWeek(int week) { } + @Override public void warning(String text) { } + @Override public boolean hasMonthChanged(boolean set) { return true; } + @Override public boolean hasWeekChanged(boolean set) { return false; } + @Override public boolean hasDayChanged(boolean set) { return false; } + @Override public void timeChanged(TimeType type, boolean fake, boolean pre, boolean post) { + calls.incrementAndGet(); + throw new IllegalStateException("test"); + } + }; + + checker.forceChanged(TimeType.MONTH, false, true, true); + checker.update(); + + assertEquals(2, calls.get(), "a failed change must not leave proxy processing permanently stuck"); + } +}