From ab469b3e15759a69ef40870a98e9aef02ec40623 Mon Sep 17 00:00:00 2001 From: XxFran10xX Date: Tue, 20 Oct 2026 21:38:58 +0200 Subject: [PATCH 01/17] Add a referral system to the activity menu New players pick who invited them in /referral (or the new button in /activity). Both players then claim per-act milestone rewards as the new player's AFK-aware playtime grows. Links are permanent, refused across shared connections, loops and inviter caps, and only open in the season's first days or an account's first week. Co-Authored-By: Claude Opus 5.5 --- .../activitytf/ActivityPlugin.java | 11 + .../activitytf/commands/ReferralCommand.java | 172 +++++ .../config/ActivityConfiguration.java | 58 +- .../activitytf/config/Messages.java | 16 + .../config/ReferralConfiguration.java | 308 +++++++++ .../activitytf/gui/ActivityGui.java | 31 + .../activitytf/gui/ReferralGui.java | 609 ++++++++++++++++++ .../listeners/ReferralListener.java | 53 ++ .../activitytf/managers/ActivityManager.java | 17 +- .../activitytf/managers/ReferralManager.java | 582 +++++++++++++++++ .../activitytf/models/ReferralData.java | 129 ++++ .../activitytf/store/ReferralStore.java | 279 ++++++++ src/main/resources/messages.yml | 99 +++ src/main/resources/plugin.yml | 11 + src/main/resources/referral.yml | 146 +++++ .../activitytf/ActivityPluginTest.java | 14 +- .../commands/ReferralCommandTest.java | 146 +++++ .../activitytf/config/MessagesLinesTest.java | 31 + .../config/ReferralConfigurationTest.java | 267 ++++++++ .../activitytf/gui/ReferralGuiTest.java | 460 +++++++++++++ .../activitytf/hooks/TLibsItemsTest.java | 4 +- .../listeners/ReferralListenerTest.java | 74 +++ .../activitytf/managers/ReferralFixture.java | 199 ++++++ .../managers/ReferralManagerTest.java | 481 ++++++++++++++ .../activitytf/models/ReferralDataTest.java | 62 ++ .../activitytf/store/ReferralStoreTest.java | 244 +++++++ 26 files changed, 4468 insertions(+), 35 deletions(-) create mode 100644 src/main/java/net/tfminecraft/activitytf/commands/ReferralCommand.java create mode 100644 src/main/java/net/tfminecraft/activitytf/config/ReferralConfiguration.java create mode 100644 src/main/java/net/tfminecraft/activitytf/gui/ReferralGui.java create mode 100644 src/main/java/net/tfminecraft/activitytf/listeners/ReferralListener.java create mode 100644 src/main/java/net/tfminecraft/activitytf/managers/ReferralManager.java create mode 100644 src/main/java/net/tfminecraft/activitytf/models/ReferralData.java create mode 100644 src/main/java/net/tfminecraft/activitytf/store/ReferralStore.java create mode 100644 src/main/resources/referral.yml create mode 100644 src/test/java/net/tfminecraft/activitytf/commands/ReferralCommandTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/config/MessagesLinesTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/config/ReferralConfigurationTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/gui/ReferralGuiTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/listeners/ReferralListenerTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/managers/ReferralFixture.java create mode 100644 src/test/java/net/tfminecraft/activitytf/managers/ReferralManagerTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/models/ReferralDataTest.java create mode 100644 src/test/java/net/tfminecraft/activitytf/store/ReferralStoreTest.java diff --git a/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java b/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java index 1dca453..89ffe58 100644 --- a/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java +++ b/src/main/java/net/tfminecraft/activitytf/ActivityPlugin.java @@ -3,7 +3,9 @@ import org.bukkit.Bukkit; import org.bukkit.plugin.java.JavaPlugin; import net.tfminecraft.activitytf.commands.ActivityCommand; +import net.tfminecraft.activitytf.commands.ReferralCommand; import net.tfminecraft.activitytf.gui.ActivityGui; +import net.tfminecraft.activitytf.gui.ReferralGui; import net.tfminecraft.activitytf.hooks.PlaceholderHook; import net.tfminecraft.activitytf.listeners.AdvancedCraftListener; import net.tfminecraft.activitytf.listeners.ArchaeologyListener; @@ -22,6 +24,7 @@ import net.tfminecraft.activitytf.listeners.MmoItemsStationListener; import net.tfminecraft.activitytf.listeners.ProfessionUpgradeListener; import net.tfminecraft.activitytf.listeners.ProfessionXpListener; +import net.tfminecraft.activitytf.listeners.ReferralListener; import net.tfminecraft.activitytf.listeners.VehicleBuildListener; import net.tfminecraft.activitytf.listeners.VoteListener; import net.tfminecraft.activitytf.managers.ActivityManager; @@ -38,6 +41,8 @@ public void onEnable() { manager.initialize(); ActivityGui gui = new ActivityGui(manager); + ReferralGui referralGui = new ReferralGui(manager, gui::open); + gui.setReferralOpener(referralGui::open); ActivityCommand command = new ActivityCommand(manager, gui); command.loadKnownPlayers(); @@ -49,6 +54,12 @@ public void onEnable() { getServer().getPluginManager().registerEvents(new CraftListener(manager), this); getServer().getPluginManager().registerEvents(gui, this); + ReferralCommand referralCommand = new ReferralCommand(manager, referralGui); + getCommand("referral").setExecutor(referralCommand); + getCommand("referral").setTabCompleter(referralCommand); + getServer().getPluginManager().registerEvents(new ReferralListener(manager.referrals()), this); + getServer().getPluginManager().registerEvents(referralGui, this); + // Paper can enable soft dependencies after this plugin when their own dependencies form // a cycle, so re-check them once every plugin has finished enabling. getServer().getScheduler().runTask(this, () -> { diff --git a/src/main/java/net/tfminecraft/activitytf/commands/ReferralCommand.java b/src/main/java/net/tfminecraft/activitytf/commands/ReferralCommand.java new file mode 100644 index 0000000..4a77572 --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/commands/ReferralCommand.java @@ -0,0 +1,172 @@ +package net.tfminecraft.activitytf.commands; + +import org.bukkit.Bukkit; +import org.bukkit.command.Command; +import org.bukkit.command.CommandExecutor; +import org.bukkit.command.CommandSender; +import org.bukkit.command.TabCompleter; +import org.bukkit.entity.Player; +import net.tfminecraft.activitytf.config.Messages; +import net.tfminecraft.activitytf.gui.ReferralGui; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.managers.ReferralManager; +import net.tfminecraft.activitytf.models.ReferralData; +import net.tfminecraft.activitytf.utils.Utils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.UUID; + +/** /referral [invitedby ], plus info/unlink/addtime for staff. */ +public class ReferralCommand implements CommandExecutor, TabCompleter { + + static final String USE = "activity.referral"; + static final String ADMIN = "activity.referral.admin"; + + private static final List PLAYER_SUBCOMMANDS = List.of("invitedby"); + private static final List ADMIN_SUBCOMMANDS = List.of("info", "unlink", "addtime"); + private static final int MAX_ADD_MINUTES = 100_000; + + private final ActivityManager manager; + private final ReferralGui gui; + + public ReferralCommand(ActivityManager manager, ReferralGui gui) { + this.manager = manager; + this.gui = gui; + } + + private Messages messages() { + return manager.getConfiguration().messages(); + } + + private ReferralManager referrals() { + return manager.referrals(); + } + + @Override + public boolean onCommand(CommandSender sender, Command command, String label, String[] args) { + String sub = args.length == 0 ? "" : args[0].toLowerCase(Locale.ROOT); + if (ADMIN_SUBCOMMANDS.contains(sub)) { + if (!sender.hasPermission(ADMIN)) { + sender.sendMessage(messages().get("admin.no-permission")); + return true; + } + switch (sub) { + case "info" -> info(sender, args); + case "unlink" -> unlink(sender, args); + default -> addTime(sender, args); + } + return true; + } + + if (!(sender instanceof Player player)) { + sender.sendMessage(messages().get("admin.players-only")); + return true; + } + if (!player.hasPermission(USE)) { + player.sendMessage(messages().get("admin.no-permission")); + return true; + } + if (sub.isEmpty()) { + gui.open(player); + } else if (sub.equals("invitedby") && args.length == 2) { + gui.confirmTyped(player, args[1]); + } else { + player.sendMessage(messages().get("referral.usage")); + if (player.hasPermission(ADMIN)) { + player.sendMessage(messages().get("referral.usage-admin")); + } + } + return true; + } + + private UUID target(CommandSender sender, String[] args, int length) { + if (args.length != length) { + sender.sendMessage(messages().get("referral.usage-admin")); + return null; + } + UUID uuid = referrals().find(args[1]); + if (uuid == null) { + sender.sendMessage(messages().get("referral.admin.unknown", "%name%", args[1])); + } + return uuid; + } + + private void info(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 2); + if (uuid == null) { + return; + } + ReferralData data = referrals().getStore().peek(uuid); + ReferralData inviter = data.invitedBy() == null ? null : referrals().getStore().peek(data.invitedBy()); + List invited = new ArrayList<>(); + for (UUID invitee : referrals().invitees(uuid)) { + ReferralData other = referrals().getStore().peek(invitee); + invited.add(other.name() + " (" + other.minutes() / 60 + "h)"); + } + sender.sendMessage(messages().get("referral.admin.info", "%name%", data.name(), "%minutes%", data.minutes(), + "%hours%", data.minutes() / 60, "%inviter%", inviter == null ? "-" : inviter.name(), + "%claimed%", data.claimed(), "%invited%", invited.isEmpty() ? "-" : String.join(", ", invited))); + } + + private void unlink(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 2); + if (uuid == null) { + return; + } + boolean done = referrals().unlink(uuid); + sender.sendMessage(messages().get(done ? "referral.admin.unlinked" : "referral.admin.not-linked", + "%name%", args[1])); + audit(sender, "action=unlink target=" + uuid + " result=" + (done ? "done" : "not-linked")); + } + + private void addTime(CommandSender sender, String[] args) { + UUID uuid = target(sender, args, 3); + if (uuid == null) { + return; + } + int minutes; + try { + minutes = Integer.parseInt(args[2]); + } catch (NumberFormatException e) { + minutes = 0; + } + if (minutes == 0 || Math.abs(minutes) > MAX_ADD_MINUTES) { + sender.sendMessage(messages().get("referral.usage-admin")); + return; + } + ReferralData data = referrals().getStore().peek(uuid); + int before = data.minutes(); + data.setMinutes((int) Math.min(Integer.MAX_VALUE, (long) before + minutes)); + referrals().getStore().markDirty(); + sender.sendMessage(messages().get("referral.admin.time-added", "%name%", data.name(), + "%minutes%", data.minutes())); + audit(sender, "action=addtime target=" + uuid + " minutes=" + before + "->" + data.minutes() + " result=done"); + } + + private void audit(CommandSender sender, String what) { + manager.logger().info("REFERRAL-AUDIT sender=" + Utils.quotedForLog(sender.getName()) + " " + what); + } + + @Override + public List onTabComplete(CommandSender sender, Command command, String alias, String[] args) { + List options = new ArrayList<>(); + if (args.length == 1) { + options.addAll(PLAYER_SUBCOMMANDS); + if (sender.hasPermission(ADMIN)) { + options.addAll(ADMIN_SUBCOMMANDS); + } + } else if (args.length == 2 && (args[0].equalsIgnoreCase("invitedby") + || sender.hasPermission(ADMIN) && ADMIN_SUBCOMMANDS.contains(args[0].toLowerCase(Locale.ROOT)))) { + for (Player online : Bukkit.getOnlinePlayers()) { + if (!(sender instanceof Player player) || player.canSee(online)) { + options.add(online.getName()); + } + } + } + String typed = args.length == 0 ? "" : args[args.length - 1].toLowerCase(Locale.ROOT); + options.removeIf(option -> !option.toLowerCase(Locale.ROOT).startsWith(typed)); + return options; + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java index 071ee0b..8e09956 100644 --- a/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java +++ b/src/main/java/net/tfminecraft/activitytf/config/ActivityConfiguration.java @@ -809,43 +809,49 @@ private Map> loadRewardPools(ConfigurationSection conf return Map.copyOf(pools); } - private List loadRewardPool(ConfigurationSection config, String path) { + List loadRewardPool(ConfigurationSection config, String path) { List pool = new ArrayList<>(); if (!set(config, path)) { return List.of(); } int index = 0; for (Map entry : config.getMapList(path)) { - String where = path + "[" + index++ + "]"; - - int weight = entry.get("weight") instanceof Number number ? number.intValue() : 1; - if (weight <= 0) { - log().warning(where + " has weight " + weight + " - a weight must be above 0," - + " so this reward is skipped and can never be drawn."); - continue; + RewardEntry reward = rewardEntry(entry, path + "[" + index++ + "]"); + if (reward != null) { + pool.add(reward); } + } + return List.copyOf(pool); + } - List commands = new ArrayList<>(); - if (entry.get("commands") instanceof List raw) { - for (Object command : raw) { - if (command != null) { - commands.add(String.valueOf(command)); - } - } - } - List items = loadRewardItems(entry.get("items"), where); + // One pool-style entry (weight, display, items, commands); null, with a warning, when it pays nothing. + RewardEntry rewardEntry(Map entry, String where) { + int weight = entry.get("weight") instanceof Number number ? number.intValue() : 1; + if (weight <= 0) { + log().warning(where + " has weight " + weight + " - a weight must be above 0," + + " so this reward is skipped and can never be drawn."); + return null; + } - if (commands.isEmpty() && items.isEmpty()) { - log().warning(where + " has no commands and no items - skipped, since drawing it" - + " would pay the player nothing."); - continue; + List commands = new ArrayList<>(); + if (entry.get("commands") instanceof List raw) { + for (Object command : raw) { + if (command != null) { + commands.add(String.valueOf(command)); + } } + } + List items = loadRewardItems(entry.get("items"), where); - Object display = entry.get("display"); - pool.add(new RewardEntry(Math.min(weight, 1_000_000), - display == null ? "" : String.valueOf(display), List.copyOf(commands), List.copyOf(items))); + if (commands.isEmpty() && items.isEmpty()) { + log().warning(where + " has no commands and no items - skipped, since drawing it" + + " would pay the player nothing."); + return null; } - return List.copyOf(pool); + + Object display = entry.get("display"); + return new RewardEntry(Math.min(weight, 1_000_000), + display == null ? "" : String.valueOf(display), List.copyOf(commands), List.copyOf(items)); } private List loadRewardItems(Object raw, String where) { @@ -1132,7 +1138,7 @@ static boolean poolNeededButEmpty(List pool, Map lines(String path, Object... placeholderPairs) { + List lines = new ArrayList<>(); + for (String line : messages.getStringList(path)) { + String message = Utils.colorize(line); + for (int index = 0; index + 1 < placeholderPairs.length; index += 2) { + message = message.replace(String.valueOf(placeholderPairs[index]), + String.valueOf(placeholderPairs[index + 1])); + } + lines.add(message); + } + return lines; + } + public String raw(String path) { String value = messages.getString(path); return value == null ? path : value; diff --git a/src/main/java/net/tfminecraft/activitytf/config/ReferralConfiguration.java b/src/main/java/net/tfminecraft/activitytf/config/ReferralConfiguration.java new file mode 100644 index 0000000..fc85695 --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/config/ReferralConfiguration.java @@ -0,0 +1,308 @@ +package net.tfminecraft.activitytf.config; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import net.tfminecraft.activitytf.models.RewardEntry; +import net.tfminecraft.activitytf.utils.ItemPath; +import net.tfminecraft.activitytf.utils.Utils; + +import java.io.File; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Set; +import java.util.logging.Logger; + +/** referral.yml: who may link, how playtime is counted, and one milestone table per act. */ +public class ReferralConfiguration { + + public static final String FILE = "referral.yml"; + + /** The GUI has one row of seven milestone slots. */ + public static final int MAX_MILESTONES = 7; + + /** A milestone reward: a fixed entry, or one draw from {@code pool}. */ + public record Reward(String display, String icon, String pool, RewardEntry fixed) { + } + + public record Milestone(int hours, Reward newPlayer, Reward inviter, boolean rare) { + public int minutes() { + return hours * 60; + } + } + + /** {@code rareLimit} below 0 means no limit. */ + public record Act(String id, String display, LocalDateTime starts, int rareLimit, List milestones) { + } + + private final JavaPlugin plugin; + private final ActivityConfiguration activity; + + private volatile boolean enabled; + private volatile LocalDateTime seasonStart; + private volatile String seasonKey = ""; + private volatile int openDays; + private volatile int newAccountDays; + private volatile int maxInvitees; + private volatile int maxLinksPerDay; + private volatile boolean ipCheck; + private volatile int afkMinutes; + private volatile int moveBlocks; + private volatile Set ignoredWorlds = Set.of(); + private volatile List acts = List.of(); + private volatile Map> pools = Map.of(); + + public ReferralConfiguration(JavaPlugin plugin, ActivityConfiguration activity) { + this.plugin = plugin; + this.activity = activity; + } + + public void load() { + File file = new File(plugin.getDataFolder(), FILE); + if (!file.exists()) { + plugin.saveResource(FILE, false); + } + load(YamlConfiguration.loadConfiguration(file)); + } + + void load(ConfigurationSection config) { + enabled = config.getBoolean("enabled", true); + + String start = config.getString("season.start", "").strip(); + seasonStart = start.isEmpty() ? null : dateTime(start, "season.start"); + seasonKey = seasonStart == null ? "" : seasonStart.toString(); + openDays = Math.max(0, config.getInt("season.open-days", 14)); + newAccountDays = Math.max(0, config.getInt("link.new-account-days", 7)); + maxInvitees = Math.max(0, config.getInt("link.max-invitees", 0)); + maxLinksPerDay = Math.max(0, config.getInt("link.max-links-per-day", 3)); + ipCheck = config.getBoolean("link.same-ip-check", true); + + afkMinutes = Math.max(1, config.getInt("playtime.afk-minutes", 5)); + moveBlocks = Math.max(0, config.getInt("playtime.move-blocks", 3)); + Set worlds = new HashSet<>(); + for (String world : config.getStringList("playtime.ignored-worlds")) { + worlds.add(world.strip().toLowerCase(Locale.ROOT)); + } + ignoredWorlds = Set.copyOf(worlds); + + pools = loadPools(config.getConfigurationSection("pools")); + acts = loadActs(config.getConfigurationSection("acts")); + if (enabled && acts.isEmpty()) { + log().warning(FILE + " has no usable act under 'acts' - nothing can be claimed."); + } + } + + private Logger log() { + return plugin.getLogger(); + } + + private LocalDateTime dateTime(String value, String at) { + try { + return value.contains("T") ? LocalDateTime.parse(value) : LocalDate.parse(value).atStartOfDay(); + } catch (DateTimeParseException e) { + log().warning(at + " '" + Utils.safeForLog(value) + "' is not a date (2026-10-10) or date-time" + + " (2026-10-10T06:00) - ignored."); + return null; + } + } + + private Map> loadPools(ConfigurationSection section) { + if (section == null) { + return Map.of(); + } + Map> loaded = new LinkedHashMap<>(); + for (String key : section.getKeys(false)) { + if (!ActivityConfiguration.isPoolName(key)) { + log().warning(FILE + " pools." + Utils.safeForLog(key) + " must use a pool_ name - ignored."); + continue; + } + loaded.put(ActivityConfiguration.poolName(key), activity.loadRewardPool(section, key)); + } + return Map.copyOf(loaded); + } + + private List loadActs(ConfigurationSection section) { + if (section == null) { + return List.of(); + } + List loaded = new ArrayList<>(); + for (String id : section.getKeys(false)) { + ConfigurationSection act = section.getConfigurationSection(id); + String at = "acts." + Utils.safeForLog(id); + if (act == null) { + log().warning(FILE + " " + at + " is not a section - ignored."); + continue; + } + LocalDateTime starts = dateTime(act.getString("starts", "").strip(), at + ".starts"); + if (starts == null) { + log().warning(FILE + " " + at + " has no usable 'starts' date - the act is ignored."); + continue; + } + List milestones = loadMilestones(act, at); + if (milestones.isEmpty()) { + log().warning(FILE + " " + at + " has no usable milestone - the act is ignored."); + continue; + } + loaded.add(new Act(id, act.getString("display", id), starts, act.getInt("rare-limit", 2), + milestones)); + } + loaded.sort(Comparator.comparing(Act::starts)); + return List.copyOf(loaded); + } + + private List loadMilestones(ConfigurationSection act, String at) { + List milestones = new ArrayList<>(); + Set seen = new HashSet<>(); + int index = 0; + for (Map raw : act.getMapList("milestones")) { + String where = at + ".milestones[" + index++ + "]"; + int hours = raw.get("hours") instanceof Number number ? number.intValue() : 0; + if (hours < 1 || hours > 10_000) { + log().warning(FILE + " " + where + " needs 'hours' between 1 and 10000 - ignored."); + continue; + } + if (seen.contains(hours)) { + log().warning(FILE + " " + where + " repeats " + hours + "h - ignored."); + continue; + } + Reward newPlayer = reward(raw.get("new-player"), where + ".new-player"); + Reward inviter = reward(raw.get("inviter"), where + ".inviter"); + if (newPlayer == null && inviter == null) { + log().warning(FILE + " " + where + " pays neither player - ignored."); + continue; + } + seen.add(hours); + milestones.add(new Milestone(hours, newPlayer, inviter, Boolean.TRUE.equals(raw.get("rare")))); + } + milestones.sort(Comparator.comparingInt(Milestone::hours)); + if (milestones.size() > MAX_MILESTONES) { + log().warning(FILE + " " + at + " has " + milestones.size() + " milestones - only the first " + + MAX_MILESTONES + " fit in the menu, the rest are ignored."); + return List.copyOf(milestones.subList(0, MAX_MILESTONES)); + } + return List.copyOf(milestones); + } + + private Reward reward(Object raw, String at) { + if (raw == null) { + return null; + } + if (raw instanceof String string && ActivityConfiguration.isPoolName(string)) { + return poolReward(ActivityConfiguration.poolName(string), null, null, at); + } + if (!(raw instanceof Map block)) { + log().warning(FILE + " " + at + " is not a pool name or a reward block - ignored."); + return null; + } + String display = block.get("display") == null ? null : String.valueOf(block.get("display")); + String icon = block.get("icon") == null ? null : String.valueOf(block.get("icon")).strip(); + if (icon != null && ItemPath.isUnsupportedPath(icon)) { + log().warning(FILE + " " + at + ".icon '" + Utils.safeForLog(icon) + "' is not an item path - ignored."); + icon = null; + } + if (block.get("pool") instanceof String pool && ActivityConfiguration.isPoolName(pool)) { + return poolReward(ActivityConfiguration.poolName(pool), display, icon, at); + } + RewardEntry fixed = activity.rewardEntry(block, FILE + " " + at); + if (fixed == null) { + return null; + } + return new Reward(display == null || display.isBlank() ? fixedName(fixed) : display, icon, null, fixed); + } + + private Reward poolReward(String pool, String display, String icon, String at) { + if (pool(pool).isEmpty()) { + log().warning(FILE + " " + at + " draws from '" + Utils.safeForLog(pool) + "', which is not a pool in " + + FILE + " or the activity rewards, or has no usable entry - it stays unclaimable."); + } + return new Reward(display == null || display.isBlank() ? ActivityConfiguration.itemName(pool) : display, + icon, pool, null); + } + + private static String fixedName(RewardEntry fixed) { + RewardEntry.Item first = fixed.items().isEmpty() ? null : fixed.items().get(0); + return first == null ? "Reward" : ActivityConfiguration.itemName(first.path()); + } + + /** A pool from referral.yml, else the activity's (week-locked) reward pool of that name. */ + public List pool(String name) { + List own = pools.get(name); + return own != null ? own : activity.rewardPool(name); + } + + /** The act whose start is the latest one not after {@code now}; null before the first act. */ + public Act act(LocalDateTime now) { + Act current = null; + for (Act act : acts) { + if (!act.starts().isAfter(now)) { + current = act; + } + } + return current; + } + + public List acts() { + return acts; + } + + public boolean enabled() { + return enabled; + } + + public LocalDateTime seasonStart() { + return seasonStart; + } + + /** Identifies the season the stored links belong to; a change starts a fresh referrals file. */ + public String seasonKey() { + return seasonKey; + } + + public int openDays() { + return openDays; + } + + public int newAccountDays() { + return newAccountDays; + } + + public int maxInvitees() { + return maxInvitees; + } + + public int maxLinksPerDay() { + return maxLinksPerDay; + } + + public boolean ipCheck() { + return ipCheck; + } + + public int afkMinutes() { + return afkMinutes; + } + + public int moveBlocks() { + return moveBlocks; + } + + public boolean ignoredWorld(String world) { + return world != null && ignoredWorlds.contains(world.toLowerCase(Locale.ROOT)); + } + + public Messages messages() { + return activity.messages(); + } + + public ActivityConfiguration activity() { + return activity; + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java b/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java index a4c5238..56538ef 100644 --- a/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java +++ b/src/main/java/net/tfminecraft/activitytf/gui/ActivityGui.java @@ -31,6 +31,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.function.Consumer; import java.util.function.Function; public class ActivityGui implements Listener { @@ -39,6 +40,7 @@ public class ActivityGui implements Listener { private static final int DAILY_BAR_SLOT = 3; private static final int REROLL_SLOT = 4; private static final int BAR_SLOT = 5; + private static final int REFERRAL_SLOT = 22; private static final int[] TASK_SLOTS = {10, 11, 12, 13, 14, 15, 16}; @@ -46,10 +48,23 @@ public class ActivityGui implements Listener { private final ActivityManager manager; + private Consumer referralOpener; + public ActivityGui(ActivityManager manager) { this.manager = manager; } + /** Adds the referral button, which opens the referral menu through {@code opener}. */ + public void setReferralOpener(Consumer opener) { + this.referralOpener = opener; + } + + public void open(Player player) { + Inventory inventory = build(player); + manager.claimDailyReward(player); + player.openInventory(inventory); + } + public static final class Marker implements InventoryHolder { private Inventory inventory; @@ -82,6 +97,11 @@ public Inventory build(Player player) { } } + if (referralShown(player)) { + inventory.setItem(REFERRAL_SLOT, item(Material.PLAYER_HEAD, messages.get("gui.referral-name"), + List.of(messages.get("gui.referral-lore")))); + } + fillEmptySlots(inventory, messages); return inventory; @@ -108,6 +128,11 @@ static String taskIdAt(ActivityConfiguration config, PlayerData data, int slot) return config.activity(id) == null ? null : id; } + private boolean referralShown(Player player) { + return referralOpener != null && manager.referrals().getConfiguration().enabled() + && player.hasPermission("activity.referral"); + } + private void fillEmptySlots(Inventory inventory, Messages messages) { ItemStack filler = filler(messages); for (int slot = 0; slot < SIZE; slot++) { @@ -326,6 +351,12 @@ public void onClick(InventoryClickEvent event) { return; } + if (event.getRawSlot() == REFERRAL_SLOT && referralShown(player)) { + clickSound(player); + referralOpener.accept(player); + return; + } + if (event.getRawSlot() == REROLL_SLOT) { clickSound(player); reroll(player, event); diff --git a/src/main/java/net/tfminecraft/activitytf/gui/ReferralGui.java b/src/main/java/net/tfminecraft/activitytf/gui/ReferralGui.java new file mode 100644 index 0000000..bf7005c --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/gui/ReferralGui.java @@ -0,0 +1,609 @@ +package net.tfminecraft.activitytf.gui; + +import com.google.common.collect.ImmutableMultimap; +import net.kyori.adventure.text.event.ClickEvent; +import net.kyori.adventure.text.serializer.legacy.LegacyComponentSerializer; +import org.bukkit.Bukkit; +import org.bukkit.Material; +import org.bukkit.OfflinePlayer; +import org.bukkit.Sound; +import org.bukkit.entity.Player; +import org.bukkit.event.EventHandler; +import org.bukkit.event.Listener; +import org.bukkit.event.inventory.InventoryClickEvent; +import org.bukkit.event.inventory.InventoryDragEvent; +import org.bukkit.inventory.Inventory; +import org.bukkit.inventory.InventoryHolder; +import org.bukkit.inventory.ItemFlag; +import org.bukkit.inventory.ItemStack; +import org.bukkit.inventory.meta.ItemMeta; +import org.bukkit.inventory.meta.SkullMeta; +import net.tfminecraft.activitytf.config.ActivityConfiguration; +import net.tfminecraft.activitytf.config.Messages; +import net.tfminecraft.activitytf.config.ReferralConfiguration; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Act; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Milestone; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Reward; +import net.tfminecraft.activitytf.hooks.ItemsAdderItems; +import net.tfminecraft.activitytf.hooks.TLibsItems; +import net.tfminecraft.activitytf.managers.ActivityManager; +import net.tfminecraft.activitytf.managers.ReferralManager; +import net.tfminecraft.activitytf.managers.ReferralManager.Claim; +import net.tfminecraft.activitytf.managers.ReferralManager.Link; +import net.tfminecraft.activitytf.managers.ReferralManager.State; +import net.tfminecraft.activitytf.models.ReferralData; +import net.tfminecraft.activitytf.models.RewardEntry; +import net.tfminecraft.activitytf.utils.Bar; +import net.tfminecraft.activitytf.utils.Utils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.UUID; +import java.util.function.Consumer; + +/** /referral: your own milestones, the players you invited, and picking who invited you. */ +public class ReferralGui implements Listener { + + static final int SIZE = 54; + static final int CONFIRM_SIZE = 27; + + static final int INVITER_SLOT = 0; + static final int PROGRESS_SLOT = 4; + static final int RULES_SLOT = 8; + static final int MILESTONE_ROW = 9; + static final int INVITED_HEADER_SLOT = 22; + static final int FIRST_INVITEE_SLOT = 27; + static final int INVITEES_PER_PAGE = 18; + static final int BACK_SLOT = 45; + static final int PREVIOUS_SLOT = 48; + static final int RARE_SLOT = 49; + static final int TYPE_NAME_SLOT = 49; + static final int NEXT_SLOT = 50; + static final int PLAYERS_PER_PAGE = 45; + + static final int CONFIRM_SLOT = 11; + static final int CONFIRM_HEAD_SLOT = 13; + static final int CANCEL_SLOT = 15; + + private static final int PROGRESS_BAR_LENGTH = 30; + + enum View { + MAIN, + PICK, + CONFIRM + } + + public static final class Marker implements InventoryHolder { + + private final View view; + private final int page; + private final UUID target; + private final List shown = new ArrayList<>(); + private final List hours = new ArrayList<>(); + private Inventory inventory; + + Marker(View view, int page, UUID target) { + this.view = view; + this.page = page; + this.target = target; + } + + @Override + public Inventory getInventory() { + return inventory; + } + + View view() { + return view; + } + + int page() { + return page; + } + + UUID target() { + return target; + } + } + + private final ActivityManager manager; + private final Consumer openActivity; + + public ReferralGui(ActivityManager manager, Consumer openActivity) { + this.manager = manager; + this.openActivity = openActivity; + } + + private ReferralManager referrals() { + return manager.referrals(); + } + + private Messages messages() { + return manager.getConfiguration().messages(); + } + + public void open(Player player) { + player.openInventory(build(player, 0)); + } + + // ---- main view --------------------------------------------------------------------------- + + @SuppressWarnings("deprecation") + public Inventory build(Player player, int page) { + Messages messages = messages(); + ReferralManager referrals = referrals(); + ReferralData data = referrals.getStore().get(player.getUniqueId(), player.getName()); + Act act = referrals.act(); + + Marker marker = new Marker(View.MAIN, page, null); + Inventory inventory = Bukkit.createInventory(marker, SIZE, messages.get("referral.gui.title")); + marker.inventory = inventory; + + inventory.setItem(INVITER_SLOT, inviterItem(player, data)); + inventory.setItem(PROGRESS_SLOT, progressItem(data, act)); + inventory.setItem(RULES_SLOT, rulesItem()); + + List milestones = act == null ? List.of() : act.milestones(); + int first = MILESTONE_ROW + (9 - milestones.size()) / 2; + for (int index = 0; index < milestones.size(); index++) { + Milestone milestone = milestones.get(index); + marker.hours.add(milestone.newPlayer() == null ? null : milestone.hours()); + inventory.setItem(first + index, ownMilestoneItem(data, milestone)); + } + if (act == null) { + inventory.setItem(MILESTONE_ROW + 4, item(Material.BARRIER, messages.get("referral.gui.no-act"), List.of())); + } + + List invited = referrals.invitees(player.getUniqueId()); + inventory.setItem(INVITED_HEADER_SLOT, item(Material.NAME_TAG, + messages.get("referral.gui.invited-header", "%count%", invited.size()), + List.of(messages.get("referral.gui.invited-header-lore")))); + int pages = Math.max(1, (invited.size() + INVITEES_PER_PAGE - 1) / INVITEES_PER_PAGE); + int shownPage = Math.max(0, Math.min(page, pages - 1)); + List slice = invited.subList(shownPage * INVITEES_PER_PAGE, + Math.min(invited.size(), (shownPage + 1) * INVITEES_PER_PAGE)); + for (int index = 0; index < slice.size(); index++) { + marker.shown.add(slice.get(index)); + inventory.setItem(FIRST_INVITEE_SLOT + index, inviteeItem(data, slice.get(index), act)); + } + + inventory.setItem(BACK_SLOT, item(Material.EXPERIENCE_BOTTLE, messages.get("referral.gui.back"), List.of())); + if (shownPage > 0) { + inventory.setItem(PREVIOUS_SLOT, item(Material.ARROW, messages.get("referral.gui.previous"), List.of())); + } + if (shownPage < pages - 1) { + inventory.setItem(NEXT_SLOT, item(Material.ARROW, messages.get("referral.gui.next"), List.of())); + } + inventory.setItem(RARE_SLOT, rareItem(data, act)); + fill(inventory); + return inventory; + } + + private ItemStack inviterItem(Player player, ReferralData data) { + Messages messages = messages(); + if (data.invitedBy() != null) { + ReferralData inviter = referrals().getStore().peek(data.invitedBy()); + String name = inviter == null ? "?" : inviter.name(); + return head(data.invitedBy(), messages.get("referral.gui.invited-by", "%name%", name), + List.of(messages.get("referral.gui.invited-by-lore"))); + } + Link refused = referrals().linkable(player.getUniqueId()); + if (refused != null) { + return item(Material.GRAY_DYE, messages.get("referral.gui.link-closed"), + List.of(messages.get(linkKey(refused)))); + } + return item(Material.WRITABLE_BOOK, messages.get("referral.gui.who-invited"), + List.of(messages.get("referral.gui.who-invited-lore"))); + } + + private ItemStack progressItem(ReferralData data, Act act) { + Messages messages = messages(); + int goal = act == null ? 0 : act.milestones().get(act.milestones().size() - 1).minutes(); + List marks = new ArrayList<>(); + if (act != null) { + for (Milestone milestone : act.milestones()) { + marks.add(milestone.minutes()); + } + } + List lore = new ArrayList<>(); + lore.add(Utils.colorize(Bar.render(data.minutes(), goal, PROGRESS_BAR_LENGTH, marks))); + lore.add(messages.get(data.invitedBy() == null ? "referral.gui.progress-unlinked" : "referral.gui.progress-lore")); + return item(Material.CLOCK, messages.get("referral.gui.progress", "%played%", duration(data.minutes()), + "%goal%", duration(goal)), lore); + } + + private ItemStack rulesItem() { + Messages messages = messages(); + ReferralConfiguration config = referrals().getConfiguration(); + return item(Material.BOOK, messages.get("referral.gui.rules"), messages.lines("referral.gui.rules-lore", + "%open-days%", config.openDays(), "%new-days%", config.newAccountDays(), "%afk%", config.afkMinutes())); + } + + private ItemStack rareItem(ReferralData data, Act act) { + Messages messages = messages(); + if (act == null) { + return item(Material.NETHER_STAR, messages.get("referral.gui.no-act"), List.of()); + } + String limit = act.rareLimit() < 0 ? "∞" : Integer.toString(act.rareLimit()); + return item(Material.NETHER_STAR, messages.get("referral.gui.rare", "%used%", data.rareClaims(act.id()), + "%limit%", limit), List.of(messages.get("referral.gui.rare-lore", "%act%", Utils.colorize(act.display())))); + } + + private ItemStack ownMilestoneItem(ReferralData data, Milestone milestone) { + Messages messages = messages(); + Reward reward = milestone.newPlayer(); + if (reward == null) { + return item(Material.LIGHT_GRAY_STAINED_GLASS_PANE, + messages.get("referral.gui.milestone-none", "%hours%", milestone.hours()), List.of()); + } + State state = referrals().ownState(data, milestone); + List lore = rewardLore(reward, milestone); + lore.add(" "); + lore.add(switch (state) { + case CLAIMED -> messages.get("referral.gui.status-claimed"); + case CLAIMABLE -> messages.get("referral.gui.status-claim"); + default -> data.invitedBy() == null ? messages.get("referral.gui.status-unlinked") + : messages.get("referral.gui.status-locked", "%left%", + duration(milestone.minutes() - data.minutes())); + }); + return rewardItem(reward, stateName(state, milestone, reward), lore, state == State.CLAIMABLE); + } + + private ItemStack inviteeItem(ReferralData inviter, UUID inviteeId, Act act) { + Messages messages = messages(); + ReferralData invitee = referrals().getStore().peek(inviteeId); + List lore = new ArrayList<>(); + lore.add(messages.get("referral.gui.invitee-played", "%played%", duration(invitee.minutes()))); + Integer next = null; + if (act != null) { + for (Milestone milestone : act.milestones()) { + Reward reward = milestone.inviter(); + if (reward == null) { + continue; + } + State state = referrals().inviterState(inviter, invitee, act, milestone); + if (state == State.CLAIMABLE && next == null) { + next = milestone.hours(); + } + lore.add(messages.get("referral.gui.invitee-line-" + state.name().toLowerCase(Locale.ROOT).replace('_', '-'), + "%hours%", milestone.hours(), "%reward%", Utils.colorize(reward.display()), + "%left%", duration(milestone.minutes() - invitee.minutes()))); + } + } + lore.add(" "); + lore.add(messages.get(next == null ? "referral.gui.invitee-nothing" : "referral.gui.invitee-click")); + String name = next == null + ? messages.get("referral.gui.invitee", "%name%", invitee.name(), "%played%", duration(invitee.minutes())) + : messages.get("referral.gui.invitee-claimable", "%name%", invitee.name(), + "%played%", duration(invitee.minutes()), "%hours%", next); + ItemStack head = head(inviteeId, name, lore); + if (next != null) { + glint(head); + } + return head; + } + + private String stateName(State state, Milestone milestone, Reward reward) { + String key = switch (state) { + case CLAIMED -> "referral.gui.milestone-claimed"; + case CLAIMABLE -> "referral.gui.milestone-claimable"; + default -> "referral.gui.milestone-locked"; + }; + return messages().get(key, "%hours%", milestone.hours(), "%reward%", Utils.colorize(reward.display())); + } + + /** Exactly what a reward hands over: its items, or every option of the pool it draws from. */ + List rewardLore(Reward reward, Milestone milestone) { + Messages messages = messages(); + List lore = new ArrayList<>(); + if (milestone.rare()) { + lore.add(messages.get("referral.gui.rare-tag")); + } + if (reward.fixed() != null) { + lore.add(messages.get("referral.gui.contents")); + for (RewardEntry.Item item : reward.fixed().items()) { + lore.add(Utils.colorize("#50d990x" + item.amount() + " &f" + ActivityConfiguration.itemName(item.path()))); + } + if (!reward.fixed().commands().isEmpty() && !reward.fixed().display().isBlank()) { + lore.add(Utils.colorize("&f" + reward.fixed().display())); + } + return lore; + } + List pool = referrals().getConfiguration().pool(reward.pool()); + if (pool.isEmpty()) { + lore.add(messages.get("gui.reward-preview-unavailable")); + return lore; + } + lore.add(messages.get("gui.reward-preview-one")); + for (String option : RewardPreview.summarize(pool)) { + lore.add(Utils.colorize(option)); + } + return lore; + } + + private ItemStack rewardItem(Reward reward, String name, List lore, boolean glint) { + ItemStack stack = rewardIcon(reward); + item(stack, name, lore); + if (glint) { + glint(stack); + } + return stack; + } + + ItemStack rewardIcon(Reward reward) { + String path = reward.icon(); + if (path == null && reward.fixed() != null && !reward.fixed().items().isEmpty()) { + path = reward.fixed().items().get(0).path(); + } + if (path != null) { + ItemStack resolved = ActivityManager.resolveRewardItem(path, manager.getConfiguration().itemPathsUsable(), + manager.getConfiguration().itemsAdderUsable(), TLibsItems::item, ItemsAdderItems::item); + if (ActivityManager.usable(resolved)) { + ItemStack icon = resolved.clone(); + icon.setAmount(1); + return icon; + } + } + return new ItemStack(reward.pool() != null ? Material.CHEST : Material.PAPER); + } + + // ---- choosing who invited you -------------------------------------------------------------- + + @SuppressWarnings("deprecation") + Inventory buildPicker(Player player, int page) { + Messages messages = messages(); + List others = new ArrayList<>(); + for (Player online : Bukkit.getOnlinePlayers()) { + if (!online.getUniqueId().equals(player.getUniqueId()) && player.canSee(online)) { + others.add(online); + } + } + others.sort(Comparator.comparing(online -> online.getName().toLowerCase(Locale.ROOT))); + int pages = Math.max(1, (others.size() + PLAYERS_PER_PAGE - 1) / PLAYERS_PER_PAGE); + int shownPage = Math.max(0, Math.min(page, pages - 1)); + + Marker marker = new Marker(View.PICK, shownPage, null); + Inventory inventory = Bukkit.createInventory(marker, SIZE, messages.get("referral.gui.pick-title")); + marker.inventory = inventory; + List slice = others.subList(shownPage * PLAYERS_PER_PAGE, + Math.min(others.size(), (shownPage + 1) * PLAYERS_PER_PAGE)); + for (int index = 0; index < slice.size(); index++) { + Player other = slice.get(index); + marker.shown.add(other.getUniqueId()); + inventory.setItem(index, head(other.getUniqueId(), messages.get("referral.gui.pick-player", + "%name%", other.getName()), List.of(messages.get("referral.gui.pick-player-lore")))); + } + inventory.setItem(BACK_SLOT, item(Material.OAK_DOOR, messages.get("referral.gui.pick-back"), List.of())); + if (shownPage > 0) { + inventory.setItem(PREVIOUS_SLOT, item(Material.ARROW, messages.get("referral.gui.previous"), List.of())); + } + if (shownPage < pages - 1) { + inventory.setItem(NEXT_SLOT, item(Material.ARROW, messages.get("referral.gui.next"), List.of())); + } + inventory.setItem(TYPE_NAME_SLOT, item(Material.OAK_SIGN, messages.get("referral.gui.type-name"), + List.of(messages.get("referral.gui.type-name-lore")))); + fill(inventory); + return inventory; + } + + /** The last step before a permanent link: shows who and asks once more. */ + @SuppressWarnings("deprecation") + public Inventory buildConfirm(UUID inviter) { + Messages messages = messages(); + ReferralData data = referrals().getStore().peek(inviter); + String name = data == null ? "?" : data.name(); + Marker marker = new Marker(View.CONFIRM, 0, inviter); + Inventory inventory = Bukkit.createInventory(marker, CONFIRM_SIZE, messages.get("referral.gui.confirm-title")); + marker.inventory = inventory; + inventory.setItem(CONFIRM_SLOT, item(Material.LIME_CONCRETE, messages.get("referral.gui.confirm", "%name%", name), + List.of(messages.get("referral.gui.confirm-lore")))); + inventory.setItem(CONFIRM_HEAD_SLOT, head(inviter, messages.get("referral.gui.pick-player", "%name%", name), + List.of())); + inventory.setItem(CANCEL_SLOT, item(Material.RED_CONCRETE, messages.get("referral.gui.cancel"), List.of())); + fill(inventory); + return inventory; + } + + /** The command's typed-name route: the same confirm screen the picker leads to. */ + public void confirmTyped(Player player, String name) { + Link refused = referrals().linkable(player.getUniqueId()); + UUID inviter = refused == null ? referrals().find(name) : null; + if (refused == null && inviter == null) { + refused = Link.UNKNOWN; + } else if (refused == null && inviter.equals(player.getUniqueId())) { + refused = Link.SELF; + } + if (refused != null) { + player.sendMessage(messages().get(linkKey(refused), "%name%", name)); + return; + } + player.openInventory(buildConfirm(inviter)); + } + + // ---- clicks ------------------------------------------------------------------------------ + + @EventHandler + public void onClick(InventoryClickEvent event) { + if (!(event.getView().getTopInventory().getHolder() instanceof Marker marker)) { + return; + } + event.setCancelled(true); + if (event.getClickedInventory() != event.getView().getTopInventory() + || !(event.getWhoClicked() instanceof Player player) + || !player.hasPermission("activity.referral")) { + return; + } + int slot = event.getRawSlot(); + switch (marker.view()) { + case MAIN -> clickMain(player, marker, slot); + case PICK -> clickPicker(player, marker, slot); + case CONFIRM -> clickConfirm(player, marker, slot); + } + } + + private void clickMain(Player player, Marker marker, int slot) { + if (slot == BACK_SLOT) { + sound(player); + openActivity.accept(player); + return; + } + if (slot == PREVIOUS_SLOT || slot == NEXT_SLOT) { + sound(player); + player.openInventory(build(player, marker.page() + (slot == NEXT_SLOT ? 1 : -1))); + return; + } + if (slot == INVITER_SLOT) { + Link refused = referrals().linkable(player.getUniqueId()); + if (refused != null) { + player.sendMessage(messages().get(linkKey(refused), "%name%", "")); + return; + } + sound(player); + player.openInventory(buildPicker(player, 0)); + return; + } + int first = MILESTONE_ROW + (9 - marker.hours.size()) / 2; + int index = slot - first; + if (slot >= MILESTONE_ROW && slot < MILESTONE_ROW + 9 && index >= 0 && index < marker.hours.size() + && marker.hours.get(index) != null) { + Claim claim = referrals().claimOwn(player, marker.hours.get(index)); + report(player, claim); + player.openInventory(build(player, marker.page())); + return; + } + int invitee = slot - FIRST_INVITEE_SLOT; + if (invitee >= 0 && invitee < marker.shown.size()) { + List claims = referrals().claimAllForInvitee(player, marker.shown.get(invitee)); + if (claims.isEmpty()) { + player.sendMessage(messages().get("referral.claim.nothing")); + } else { + report(player, claims.get(claims.size() - 1)); + } + player.openInventory(build(player, marker.page())); + } + } + + private void clickPicker(Player player, Marker marker, int slot) { + if (slot == BACK_SLOT) { + sound(player); + player.openInventory(build(player, 0)); + } else if (slot == PREVIOUS_SLOT || slot == NEXT_SLOT) { + sound(player); + player.openInventory(buildPicker(player, marker.page() + (slot == NEXT_SLOT ? 1 : -1))); + } else if (slot == TYPE_NAME_SLOT) { + sound(player); + player.closeInventory(); + String text = messages().get("referral.type-name-chat"); + player.sendMessage(LegacyComponentSerializer.legacySection().deserialize(text) + .clickEvent(ClickEvent.suggestCommand("/referral invitedby "))); + } else if (slot >= 0 && slot < marker.shown.size()) { + sound(player); + player.openInventory(buildConfirm(marker.shown.get(slot))); + } + } + + private void clickConfirm(Player player, Marker marker, int slot) { + if (slot == CANCEL_SLOT) { + sound(player); + player.openInventory(build(player, 0)); + return; + } + if (slot != CONFIRM_SLOT) { + return; + } + Link link = referrals().link(player, marker.target()); + ReferralData inviter = referrals().getStore().peek(marker.target()); + player.sendMessage(messages().get(linkKey(link), "%name%", inviter == null ? "?" : inviter.name())); + if (link == Link.LINKED) { + player.playSound(player.getLocation(), Sound.ENTITY_PLAYER_LEVELUP, 1f, 1f); + Player online = Bukkit.getPlayer(marker.target()); + if (online != null) { + online.sendMessage(messages().get("referral.linked-inviter", "%name%", player.getName())); + } + } + player.openInventory(build(player, 0)); + } + + private void report(Player player, Claim claim) { + if (claim == Claim.CLAIMED) { + String sound = manager.getConfiguration().barCompleteSound(); + if (sound != null) { + player.playSound(player.getLocation(), sound, 1f, 1f); + } + return; + } + player.sendMessage(messages().get("referral.claim." + claim.name().toLowerCase(Locale.ROOT).replace('_', '-'))); + } + + static String linkKey(Link link) { + return "referral.link." + link.name().toLowerCase(Locale.ROOT).replace('_', '-'); + } + + @EventHandler + public void onDrag(InventoryDragEvent event) { + if (!(event.getView().getTopInventory().getHolder() instanceof Marker)) { + return; + } + int size = event.getView().getTopInventory().getSize(); + for (int rawSlot : event.getRawSlots()) { + if (rawSlot < size) { + event.setCancelled(true); + return; + } + } + } + + // ---- items ------------------------------------------------------------------------------- + + static String duration(int minutes) { + int safe = Math.max(0, minutes); + return safe < 60 ? safe + "m" : safe / 60 + "h" + (safe % 60 == 0 ? "" : " " + safe % 60 + "m"); + } + + private ItemStack head(UUID uuid, String name, List lore) { + ItemStack stack = new ItemStack(Material.PLAYER_HEAD); + if (stack.getItemMeta() instanceof SkullMeta skull) { + OfflinePlayer owner = Bukkit.getOfflinePlayer(uuid); + skull.setOwningPlayer(owner); + stack.setItemMeta(skull); + } + return item(stack, name, lore); + } + + private static void glint(ItemStack stack) { + ItemMeta meta = stack.getItemMeta(); + meta.setEnchantmentGlintOverride(true); + stack.setItemMeta(meta); + } + + private void fill(Inventory inventory) { + ItemStack filler = item(Material.GRAY_STAINED_GLASS_PANE, messages().get("gui.filler-name"), List.of()); + for (int slot = 0; slot < inventory.getSize(); slot++) { + if (inventory.getItem(slot) == null) { + inventory.setItem(slot, filler); + } + } + } + + private static ItemStack item(Material material, String name, List lore) { + return item(new ItemStack(material), name, lore); + } + + @SuppressWarnings("deprecation") + private static ItemStack item(ItemStack stack, String name, List lore) { + ItemMeta meta = stack.getItemMeta(); + meta.setDisplayName(name); + meta.setLore(lore); + meta.addItemFlags(ItemFlag.HIDE_ENCHANTS, ItemFlag.HIDE_ATTRIBUTES, ItemFlag.HIDE_STORED_ENCHANTS, + ItemFlag.HIDE_ADDITIONAL_TOOLTIP); + meta.setAttributeModifiers(ImmutableMultimap.of()); + stack.setItemMeta(meta); + return stack; + } + + private static void sound(Player player) { + player.playSound(player.getLocation(), Sound.BLOCK_NOTE_BLOCK_BIT, 1f, 1f); + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/listeners/ReferralListener.java b/src/main/java/net/tfminecraft/activitytf/listeners/ReferralListener.java new file mode 100644 index 0000000..c6b210a --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/listeners/ReferralListener.java @@ -0,0 +1,53 @@ +package net.tfminecraft.activitytf.listeners; + +import io.papermc.paper.event.player.AsyncChatEvent; +import org.bukkit.event.EventHandler; +import org.bukkit.event.EventPriority; +import org.bukkit.event.Listener; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.event.player.PlayerCommandPreprocessEvent; +import org.bukkit.event.player.PlayerJoinEvent; +import org.bukkit.event.player.PlayerQuitEvent; +import net.tfminecraft.activitytf.managers.ReferralManager; + +/** Feeds the referral playtime: joins, quits, and the actions that show someone is not AFK. */ +public class ReferralListener implements Listener { + + private final ReferralManager referrals; + + public ReferralListener(ReferralManager referrals) { + this.referrals = referrals; + } + + @EventHandler + public void onJoin(PlayerJoinEvent event) { + referrals.onJoin(event.getPlayer()); + } + + @EventHandler + public void onQuit(PlayerQuitEvent event) { + referrals.onQuit(event.getPlayer().getUniqueId()); + } + + // Roleplay chat is often cancelled and re-sent by the character plugin; it still means someone typed. + @EventHandler(priority = EventPriority.MONITOR) + public void onChat(AsyncChatEvent event) { + referrals.noteActive(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR) + public void onCommand(PlayerCommandPreprocessEvent event) { + referrals.noteActive(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onBreak(BlockBreakEvent event) { + referrals.noteActive(event.getPlayer().getUniqueId()); + } + + @EventHandler(priority = EventPriority.MONITOR, ignoreCancelled = true) + public void onPlace(BlockPlaceEvent event) { + referrals.noteActive(event.getPlayer().getUniqueId()); + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java b/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java index fa28918..5a484ce 100644 --- a/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java +++ b/src/main/java/net/tfminecraft/activitytf/managers/ActivityManager.java @@ -53,6 +53,7 @@ public class ActivityManager { private final JavaPlugin plugin; private final ActivityConfiguration config; private final PlayerStore store; + private final ReferralManager referrals; private final Set warnedStoreNotLoaded = new HashSet<>(); @@ -80,6 +81,7 @@ private ActivityManager(JavaPlugin plugin) { this.plugin = plugin; this.config = new ActivityConfiguration(plugin); this.store = new PlayerStore(plugin, config); + this.referrals = new ReferralManager(plugin, this); } public static ActivityManager getInstance(JavaPlugin plugin) { @@ -106,6 +108,7 @@ public void initialize() { store.load(); store.startAutoSave(); startPlaytime(); + referrals.initialize(); } private void startPlaytime() { @@ -134,6 +137,7 @@ public void shutdown() { playtime = null; } store.shutdown(); + referrals.shutdown(); instance = null; } @@ -145,6 +149,10 @@ public PlayerStore getStore() { return store; } + public ReferralManager referrals() { + return referrals; + } + public Logger logger() { return plugin.getLogger(); } @@ -575,7 +583,7 @@ static RewardEntry dailyRewardFor(Map groups, Permissible p return null; } - private static RewardEntry draw(List pool) { + static RewardEntry draw(List pool) { int total = RewardEntry.totalWeight(pool); if (total <= 0) { return null; @@ -605,7 +613,7 @@ static List runnableEntries(List pool, String name) { return runnable; } - private boolean dispatchRewards(Player player, RewardEntry entry, String at, int multiplier) { + boolean dispatchRewards(Player player, RewardEntry entry, String at, int multiplier) { return dispatchRewards( () -> giveItems(player, entry, multiplier, at, this::resolveRewardItem, plugin.getLogger()), @@ -623,7 +631,7 @@ private ItemStack resolveRewardItem(String path) { p -> TLibsItems.item(p), id -> ItemsAdderItems.item(id)); } - static ItemStack resolveRewardItem(String path, boolean tlibs, boolean ia, + public static ItemStack resolveRewardItem(String path, boolean tlibs, boolean ia, Function tlibsItems, Function iaItems) { if (ItemPath.isPluginPath(path)) { @@ -637,7 +645,7 @@ static ItemStack resolveRewardItem(String path, boolean tlibs, boolean ia, return material == null ? null : new ItemStack(material); } - static boolean usable(ItemStack stack) { + public static boolean usable(ItemStack stack) { return stack != null && stack.getType() != Material.AIR && isItem(stack); } @@ -883,6 +891,7 @@ public void reload() { ItemsAdderItems.reset(); TLibsItems.reset(); reportedBrokenCommands.clear(); + referrals.reload(); } private void playSound(Player player, String soundKey) { diff --git a/src/main/java/net/tfminecraft/activitytf/managers/ReferralManager.java b/src/main/java/net/tfminecraft/activitytf/managers/ReferralManager.java new file mode 100644 index 0000000..21a9420 --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/managers/ReferralManager.java @@ -0,0 +1,582 @@ +package net.tfminecraft.activitytf.managers; + +import org.bukkit.Bukkit; +import org.bukkit.Location; +import org.bukkit.entity.Player; +import org.bukkit.plugin.java.JavaPlugin; +import org.bukkit.scheduler.BukkitTask; +import net.tfminecraft.activitytf.config.Messages; +import net.tfminecraft.activitytf.config.ReferralConfiguration; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Act; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Milestone; +import net.tfminecraft.activitytf.config.ReferralConfiguration.Reward; +import net.tfminecraft.activitytf.models.ReferralData; +import net.tfminecraft.activitytf.models.RewardEntry; +import net.tfminecraft.activitytf.store.ReferralStore; +import net.tfminecraft.activitytf.utils.Utils; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.LongSupplier; + +/** + * Referral links between a new player and the player who invited them, the AFK-aware playtime that + * unlocks their milestones, and the claims both of them make from the referral menu. + */ +public class ReferralManager { + + private static final long MINUTE_TICKS = 60L * 20L; + private static final long DAY_MILLIS = 24L * 60L * 60L * 1000L; + + public enum Link { + LINKED, + DISABLED, + FAILED, + ALREADY_LINKED, + SELF, + UNKNOWN, + CLOSED, + SAME_IP, + LOOP, + INVITER_FULL, + INVITER_BUSY + } + + public enum Claim { + CLAIMED, + DISABLED, + FAILED, + NOT_LINKED, + NOT_YOURS, + NO_REWARD, + ALREADY_CLAIMED, + LOCKED, + RARE_LIMIT, + WRONG_WORLD, + SAME_IP, + UNCONFIGURED + } + + public enum State { + LOCKED, + CLAIMABLE, + CLAIMED, + RARE_LIMIT + } + + private final JavaPlugin plugin; + private final ActivityManager activity; + private final ReferralConfiguration config; + private final ReferralStore store; + + private final Map lastActive = new ConcurrentHashMap<>(); + private final Map anchors = new ConcurrentHashMap<>(); + private final Set warnedPools = ConcurrentHashMap.newKeySet(); + + private LongSupplier clock = System::currentTimeMillis; + + private BukkitTask timer; + private BukkitTask autoSave; + + ReferralManager(JavaPlugin plugin, ActivityManager activity) { + this(plugin, activity, new ReferralStore(plugin)); + } + + ReferralManager(JavaPlugin plugin, ActivityManager activity, ReferralStore store) { + this.plugin = plugin; + this.activity = activity; + this.config = new ReferralConfiguration(plugin, activity.getConfiguration()); + this.store = store; + } + + public void initialize() { + config.load(); + store.load(config.seasonKey()); + stopTimers(); + timer = plugin.getServer().getScheduler().runTaskTimer(plugin, this::tick, MINUTE_TICKS, MINUTE_TICKS); + long save = activity.getConfiguration().saveIntervalMinutes() * MINUTE_TICKS; + autoSave = plugin.getServer().getScheduler().runTaskTimer(plugin, () -> { + if (store.isDirty()) { + store.saveSoon(); + } + }, save, save); + } + + public void reload() { + config.load(); + warnedPools.clear(); + if (!Objects.equals(store.season(), config.seasonKey())) { + store.saveNow(); + store.load(config.seasonKey()); + } + } + + public void shutdown() { + stopTimers(); + store.shutdown(); + } + + private void stopTimers() { + if (timer != null) { + timer.cancel(); + timer = null; + } + if (autoSave != null) { + autoSave.cancel(); + autoSave = null; + } + } + + public ReferralConfiguration getConfiguration() { + return config; + } + + public ReferralStore getStore() { + return store; + } + + void setClock(LongSupplier clock) { + this.clock = clock; + } + + public long millis() { + return clock.getAsLong(); + } + + public LocalDateTime now() { + return LocalDateTime.ofInstant(Instant.ofEpochMilli(clock.getAsLong()), ZoneId.systemDefault()); + } + + public Act act() { + return config.act(now()); + } + + private Messages messages() { + return config.messages(); + } + + // ---- playtime ---------------------------------------------------------------------------- + + void tick() { + long now = clock.getAsLong(); + for (Player player : Bukkit.getOnlinePlayers()) { + sampleMovement(player, now); + if (counts(player, now)) { + store.get(player.getUniqueId(), player.getName()).addMinute(); + store.markDirty(); + } + } + } + + // A move of at least playtime.move-blocks since the last one counts as being at the keyboard. + void sampleMovement(Player player, long now) { + Location here = player.getLocation(); + Location anchor = anchors.get(player.getUniqueId()); + double move = config.moveBlocks(); + if (anchor == null || anchor.getWorld() != here.getWorld() || anchor.distanceSquared(here) >= move * move) { + anchors.put(player.getUniqueId(), here); + noteActive(player.getUniqueId(), now); + } + } + + /** Chat, commands and building also show someone is at the keyboard. */ + public void noteActive(UUID uuid) { + noteActive(uuid, clock.getAsLong()); + } + + private void noteActive(UUID uuid, long now) { + lastActive.put(uuid, now); + } + + boolean counts(Player player, long now) { + if (!config.enabled() || !seasonStarted() || config.ignoredWorld(player.getWorld().getName())) { + return false; + } + Duration afk = Duration.ofMinutes(config.afkMinutes()); + if (player.getIdleDuration().compareTo(afk) >= 0) { + return false; + } + Long last = lastActive.get(player.getUniqueId()); + return last != null && now - last < afk.toMillis(); + } + + boolean seasonStarted() { + LocalDateTime start = config.seasonStart(); + return start == null || !now().isBefore(start); + } + + public void onJoin(Player player) { + long now = clock.getAsLong(); + ReferralData data = store.get(player.getUniqueId(), player.getName()); + long first = player.getFirstPlayed() > 0 ? player.getFirstPlayed() : now; + if (data.firstSeen() <= 0 || first < data.firstSeen()) { + data.setFirstSeen(first); + } + InetSocketAddress address = player.getAddress(); + if (address != null && address.getAddress() != null) { + data.addIp(store.ipHash(address.getAddress().getHostAddress())); + } + store.markDirty(); + anchors.put(player.getUniqueId(), player.getLocation()); + noteActive(player.getUniqueId(), now); + if (config.enabled() && hasClaimable(player.getUniqueId())) { + player.sendMessage(messages().get("referral.reward-ready")); + } + } + + public void onQuit(UUID uuid) { + lastActive.remove(uuid); + anchors.remove(uuid); + } + + // ---- linking ----------------------------------------------------------------------------- + + /** Why this account cannot pick an inviter right now, or null when it can. */ + public Link linkable(UUID uuid) { + if (!config.enabled()) { + return Link.DISABLED; + } + ReferralData data = store.peek(uuid); + if (data != null && data.invitedBy() != null) { + return Link.ALREADY_LINKED; + } + return inWindow(data) ? null : Link.CLOSED; + } + + /** The season's opening days, or the account's own first days on the server. */ + boolean inWindow(ReferralData data) { + LocalDateTime start = config.seasonStart(); + LocalDateTime now = now(); + if (start != null && !now.isBefore(start) && now.isBefore(start.plusDays(config.openDays()))) { + return true; + } + return data != null && data.firstSeen() > 0 + && clock.getAsLong() - data.firstSeen() < config.newAccountDays() * DAY_MILLIS; + } + + public Link link(Player player, UUID inviterId) { + UUID uuid = player.getUniqueId(); + Link refused = linkable(uuid); + if (refused == null) { + refused = linkRefusal(uuid, inviterId); + } + if (refused != null) { + audit(player, "action=link inviter=" + inviterId + " result=" + refused.name().toLowerCase(Locale.ROOT)); + return refused; + } + + ReferralData data = store.get(uuid, player.getName()); + data.link(inviterId, clock.getAsLong()); + store.markDirty(); + if (!store.saveNow()) { + data.link(null, 0); + store.markDirty(); + audit(player, "action=link inviter=" + inviterId + " result=save-failed"); + return Link.FAILED; + } + audit(player, "action=link inviter=" + inviterId + " inviter-name=" + + Utils.quotedForLog(store.peek(inviterId).name()) + " result=done"); + return Link.LINKED; + } + + private Link linkRefusal(UUID uuid, UUID inviterId) { + if (!store.isLoaded()) { + return Link.FAILED; + } + if (uuid.equals(inviterId)) { + return Link.SELF; + } + ReferralData inviter = inviterId == null ? null : store.peek(inviterId); + if (inviter == null) { + return Link.UNKNOWN; + } + if (leadsTo(inviterId, uuid)) { + return Link.LOOP; + } + ReferralData data = store.peek(uuid); + if (config.ipCheck() && data != null && data.sharesIp(inviter)) { + return Link.SAME_IP; + } + List invited = invitees(inviterId); + if (config.maxInvitees() > 0 && invited.size() >= config.maxInvitees()) { + return Link.INVITER_FULL; + } + long since = clock.getAsLong() - DAY_MILLIS; + long today = invited.stream().filter(id -> store.peek(id).linkedAt() > since).count(); + if (config.maxLinksPerDay() > 0 && today >= config.maxLinksPerDay()) { + return Link.INVITER_BUSY; + } + return null; + } + + /** True when following "invited by" up from {@code start} reaches {@code target}. */ + boolean leadsTo(UUID start, UUID target) { + Set seen = new HashSet<>(); + UUID current = start; + while (current != null && seen.add(current)) { + ReferralData data = store.peek(current); + current = data == null ? null : data.invitedBy(); + if (target.equals(current)) { + return true; + } + } + return false; + } + + /** Everyone linked to this inviter, by name. */ + public List invitees(UUID inviter) { + List invited = new ArrayList<>(); + for (Map.Entry entry : store.all().entrySet()) { + if (inviter.equals(entry.getValue().invitedBy())) { + invited.add(entry.getKey()); + } + } + invited.sort(Comparator.comparing(id -> String.valueOf(store.peek(id).name()).toLowerCase(Locale.ROOT))); + return invited; + } + + /** Typed name to account: someone who has played this season. */ + public UUID find(String name) { + Player online = Bukkit.getPlayerExact(name); + if (online != null && store.peek(online.getUniqueId()) != null) { + return online.getUniqueId(); + } + return store.byName(name); + } + + public boolean unlink(UUID uuid) { + ReferralData data = store.peek(uuid); + if (data == null || data.invitedBy() == null) { + return false; + } + data.link(null, 0); + store.markDirty(); + store.saveNow(); + return true; + } + + // ---- milestones -------------------------------------------------------------------------- + + public static Milestone milestone(Act act, int hours) { + if (act != null) { + for (Milestone milestone : act.milestones()) { + if (milestone.hours() == hours) { + return milestone; + } + } + } + return null; + } + + public State ownState(ReferralData data, Milestone milestone) { + if (data.claimed().contains(milestone.hours())) { + return State.CLAIMED; + } + return data.invitedBy() != null && data.minutes() >= milestone.minutes() ? State.CLAIMABLE : State.LOCKED; + } + + public State inviterState(ReferralData inviter, ReferralData invitee, Act act, Milestone milestone) { + if (invitee.inviterClaimed().contains(milestone.hours())) { + return State.CLAIMED; + } + if (invitee.minutes() < milestone.minutes()) { + return State.LOCKED; + } + return rareLimitReached(inviter, act, milestone) ? State.RARE_LIMIT : State.CLAIMABLE; + } + + private static boolean rareLimitReached(ReferralData inviter, Act act, Milestone milestone) { + return milestone.rare() && act.rareLimit() >= 0 && inviter.rareClaims(act.id()) >= act.rareLimit(); + } + + /** True when this account has any milestone, its own or an invitee's, it could claim now. */ + public boolean hasClaimable(UUID uuid) { + Act act = act(); + ReferralData data = store.peek(uuid); + if (act == null || data == null) { + return false; + } + for (Milestone milestone : act.milestones()) { + if (milestone.newPlayer() != null && ownState(data, milestone) == State.CLAIMABLE) { + return true; + } + } + for (UUID invitee : invitees(uuid)) { + for (Milestone milestone : act.milestones()) { + if (milestone.inviter() != null + && inviterState(data, store.peek(invitee), act, milestone) == State.CLAIMABLE) { + return true; + } + } + } + return false; + } + + public Claim claimOwn(Player player, int hours) { + if (!config.enabled()) { + return Claim.DISABLED; + } + if (!store.isLoaded()) { + return Claim.FAILED; + } + ReferralData data = store.get(player.getUniqueId(), player.getName()); + if (data.invitedBy() == null) { + return Claim.NOT_LINKED; + } + Milestone milestone = milestone(act(), hours); + if (milestone == null || milestone.newPlayer() == null) { + return Claim.NO_REWARD; + } + if (data.claimed().contains(hours)) { + return Claim.ALREADY_CLAIMED; + } + if (data.minutes() < milestone.minutes()) { + return Claim.LOCKED; + } + Claim refused = placeAndConnection(player, data, store.peek(data.invitedBy())); + if (refused != null) { + return refused; + } + return pay(player, milestone.newPlayer(), "referral " + hours + "h", + () -> data.claimed().add(hours), () -> data.claimed().remove(hours)); + } + + public Claim claimForInvitee(Player player, UUID inviteeId, int hours) { + if (!config.enabled()) { + return Claim.DISABLED; + } + if (!store.isLoaded()) { + return Claim.FAILED; + } + ReferralData invitee = store.peek(inviteeId); + if (invitee == null || !player.getUniqueId().equals(invitee.invitedBy())) { + return Claim.NOT_YOURS; + } + Act act = act(); + Milestone milestone = milestone(act, hours); + if (milestone == null || milestone.inviter() == null) { + return Claim.NO_REWARD; + } + ReferralData data = store.get(player.getUniqueId(), player.getName()); + switch (inviterState(data, invitee, act, milestone)) { + case CLAIMED -> { + return Claim.ALREADY_CLAIMED; + } + case LOCKED -> { + return Claim.LOCKED; + } + case RARE_LIMIT -> { + return Claim.RARE_LIMIT; + } + case CLAIMABLE -> { + } + } + Claim refused = placeAndConnection(player, data, invitee); + if (refused != null) { + return refused; + } + int rareDelta = milestone.rare() ? 1 : 0; + return pay(player, milestone.inviter(), "referral " + hours + "h for " + inviteeId, + () -> { + invitee.inviterClaimed().add(hours); + data.setRareClaims(act.id(), data.rareClaims(act.id()) + rareDelta); + }, + () -> { + invitee.inviterClaimed().remove(hours); + data.setRareClaims(act.id(), data.rareClaims(act.id()) - rareDelta); + }); + } + + /** Claims every milestone of one invitee that can be claimed now, stopping at the first refusal. */ + public List claimAllForInvitee(Player player, UUID inviteeId) { + List results = new ArrayList<>(); + Act act = act(); + ReferralData invitee = store.peek(inviteeId); + if (act == null || invitee == null) { + return results; + } + ReferralData data = store.get(player.getUniqueId(), player.getName()); + for (Milestone milestone : act.milestones()) { + if (milestone.inviter() != null && inviterState(data, invitee, act, milestone) == State.CLAIMABLE) { + Claim claim = claimForInvitee(player, inviteeId, milestone.hours()); + results.add(claim); + if (claim != Claim.CLAIMED) { + break; + } + } + } + return results; + } + + // Rewards go to the character being played, never to the character-selection room, and are + // held for accounts that have been seen on the same connection. + private Claim placeAndConnection(Player player, ReferralData mine, ReferralData other) { + if (config.ignoredWorld(player.getWorld().getName())) { + return Claim.WRONG_WORLD; + } + if (config.ipCheck() && other != null && mine.sharesIp(other)) { + return Claim.SAME_IP; + } + return null; + } + + private Claim pay(Player player, Reward reward, String at, Runnable apply, Runnable revert) { + RewardEntry entry = resolve(reward, player.getName()); + if (entry == null) { + audit(player, "action=claim at=" + Utils.quotedForLog(at) + " result=unconfigured"); + return Claim.UNCONFIGURED; + } + + apply.run(); + store.markDirty(); + if (!store.saveNow()) { + revert.run(); + store.markDirty(); + audit(player, "action=claim at=" + Utils.quotedForLog(at) + " result=save-failed"); + return Claim.FAILED; + } + if (!activity.dispatchRewards(player, entry, at, 1)) { + revert.run(); + store.markDirty(); + store.saveSoon(); + audit(player, "action=claim at=" + Utils.quotedForLog(at) + " result=pay-failed"); + return Claim.FAILED; + } + String shown = entry.display() == null || entry.display().isBlank() ? reward.display() : entry.display(); + player.sendMessage(messages().get("referral.claimed", "%reward%", Utils.colorize(shown))); + audit(player, "action=claim at=" + Utils.quotedForLog(at) + " reward=" + Utils.quotedForLog(shown) + + " result=done"); + return Claim.CLAIMED; + } + + RewardEntry resolve(Reward reward, String name) { + if (reward.fixed() != null) { + return reward.fixed(); + } + List pool = config.pool(reward.pool()); + RewardEntry drawn = ActivityManager.draw(ActivityManager.runnableEntries(pool, name)); + if (drawn == null && warnedPools.add(reward.pool())) { + plugin.getLogger().warning("Referral pool '" + Utils.safeForLog(reward.pool()) + "' has no entry that" + + " can be paid to '" + Utils.safeForLog(name) + "' - the milestone stays claimable."); + } + return drawn; + } + + private void audit(Player player, String what) { + plugin.getLogger().info("REFERRAL-AUDIT player=" + Utils.quotedForLog(player.getName()) + " uuid=" + + player.getUniqueId() + " " + what); + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/models/ReferralData.java b/src/main/java/net/tfminecraft/activitytf/models/ReferralData.java new file mode 100644 index 0000000..b0adfaa --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/models/ReferralData.java @@ -0,0 +1,129 @@ +package net.tfminecraft.activitytf.models; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeSet; +import java.util.UUID; + +/** One account's referral state for the current season. */ +public class ReferralData { + + /** Most connection hashes kept per account; the oldest is dropped first. */ + public static final int MAX_IPS = 16; + + private String name; + private int minutes; + private long firstSeen; + private UUID invitedBy; + private long linkedAt; + private final Set claimed = new TreeSet<>(); + private final Set inviterClaimed = new TreeSet<>(); + private final LinkedHashSet ips = new LinkedHashSet<>(); + private final Map rareClaims = new HashMap<>(); + + public ReferralData(String name, long firstSeen) { + this.name = name; + this.firstSeen = firstSeen; + } + + public String name() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int minutes() { + return minutes; + } + + public void setMinutes(int minutes) { + this.minutes = Math.max(0, minutes); + } + + public void addMinute() { + if (minutes < Integer.MAX_VALUE) { + minutes++; + } + } + + public long firstSeen() { + return firstSeen; + } + + public void setFirstSeen(long firstSeen) { + this.firstSeen = firstSeen; + } + + public UUID invitedBy() { + return invitedBy; + } + + public long linkedAt() { + return linkedAt; + } + + public void link(UUID inviter, long at) { + this.invitedBy = inviter; + this.linkedAt = inviter == null ? 0 : at; + } + + /** Milestone hours this account has claimed as the invited player. */ + public Set claimed() { + return claimed; + } + + /** Milestone hours the inviter has claimed for this account. */ + public Set inviterClaimed() { + return inviterClaimed; + } + + public Set ips() { + return ips; + } + + /** Remembers a connection hash as the most recent one. */ + public boolean addIp(String hash) { + boolean added = !ips.remove(hash); + ips.add(hash); + while (ips.size() > MAX_IPS) { + ips.remove(ips.iterator().next()); + } + return added; + } + + public boolean sharesIp(ReferralData other) { + for (String ip : other.ips) { + if (ips.contains(ip)) { + return true; + } + } + return false; + } + + /** Rare inviter rewards claimed, per act. */ + public Map rareClaims() { + return rareClaims; + } + + public int rareClaims(String act) { + return rareClaims.getOrDefault(act, 0); + } + + public void setRareClaims(String act, int count) { + if (count <= 0) { + rareClaims.remove(act); + } else { + rareClaims.put(act, count); + } + } + + public List ipList() { + return new ArrayList<>(ips); + } +} diff --git a/src/main/java/net/tfminecraft/activitytf/store/ReferralStore.java b/src/main/java/net/tfminecraft/activitytf/store/ReferralStore.java new file mode 100644 index 0000000..40bc430 --- /dev/null +++ b/src/main/java/net/tfminecraft/activitytf/store/ReferralStore.java @@ -0,0 +1,279 @@ +package net.tfminecraft.activitytf.store; + +import org.bukkit.configuration.ConfigurationSection; +import org.bukkit.configuration.file.YamlConfiguration; +import org.bukkit.plugin.java.JavaPlugin; +import net.tfminecraft.activitytf.models.ReferralData; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.AtomicMoveNotSupportedException; +import java.nio.file.Files; +import java.nio.file.StandardCopyOption; +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; + +/** referrals.yml: every account's counted playtime, link and claims for one season. */ +public class ReferralStore { + + public static final String FILE = "referrals.yml"; + + private final JavaPlugin plugin; + private final File file; + private final Map players = new ConcurrentHashMap<>(); + private final Object saveLock = new Object(); + + private volatile String season = ""; + private volatile String salt = ""; + private volatile boolean dirty; + private volatile boolean loaded; + private volatile boolean shuttingDown; + + private final AtomicLong saveSeq = new AtomicLong(); + private long lastWritten; + + public ReferralStore(JavaPlugin plugin) { + this(plugin, new File(plugin.getDataFolder(), FILE)); + } + + ReferralStore(JavaPlugin plugin, File file) { + this.plugin = plugin; + this.file = file; + } + + /** Reads the file; a file from another season is moved aside and this one starts empty. */ + public void load(String currentSeason) { + players.clear(); + loaded = false; + shuttingDown = false; + season = currentSeason; + salt = ""; + if (file.exists()) { + YamlConfiguration yaml = new YamlConfiguration(); + try { + yaml.load(file); + } catch (Exception e) { + plugin.getLogger().severe(FILE + " could not be parsed (" + e.getMessage() + ") - referral saving" + + " is off until an operator fixes or moves it aside."); + return; + } + salt = yaml.getString("salt", ""); + String stored = yaml.getString("season", ""); + if (!stored.equals(currentSeason)) { + if (!archive(stored)) { + return; + } + } else { + read(yaml.getConfigurationSection("players")); + } + } + if (salt.isEmpty()) { + byte[] bytes = new byte[16]; + new SecureRandom().nextBytes(bytes); + salt = HexFormat.of().formatHex(bytes); + dirty = true; + } + loaded = true; + } + + private boolean archive(String stored) { + String tag = stored.isEmpty() ? "unset" : stored.replaceAll("[^0-9A-Za-z-]", "-"); + File old = new File(file.getParentFile(), "referrals-" + tag + "-" + System.currentTimeMillis() + ".yml"); + try { + Files.move(file.toPath(), old.toPath()); + plugin.getLogger().info("The referral season changed (" + stored + " -> " + season + ") - the old links" + + " are kept in " + old.getName() + " and this season starts empty."); + return true; + } catch (IOException e) { + plugin.getLogger().severe("The referral season changed, but " + FILE + " could not be moved aside (" + + e.getMessage() + ") - referral saving is off."); + return false; + } + } + + private void read(ConfigurationSection root) { + if (root == null) { + return; + } + for (String key : root.getKeys(false)) { + ConfigurationSection entry = root.getConfigurationSection(key); + UUID uuid = uuid(key); + if (entry == null || uuid == null) { + plugin.getLogger().warning("Skipping malformed entry '" + key + "' in " + FILE); + continue; + } + players.put(uuid, parse(entry)); + } + } + + static ReferralData parse(ConfigurationSection entry) { + ReferralData data = new ReferralData(entry.getString("name", ""), entry.getLong("first-seen")); + data.setMinutes(entry.getInt("minutes")); + UUID inviter = uuid(entry.getString("invited-by", "")); + if (inviter != null) { + data.link(inviter, entry.getLong("linked-at")); + } + data.claimed().addAll(entry.getIntegerList("claimed")); + data.inviterClaimed().addAll(entry.getIntegerList("inviter-claimed")); + for (String ip : entry.getStringList("ips")) { + data.addIp(ip); + } + ConfigurationSection rare = entry.getConfigurationSection("rare"); + if (rare != null) { + for (String act : rare.getKeys(false)) { + data.setRareClaims(act, rare.getInt(act)); + } + } + return data; + } + + private static UUID uuid(String value) { + try { + return UUID.fromString(value); + } catch (IllegalArgumentException e) { + return null; + } + } + + public ReferralData get(UUID uuid, String name) { + ReferralData data = players.computeIfAbsent(uuid, id -> { + dirty = true; + return new ReferralData(name, 0); + }); + if (name != null && !name.equals(data.name())) { + data.setName(name); + dirty = true; + } + return data; + } + + public ReferralData peek(UUID uuid) { + return players.get(uuid); + } + + public Map all() { + return players; + } + + /** The account a name was last seen on, ignoring case; null when nobody this season used it. */ + public UUID byName(String name) { + for (Map.Entry entry : players.entrySet()) { + if (entry.getValue().name() != null && entry.getValue().name().equalsIgnoreCase(name)) { + return entry.getKey(); + } + } + return null; + } + + /** Salted hash of a connection address, so the file never holds a raw IP. */ + public String ipHash(String address) { + return UUID.nameUUIDFromBytes((salt + "|" + address).getBytes(StandardCharsets.UTF_8)).toString(); + } + + public String season() { + return season; + } + + public boolean isLoaded() { + return loaded; + } + + public void markDirty() { + dirty = true; + } + + public boolean isDirty() { + return dirty; + } + + public void saveSoon() { + if (shuttingDown) { + return; + } + YamlConfiguration snapshot = snapshot(); + long seq = saveSeq.incrementAndGet(); + dirty = false; + if (!plugin.isEnabled()) { + write(snapshot, seq); + return; + } + plugin.getServer().getScheduler().runTaskAsynchronously(plugin, () -> write(snapshot, seq)); + } + + public boolean saveNow() { + if (!write(snapshot(), saveSeq.incrementAndGet())) { + return false; + } + dirty = false; + return true; + } + + public void shutdown() { + shuttingDown = true; + if (loaded) { + saveNow(); + } + } + + YamlConfiguration snapshot() { + YamlConfiguration yaml = new YamlConfiguration(); + yaml.options().setHeader(List.of("Referral links and counted playtime. Written by the plugin - do not edit" + + " while the server runs.")); + yaml.set("season", season); + yaml.set("salt", salt); + yaml.createSection("players"); + for (Map.Entry entry : players.entrySet()) { + ReferralData data = entry.getValue(); + String path = "players." + entry.getKey(); + yaml.set(path + ".name", data.name()); + yaml.set(path + ".minutes", data.minutes()); + yaml.set(path + ".first-seen", data.firstSeen()); + if (data.invitedBy() != null) { + yaml.set(path + ".invited-by", data.invitedBy().toString()); + yaml.set(path + ".linked-at", data.linkedAt()); + } + yaml.set(path + ".claimed", new ArrayList<>(data.claimed())); + yaml.set(path + ".inviter-claimed", new ArrayList<>(data.inviterClaimed())); + yaml.set(path + ".ips", data.ipList()); + if (!data.rareClaims().isEmpty()) { + yaml.createSection(path + ".rare", data.rareClaims()); + } + } + return yaml; + } + + private boolean write(YamlConfiguration snapshot, long seq) { + synchronized (saveLock) { + if (!loaded) { + plugin.getLogger().warning("Not writing " + FILE + ": it was never loaded."); + return false; + } + if (seq < lastWritten) { + return false; + } + lastWritten = seq; + File temp = new File(file.getParentFile(), FILE + ".tmp"); + try { + snapshot.save(temp); + try { + Files.move(temp.toPath(), file.toPath(), + StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); + } catch (AtomicMoveNotSupportedException e) { + Files.move(temp.toPath(), file.toPath(), StandardCopyOption.REPLACE_EXISTING); + } + return true; + } catch (IOException e) { + plugin.getLogger().severe("Failed to write " + FILE + ": " + e.getMessage()); + dirty = true; + return false; + } + } + } +} diff --git a/src/main/resources/messages.yml b/src/main/resources/messages.yml index 0a1b091..5ec0aae 100644 --- a/src/main/resources/messages.yml +++ b/src/main/resources/messages.yml @@ -32,6 +32,8 @@ gui: reroll-lore-locked: "#823FFF&l[#8939EE&lA#9033DD&ls#972DCC&lc#9E27BB&le#A620AB&ln#AD1A9A&ld#B41489&le#BB0E78&ld#C20867&l]&7 perk - click to find out more" reroll-lore-disabled: "&8Rerolling is turned off" filler-name: "&8 " + referral-name: "#e6ca40&lReferrals" + referral-lore: "&7Who invited you, and the players you invited" placeholder: { done: "&a✔", not-done: "&c✘" } admin: usage: "&eUsage: /activity |reset |givereroll |add [--force]>" @@ -74,3 +76,100 @@ admin: check-no-tasks: "&7 No tasks drawn yet today." check-task-revealed: "&7 %slot%. &a%activity%&7 - revealed, count &f%count%&7, that count is worth &f%points%&7 points before any cap" check-task-hidden: "&7 %slot%. &8%activity%&7 - hidden" +referral: + usage: "&eUsage: /referral &7(opens the menu)&e, /referral invitedby " + usage-admin: "&eStaff: /referral info | unlink | addtime " + reward-ready: "&a&lREFERRAL REWARD READY! &7Open &f/referral &7to claim." + claimed: "&a&lREFERRAL &7You received %reward%&7." + linked-inviter: "&a&lREFERRAL &f%name% &7says you invited them. Their playtime now earns you rewards in &f/referral&7." + type-name-chat: "&e» Click here, then type the name of the player who invited you." + link: + linked: "&a&lREFERRAL &7You are now linked to &f%name%&7. Rewards unlock as you play - see &f/referral&7." + disabled: "&cReferrals are turned off right now." + failed: "&cThe link could not be saved - tell an admin." + already-linked: "&cYour account is already linked - a link can't be changed." + self: "&cYou can't invite yourself." + unknown: "&cNobody called &f%name% &chas played this season." + closed: "&cLinking is only open in the first days of the season, or in an account's first week." + same-ip: "&cThat account has played from the same connection as yours, so it can't be your inviter." + loop: "&cYou invited that player, so they can't have invited you." + inviter-full: "&cThat player has invited as many players as a season allows." + inviter-busy: "&cThat player has been picked by several new players today - try again tomorrow." + claim: + disabled: "&cReferrals are turned off right now." + failed: "&cThat reward could not be handed over - tell an admin." + not-linked: "&cPick who invited you first - your rewards unlock once you are linked." + not-yours: "&cThat player is not one you invited." + no-reward: "&cThere is no reward there this act." + already-claimed: "&7Already claimed." + locked: "&cNot played long enough for that one yet." + rare-limit: "&cYou have claimed every rare inviter reward this act allows - it waits for the next act." + wrong-world: "&cPlay a character first - rewards go to the character you are playing." + same-ip: "&cThese accounts have played from the same connection, so their referral rewards are held. Ask staff." + unconfigured: "&cThat reward is not set up - tell an admin." + nothing: "&7Nothing to claim for that player yet." + admin: + unknown: "&cNobody called &f%name% &chas played this season." + info: "&e%name%&7: &f%hours%h &7(%minutes% min) counted, invited by &f%inviter%&7, claimed &f%claimed%&7, invited: &f%invited%" + unlinked: "&aUnlinked %name% - they can pick who invited them again." + not-linked: "&e%name% is not linked." + time-added: "&a%name% now has &f%minutes%&a counted minutes." + gui: + title: "&8Referral" + who-invited: "#e6ca40&lWho invited you?" + who-invited-lore: "&7Click to pick the player who brought you here." + invited-by: "#e6ca40Invited by&e: &f%name%" + invited-by-lore: "&7Links can't be changed." + link-closed: "&7No inviter" + progress: "#d97b66Your progress&e: &6%played% &7/ &f%goal%" + progress-lore: "&7Counted playtime on this account. AFK time doesn't count." + progress-unlinked: "&7Your playtime counts already - pick who invited you to claim." + rules: "#e6ca40&lRules" + rules-lore: + - "&7Rewards unlock with the new player's playtime." + - "&7Playtime counts per account; you claim each" + - "&7reward on the character you are playing." + - "&7AFK time doesn't count (nothing done for &f%afk% &7min)." + - "&7One link per account, and it can't be changed." + - "&7Anyone can link in the season's first &f%open-days% &7days," + - "&7after that only in an account's first &f%new-days% &7days." + - "&7Accounts on the same connection can't link," + - "&7and two players can't invite each other." + - "&7Each act has its own rewards." + no-act: "&7No referral rewards are set up yet" + invited-header: "#e6ca40&lPlayers you invited &7(%count%)" + invited-header-lore: "&7Click a head to claim what their playtime unlocked." + back: "#e6ca40Weekly Activity" + previous: "&7Previous page" + next: "&7Next page" + rare: "#c26ef0Rare rewards this act&e: &6%used% &7/ &f%limit%" + rare-lore: "&7Rare inviter rewards you can claim during %act%&7." + rare-tag: "#c26ef0&lRare" + contents: "&7Contains:" + milestone-none: "&8%hours%h - nothing for you here" + milestone-claimed: "&a✔ %hours%h &7- %reward%" + milestone-claimable: "&a&l%hours%h &a- %reward%" + milestone-locked: "&7%hours%h - %reward%" + status-claimed: "&aClaimed" + status-claim: "#6b9c68[#fafa16Click to claim#6b9c68]" + status-locked: "&8Locked - &7%left% &8more to play" + status-unlinked: "&8Locked - pick who invited you first" + invitee: "&f%name% &7- %played%" + invitee-claimable: "&f%name% &7- %played% &a[CLAIM %hours%h]" + invitee-played: "&7Counted playtime&e: &6%played%" + invitee-line-claimed: "&a✔ %hours%h &7%reward%" + invitee-line-claimable: "&e» %hours%h &f%reward% &a(claim)" + invitee-line-locked: "&8✖ %hours%h &7%reward% &8(%left% to go)" + invitee-line-rare-limit: "&c✖ %hours%h &7%reward% &c(act limit reached)" + invitee-click: "#6b9c68[#fafa16Click to claim#6b9c68]" + invitee-nothing: "&8Nothing to claim yet" + pick-title: "&8Who invited you?" + pick-player: "&f%name%" + pick-player-lore: "&7Click to pick this player." + pick-back: "&7Back" + type-name: "#e6ca40Type a name" + type-name-lore: "&7For a player who is not online." + confirm-title: "&8Confirm your inviter" + confirm: "&a&lYes, %name% invited me" + confirm-lore: "&cThis can't be changed later." + cancel: "&cCancel" diff --git a/src/main/resources/plugin.yml b/src/main/resources/plugin.yml index c381666..18ead77 100644 --- a/src/main/resources/plugin.yml +++ b/src/main/resources/plugin.yml @@ -10,6 +10,10 @@ commands: activity: description: Open the weekly activity bar, or run admin subcommands usage: /activity [reload|rewards [apply]|check |reset |givereroll |add [--force]|addpoints ] + referral: + description: Open the referral menu, pick who invited you, or run referral staff subcommands + usage: /referral [invitedby |info |unlink |addtime ] + aliases: [referrals] permissions: activity.use: description: Open the weekly activity GUI @@ -20,8 +24,15 @@ permissions: activity.check: description: Inspect another player's activity state default: op + activity.referral: + description: Open the referral menu and claim referral rewards + default: true + activity.referral.admin: + description: Inspect, unlink and adjust referral playtime + default: op activity.admin: description: Access to activity admin subcommands default: op children: activity.check: true + activity.referral.admin: true diff --git a/src/main/resources/referral.yml b/src/main/resources/referral.yml new file mode 100644 index 0000000..4b35753 --- /dev/null +++ b/src/main/resources/referral.yml @@ -0,0 +1,146 @@ +# Referrals: /referral (also a button in /activity). +# +# A new player opens /referral, clicks "Who invited you?" and picks an online +# player or types a name (/referral invitedby ), then confirms. That is +# one link per account, and it can never be changed by the player (staff can +# undo it with /referral unlink ). Both players then claim rewards from +# the same menu as the NEW player's counted playtime passes each milestone. +# +# /activity reload reloads this file too. + +# Off = nobody can link or claim, and no playtime is counted. The kill switch. +enabled: true + +season: + # When the season started, in server time: 2026-10-01 or 2026-10-01T06:10. + # Playtime only counts from here on, and changing it starts a new season: + # referrals.yml is moved aside (referrals--