From 1fdf7fd546207804332e5f6369561bcac27acb56 Mon Sep 17 00:00:00 2001 From: Mrec <66518248+RecoTG@users.noreply.github.com> Date: Sun, 17 Aug 2025 00:32:36 +0100 Subject: [PATCH] feat: schedule weekly pick rotation --- .../servershop/weekly/CronSchedule.java | 97 +++++++++++++ .../servershop/weekly/WeeklyShopManager.java | 129 +++++++++++++++--- src/main/resources/config.yml | 2 + 3 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 src/main/java/com/yourorg/servershop/weekly/CronSchedule.java diff --git a/src/main/java/com/yourorg/servershop/weekly/CronSchedule.java b/src/main/java/com/yourorg/servershop/weekly/CronSchedule.java new file mode 100644 index 0000000..e4817f3 --- /dev/null +++ b/src/main/java/com/yourorg/servershop/weekly/CronSchedule.java @@ -0,0 +1,97 @@ +package com.yourorg.servershop.weekly; + +import java.time.*; +import java.util.*; + +/** + * Minimal cron expression parser supporting five fields: minute, hour, day of + * month, month and day of week. Only simple numeric values, ranges and + * comma-separated lists are supported. This is sufficient for weekly schedules + * such as "0 0 * * MON". + */ +public final class CronSchedule { + private final Set minutes; + private final Set hours; + private final Set doms; + private final Set months; + private final Set dows; // 1=Mon .. 7=Sun + + public CronSchedule(String expr) { + String[] parts = expr.trim().split("\\s+"); + if (parts.length != 5) throw new IllegalArgumentException("Cron must have 5 parts"); + minutes = parsePart(parts[0], 0, 59); + hours = parsePart(parts[1], 0, 23); + doms = parsePart(parts[2], 1, 31); + months = parsePart(parts[3], 1, 12); + dows = parseDow(parts[4]); + } + + private Set parsePart(String part, int min, int max) { + Set set = new LinkedHashSet<>(); + if ("*".equals(part)) { + for (int i = min; i <= max; i++) set.add(i); + return set; + } + for (String t : part.split(",")) { + if (t.contains("-")) { + String[] lr = t.split("-"); + int l = Integer.parseInt(lr[0]); + int r = Integer.parseInt(lr[1]); + for (int i = l; i <= r; i++) set.add(i); + } else { + set.add(Integer.parseInt(t)); + } + } + return set; + } + + private Set parseDow(String part) { + Set set = new LinkedHashSet<>(); + if ("*".equals(part)) { + for (int i = 1; i <= 7; i++) set.add(i); + return set; + } + for (String t : part.split(",")) { + if (t.contains("-")) { + String[] lr = t.split("-"); + int l = toDow(lr[0]); + int r = toDow(lr[1]); + for (int i = l; i <= r; i++) set.add(i); + } else { + set.add(toDow(t)); + } + } + return set; + } + + private int toDow(String s) { + try { + int v = Integer.parseInt(s); + return v == 0 ? 7 : v; // cron 0 or 7 = Sunday + } catch (NumberFormatException ignored) { + return DayOfWeek.valueOf(s.toUpperCase()).getValue(); + } + } + + private boolean matches(ZonedDateTime z) { + return minutes.contains(z.getMinute()) && + hours.contains(z.getHour()) && + doms.contains(z.getDayOfMonth()) && + months.contains(z.getMonthValue()) && + dows.contains(z.getDayOfWeek().getValue()); + } + + /** + * Returns the next instant strictly after {@code from} that matches the + * cron expression. + */ + public Instant next(Instant from, ZoneId zone) { + ZonedDateTime z = ZonedDateTime.ofInstant(from, zone).withSecond(0).withNano(0).plusMinutes(1); + for (int i = 0; i < 500000; i++) { // safety bound ~1 year + if (matches(z)) return z.toInstant(); + z = z.plusMinutes(1); + } + return z.toInstant(); + } +} + diff --git a/src/main/java/com/yourorg/servershop/weekly/WeeklyShopManager.java b/src/main/java/com/yourorg/servershop/weekly/WeeklyShopManager.java index 86928a7..ff58657 100644 --- a/src/main/java/com/yourorg/servershop/weekly/WeeklyShopManager.java +++ b/src/main/java/com/yourorg/servershop/weekly/WeeklyShopManager.java @@ -1,29 +1,124 @@ package com.yourorg.servershop.weekly; import com.yourorg.servershop.ServerShopPlugin; +import org.bukkit.Bukkit; import org.bukkit.Material; +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; -import java.time.*; +import java.io.File; +import java.io.IOException; +import java.time.Instant; +import java.time.ZoneId; import java.util.*; +/** + * Manages the list of weekly picks. Picks are rotated automatically according to + * a cron expression and chosen from a configurable pool (optionally weighted). + */ public final class WeeklyShopManager { private final ServerShopPlugin plugin; + private final int count; + private final Map pool = new EnumMap<>(Material.class); + private final Set current = new LinkedHashSet<>(); + private final Random random = new Random(); + private final File file; + private final CronSchedule schedule; + private final ZoneId zone = ZoneId.of("UTC"); + private long lastRotation = 0L; - public WeeklyShopManager(ServerShopPlugin plugin) { this.plugin = plugin; } - - public boolean isWeekly(Material m) { return currentPicks().contains(m); } - - public java.util.Set currentPicks() { - int count = Math.max(1, plugin.getConfig().getInt("weekly.count", 6)); - LocalDate now = LocalDate.now(); - DayOfWeek dow = DayOfWeek.valueOf(plugin.getConfig().getString("weekly.firstDayOfWeek", "MONDAY")); - LocalDate weekStart = now.with(java.time.temporal.TemporalAdjusters.previousOrSame(dow)); - long seed = weekStart.toEpochDay(); - java.util.List pool = new java.util.ArrayList<>(plugin.catalog().allMaterials()); - pool.sort(java.util.Comparator.comparing(Enum::name)); - java.util.Random rnd = new java.util.Random(seed); - java.util.Set pick = new java.util.LinkedHashSet<>(); - while (pick.size() < Math.min(count, pool.size())) pick.add(pool.get(rnd.nextInt(pool.size()))); - return pick; + public WeeklyShopManager(ServerShopPlugin plugin) { + this.plugin = plugin; + var cfg = plugin.getConfig().getConfigurationSection("weekly"); + this.count = Math.max(1, cfg.getInt("count", 6)); + this.schedule = new CronSchedule(cfg.getString("cron", "0 0 * * MON")); + loadPool(cfg); + this.file = new File(plugin.getDataFolder(), "weekly.yml"); + load(); + Instant now = Instant.now(); + if (current.isEmpty() || lastRotation == 0L || + schedule.next(Instant.ofEpochMilli(lastRotation), zone).isBefore(now)) { + rotate(); + } + scheduleNext(); + } + + /** + * Returns true if the given material is in the current weekly picks. + */ + public synchronized boolean isWeekly(Material m) { return current.contains(m); } + + /** + * Returns the set of current weekly picks. + */ + public synchronized Set currentPicks() { return new LinkedHashSet<>(current); } + + private void loadPool(ConfigurationSection cfg) { + if (cfg.isConfigurationSection("pool")) { + ConfigurationSection sec = cfg.getConfigurationSection("pool"); + for (String k : sec.getKeys(false)) { + Material m = Material.matchMaterial(k); + if (m != null) pool.put(m, Math.max(0.0, sec.getDouble(k, 1.0))); + } + } else if (cfg.isList("pool")) { + for (String s : cfg.getStringList("pool")) { + Material m = Material.matchMaterial(s); + if (m != null) pool.put(m, 1.0); + } + } + if (pool.isEmpty()) { + for (Material m : plugin.catalog().allMaterials()) pool.put(m, 1.0); + } + } + + private synchronized void rotate() { + current.clear(); + List> entries = new ArrayList<>(pool.entrySet()); + for (int i = 0; i < Math.min(count, entries.size()); i++) { + double total = 0.0; + for (var e : entries) total += Math.max(0.0, e.getValue()); + double r = random.nextDouble() * total; + double acc = 0.0; + Iterator> it = entries.iterator(); + while (it.hasNext()) { + var e = it.next(); + acc += Math.max(0.0, e.getValue()); + if (r <= acc) { current.add(e.getKey()); it.remove(); break; } + } + } + lastRotation = System.currentTimeMillis(); + save(); + } + + private synchronized void load() { + current.clear(); + if (!file.exists()) return; + YamlConfiguration y = YamlConfiguration.loadConfiguration(file); + lastRotation = y.getLong("lastRotation", 0L); + for (String s : y.getStringList("picks")) { + Material m = Material.matchMaterial(s); + if (m != null) current.add(m); + } + } + + private synchronized void save() { + YamlConfiguration y = new YamlConfiguration(); + y.set("lastRotation", lastRotation); + List list = new ArrayList<>(); + for (Material m : current) list.add(m.name()); + y.set("picks", list); + try { y.save(file); } catch (IOException e) { + plugin.getLogger().warning("Failed to save weekly picks: " + e.getMessage()); + } + } + + private void scheduleNext() { + Instant next = schedule.next(Instant.ofEpochMilli(lastRotation), zone); + long delayTicks = Math.max(1L, (next.toEpochMilli() - System.currentTimeMillis()) / 50L); + Bukkit.getScheduler().runTaskLater(plugin, () -> { + rotate(); + scheduleNext(); + }, delayTicks); } } + diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 69d4af7..4947b87 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -4,6 +4,8 @@ weekly: count: 6 discount: 0.80 firstDayOfWeek: MONDAY + cron: "0 0 * * MON" # Monday 00:00 UTC + pool: {} priceModel: minFactor: 0.5 maxFactor: 1.5